Files
localsend/app/lib/provider/network/server/controller/receive_controller.dart
T
Tien Do Nam 0aea05ac85
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled
feat: implement CLI
2026-07-30 05:28:48 +02:00

726 lines
26 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:localsend_app/model/state/server/receive_session_state.dart';
import 'package:localsend_app/model/state/server/receiving_file.dart';
import 'package:localsend_app/pages/home_page.dart';
import 'package:localsend_app/pages/home_page_controller.dart';
import 'package:localsend_app/pages/progress_page.dart';
import 'package:localsend_app/pages/receive_page.dart';
import 'package:localsend_app/provider/device_info_provider.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/http_provider.dart';
import 'package:localsend_app/provider/logging/discovery_logs_provider.dart';
import 'package:localsend_app/provider/network/nearby_devices_provider.dart';
import 'package:localsend_app/provider/network/send_provider.dart';
import 'package:localsend_app/provider/network/server/server_provider.dart';
import 'package:localsend_app/provider/network/server/server_utils.dart';
import 'package:localsend_app/provider/progress_provider.dart';
import 'package:localsend_app/provider/receive_history_provider.dart';
import 'package:localsend_app/provider/security_provider.dart';
import 'package:localsend_app/provider/selection/selected_receiving_files_provider.dart';
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/native/directories.dart';
import 'package:localsend_app/util/native/platform_check.dart';
import 'package:localsend_app/util/native/tray_helper.dart';
import 'package:localsend_app/widget/dialogs/open_file_dialog.dart';
import 'package:localsend_isolates/isolate.dart';
import 'package:localsend_isolates/model/device.dart';
import 'package:localsend_isolates/model/file_status.dart';
import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/model/session_status.dart';
import 'package:localsend_isolates/rust/api/server.dart' show SessionEndReasonV2;
import 'package:localsend_isolates/util/rust.dart';
import 'package:localsend_isolates/util/transfer_notification.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
import 'package:uuid/uuid.dart';
import 'package:window_manager/window_manager.dart';
final _logger = Logger('ReceiveController');
/// Handles all server events for receiving files.
/// The HTTP requests themselves are served by the Rust server which emits
/// the events handled here.
class ReceiveController {
final ServerUtils server;
ReceiveController(this.server);
/// A device registered itself on this server.
Future<void> onRegister(HttpServerRegisterEvent event) async {
if (event.info.fingerprint == server.ref.read(securityProvider).certificateHash) {
// "I talked to myself lol"
return;
}
await server.ref.redux(nearbyDevicesProvider).dispatchAsync(RegisterDeviceAction(event.info.toDevice(event.ip, HttpDiscovery(ip: event.ip))));
server.ref.notifier(discoveryLoggerProvider).addLog('[DISCOVER/TCP] Received "/register" HTTP request: ${event.info.alias} (${event.ip})');
}
/// A sender requests to upload files.
/// The Rust server already checked the PIN and enforces that only one
/// session can be active at a time.
Future<void> onPrepareUpload(HttpServerPrepareUploadEvent event) async {
if (server.getStateOrNull()?.session != null) {
// The Rust server is the authority on the single-session invariant:
// a new request means the old session is over (e.g. finished but still
// displayed, or aborted while waiting).
closeSession();
}
final settings = server.ref.read(settingsProvider);
final destinationDir = settings.destination ?? await getDefaultDestinationDirectory();
final cacheDir = await getCacheDirectory();
final sessionId = event.sessionId;
final files = {
for (final entry in event.files.entries) entry.key: entry.value.toDart(),
};
// The fingerprint of the sender's mTLS certificate cannot be spoofed, unlike the
// self-reported fingerprint in the JSON payload which is only used as fallback
// when encryption is disabled.
final senderFingerprint = event.certFingerprint ?? event.info.fingerprint;
_logger.info('Session Id: $sessionId');
_logger.info('Destination Directory: $destinationDir');
server.setState(
(oldState) => oldState?.copyWith(
session: ReceiveSessionState(
sessionId: sessionId,
status: SessionStatus.waiting,
sender: event.info.toDevice(event.ip, null).copyWith(fingerprint: senderFingerprint),
senderAlias: server.ref.read(favoritesProvider).firstWhereOrNull((e) => e.fingerprint == senderFingerprint)?.alias ?? event.info.alias,
files: {
for (final file in files.values)
file.id: ReceivingFile(
file: file,
status: FileStatus.queue,
token: null,
desiredName: null,
path: null,
savedToGallery: false,
errorMessage: null,
),
},
startTime: null,
endTime: null,
destinationDirectory: destinationDir,
cacheDirectory: cacheDir,
saveToGallery: checkPlatformWithGallery() && settings.saveToGallery && files.values.every((f) => !f.fileName.contains('/')),
createdDirectories: {},
),
),
);
bool quickSave = settings.quickSave && server.getState().session?.message == null;
final quickSaveFromFavorites = settings.quickSaveFromFavorites && server.getState().session?.message == null;
if (quickSaveFromFavorites) {
final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == senderFingerprint);
if (isFavorite) {
quickSave = true;
}
}
if (quickSave) {
// accept all files
await acceptFileRequest({
for (final f in files.values) f.id: f.fileName,
});
// ignore: use_build_context_synchronously, unawaited_futures
Routerino.context.pushImmediately(
() => ProgressPage(
showAppBar: false,
closeSessionOnClose: true,
sessionId: sessionId,
),
);
return;
}
if (checkPlatformHasTray() && (await windowManager.isMinimized() || !(await windowManager.isVisible()) || !(await windowManager.isFocused()))) {
await showFromTray();
}
final message = server.getState().session?.message;
if (message != null) {
// Message already received
await server.ref
.redux(receiveHistoryProvider)
.dispatchAsync(
AddHistoryEntryAction(
entryId: const Uuid().v4(),
fileName: message,
fileType: FileType.text,
path: null,
savedToGallery: false,
isMessage: true,
fileSize: utf8.encode(message).length,
senderAlias: server.getState().session!.senderAlias,
timestamp: DateTime.now().toUtc(),
),
);
}
final receiveProvider = ViewProvider((ref) {
final session = ref.watch(serverProvider.select((state) => state?.session));
return ReceivePageVm(
status: session?.status,
sender: session?.sender ?? Device.empty,
showSenderInfo: true,
files: session?.files.values.map((f) => f.file).toList() ?? [],
message: message,
onAccept: () async {
if (message != null) {
// accept nothing
await ref.notifier(serverProvider).acceptFileRequest({});
return;
}
final sessionId = ref.read(serverProvider)?.session?.sessionId;
if (sessionId == null) {
return;
}
final selectedFiles = ref.read(selectedReceivingFilesProvider);
await ref.notifier(serverProvider).acceptFileRequest(selectedFiles);
// ignore: use_build_context_synchronously
await Routerino.context.pushAndRemoveUntilImmediately(
removeUntil: ReceivePage,
builder: () => ProgressPage(
showAppBar: false,
closeSessionOnClose: true,
sessionId: sessionId,
),
);
},
onDecline: () {
ref.notifier(serverProvider).declineFileRequest();
},
onClose: () {
ref.notifier(serverProvider).closeSession();
},
);
});
// ignore: use_build_context_synchronously, unawaited_futures
Routerino.context.push(() => ReceivePage(receiveProvider));
}
/// An accepted file started being uploaded.
/// The server isolate receives and saves the file on its own
/// ([HttpServerReceiveConfig] was sent with the accept decision);
/// only the session state is updated here.
void onFileUpload(HttpServerFileUploadEvent event) {
final receiveState = server.getStateOrNull()?.session;
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) {
_logger.warning('Failing upload of file ${event.fileId}: no matching active session');
// Fail the upload (and any further ones) by cancelling the session on the Rust side.
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId));
return;
}
final fileId = event.fileId;
final receivingFile = receiveState.files[fileId];
if (receivingFile == null || receivingFile.desiredName == null) {
_logger.warning('Unexpected fileId: $fileId');
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId));
return;
}
// begin of actual file transfer
server.setState(
(oldState) => oldState?.copyWith(
session: receiveState.copyWith(
files: {...receiveState.files}
..update(
fileId,
(_) => receivingFile.copyWith(
status: FileStatus.sending,
),
),
startTime: receiveState.startTime ?? DateTime.now().millisecondsSinceEpoch,
status: SessionStatus.sending, // in case it was finishedWithErrors and user retries a failed file
),
),
);
}
/// The receive progress of a file reported by the server isolate.
void onFileUploadProgress(HttpServerFileUploadProgressEvent event) {
final receiveState = server.getStateOrNull()?.session;
if (receiveState == null || receiveState.sessionId != event.sessionId) {
return;
}
server.ref
.notifier(progressProvider)
.setProgress(
sessionId: event.sessionId,
fileId: event.fileId,
progress: event.progress,
);
_updateForegroundServiceProgress(receiveState);
}
/// Reports the total session progress to the foreground service notification,
/// so that it stays up to date while the app is minimized.
void _updateForegroundServiceProgress(ReceiveSessionState session) {
if (!TransferNotification.shouldUpdate) {
// Checked before the sum below because progress events arrive several times per second per file.
return;
}
final progress = server.ref.read(progressProvider);
int currentBytes = 0;
int totalBytes = 0;
for (final receivingFile in session.files.values) {
if (receivingFile.desiredName == null) {
// not accepted by the user
continue;
}
final size = receivingFile.file.size;
totalBytes += size;
currentBytes += (progress.getProgress(sessionId: session.sessionId, fileId: receivingFile.file.id) * size).round();
}
TransferNotification.update(
sessionId: session.sessionId,
currentBytes: currentBytes,
totalBytes: totalBytes,
startTime: session.startTime,
endTime: session.endTime,
);
}
/// A file has been received completely (or failed) by the server isolate.
Future<void> onFileUploadResult(HttpServerFileUploadResultEvent event) async {
final receiveState = server.getStateOrNull()?.session;
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) {
return;
}
final fileId = event.fileId;
final receivingFile = receiveState.files[fileId];
if (receivingFile == null || receivingFile.desiredName == null) {
_logger.warning('Unexpected fileId: $fileId');
return;
}
final fileType = receivingFile.file.fileType;
final filePath = event.path;
final error = event.error;
if (error == null) {
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.fileFinished(
fileId: fileId,
status: FileStatus.finished,
path: filePath,
savedToGallery: event.savedToGallery,
errorMessage: null,
),
),
);
// Track it in history
await server.ref
.redux(receiveHistoryProvider)
.dispatchAsync(
AddHistoryEntryAction(
entryId: fileId,
fileName: receivingFile.desiredName!,
fileType: fileType,
path: filePath,
savedToGallery: event.savedToGallery,
isMessage: false,
fileSize: receivingFile.file.size,
senderAlias: receiveState.senderAlias,
timestamp: DateTime.now().toUtc(),
),
);
} else {
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.fileFinished(
fileId: fileId,
status: FileStatus.failed,
path: null,
savedToGallery: false,
errorMessage: error,
),
),
);
}
server.ref
.notifier(progressProvider)
.setProgress(
sessionId: receiveState.sessionId,
fileId: fileId,
progress: 1,
);
final session = server.getStateOrNull()?.session;
if (session == null) {
return;
}
_updateForegroundServiceProgress(session);
if (allowedStates.contains(session.status) && session.files.values.map((e) => e.status).isFinishedOrError) {
// The transfer is over, the process no longer needs to be kept alive for it.
TransferNotification.stop(session.sessionId);
final hasError = session.files.values.any((f) => f.status == FileStatus.failed);
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session!.copyWith(
status: hasError ? SessionStatus.finishedWithErrors : SessionStatus.finished,
endTime: DateTime.now().millisecondsSinceEpoch,
),
),
);
final settings = server.ref.read(settingsProvider);
// Only auto-close fully successful sessions: a failed file may still be
// retried by the sender (e.g. after a checksum mismatch), which requires
// the session to stay open.
bool quickSave = settings.quickSave && !hasError && server.getState().session?.message == null;
final quickSaveFromFavorites = settings.quickSaveFromFavorites && !hasError && server.getState().session?.message == null;
if (quickSaveFromFavorites) {
final bool isFavorite = server.ref.read(favoritesProvider).any((e) => e.fingerprint == session.sender.fingerprint);
if (isFavorite) {
quickSave = true;
}
}
if (quickSave) {
// close the session **after** the response has been sent
Future.delayed(Duration.zero, () {
closeSession();
_logger.info('Closing session');
// ignore: use_build_context_synchronously, discarded_futures
Routerino.context.pushRootImmediately(() => const HomePage(initialTab: HomeTab.receive, appStart: false));
// open the dialog to open file instantly
if (filePath != null && filePath.isNotEmpty) {
// ignore: discarded_futures
OpenFileDialog.open(
Routerino.context, // ignore: use_build_context_synchronously
filePath: filePath,
fileType: fileType,
openGallery: event.savedToGallery,
);
}
});
}
_logger.info('Received all files.');
}
}
/// An upload session ended on the Rust server.
void onSessionEnd(HttpServerSessionEndEvent event) {
final receiveSession = server.getStateOrNull()?.session;
if (receiveSession == null || receiveSession.sessionId != event.sessionId) {
return;
}
switch (event.reason) {
case SessionEndReasonV2.finished:
// Already handled when the last file finished.
break;
case SessionEndReasonV2.cancelled:
_cancelBySender(server);
}
}
/// The sender aborted the request while the user was still deciding.
void onPrepareUploadAborted(HttpServerPrepareUploadAbortedEvent event) {
final receiveSession = server.getStateOrNull()?.session;
if (receiveSession == null || receiveSession.sessionId != event.sessionId || receiveSession.status != SessionStatus.waiting) {
return;
}
_cancelBySender(server);
}
/// A remote device cancels a transfer this application is currently
/// *sending* to it.
void onCancelReceived(HttpServerCancelReceivedEvent event) {
final sendSessions = server.ref.read(sendProvider);
final selectedSession = sendSessions.values.firstWhereOrNull((s) => s.remoteSessionId == event.sessionId);
if (selectedSession == null) {
return;
}
if (selectedSession.target.ip != event.ip) {
return;
}
if (selectedSession.status != SessionStatus.sending) {
return;
}
server.ref
.notifier(sendProvider)
.cancelSessionByReceiver(
selectedSession.sessionId,
);
}
/// Another application instance requested the running application to show itself.
/// The show token has already been checked by the Rust server.
void onShow(HttpServerShowEvent event) {
if (!checkPlatformIsDesktop()) {
return;
}
// ignore: discarded_futures
showFromTray().catchError((e) {
// don't wait for it
_logger.severe('Failed to show from tray', e);
});
final args = event.args;
if (args.isEmpty) {
return;
}
// ignore: unawaited_futures, discarded_futures
server.ref.redux(selectedSendingFilesProvider).dispatchAsyncTakeResult(LoadSelectionFromArgsAction(args)).then((filesAdded) {
if (filesAdded) {
server.ref.redux(homePageControllerProvider).dispatch(ChangeTabAction(HomeTab.send));
}
});
}
/// Accepts the file request with the given [fileNameMap] (file id -> desired file name).
Future<void> acceptFileRequest(Map<String, String> fileNameMap) async {
final session = server.getStateOrNull()?.session;
if (session == null || session.status != SessionStatus.waiting) {
return;
}
if (fileNameMap.isEmpty) {
// nothing selected, the Rust server responds with 204 and creates no session
// This usually happens for message transfers
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareUploadDecisionAction(config: _buildReceiveConfig(session, {})));
closeSession();
return;
}
server.setState(
(oldState) {
final receiveState = oldState!.session!;
return oldState.copyWith(
session: receiveState.copyWith(
status: SessionStatus.sending,
files: Map.fromEntries(
receiveState.files.values.map((entry) {
final desiredName = fileNameMap[entry.file.id];
return MapEntry(
entry.file.id,
ReceivingFile(
file: entry.file,
status: desiredName != null ? FileStatus.queue : FileStatus.skipped,
token: null,
desiredName: desiredName,
path: null,
savedToGallery: false,
errorMessage: null,
),
);
}),
),
),
);
},
);
if (checkPlatform([TargetPlatform.android, TargetPlatform.iOS])) {
if (checkPlatform([TargetPlatform.android]) && !session.destinationDirectory.startsWith('/storage/emulated/0/Download')) {
// Android requires more permission to save files outside of the Download directory
try {
final result = await Permission.storage.request();
_logger.info('storage permission: $result');
} catch (e) {
_logger.warning('Could not request storage permission', e);
}
}
try {
await Permission.storage.request();
} catch (e) {
_logger.warning('Could not request storage permission', e);
}
}
// Keep the process alive for the whole transfer. Started here because:
// - the app is still in the foreground, and Android 12+ rejects starting a foreground service
// from the background,
// - the service may ask for the notification permission, which Android cancels when it overlaps
// with the storage permission requests above.
TransferNotification.start(sessionId: session.sessionId, receiving: true);
// From here on, the server isolate receives all accepted files on its own
// and reports back via upload progress/result events.
final updatedSession = server.getStateOrNull()?.session;
if (updatedSession == null) {
return;
}
server.ref
.redux(parentIsolateProvider)
.dispatch(IsolateHttpServerPrepareUploadDecisionAction(config: _buildReceiveConfig(updatedSession, fileNameMap)));
}
HttpServerReceiveConfig _buildReceiveConfig(ReceiveSessionState session, Map<String, String> fileNameMap) {
return HttpServerReceiveConfig(
sessionId: session.sessionId,
fileNameMap: fileNameMap,
destinationDirectory: session.destinationDirectory,
cacheDirectory: session.cacheDirectory,
saveToGallery: session.saveToGallery,
androidSdkInt: server.ref.read(deviceInfoProvider).androidSdkInt,
);
}
void declineFileRequest() {
final session = server.getStateOrNull()?.session;
if (session == null || session.status != SessionStatus.waiting) {
return;
}
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareUploadDecisionAction(config: null));
closeSession();
}
/// Updates the destination directory for the current session.
void setSessionDestinationDir(String destinationDirectory) {
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.copyWith(
destinationDirectory: destinationDirectory.replaceAll('\\', '/'),
),
),
);
}
/// Updates the "saveToGallery" setting for the current session.
void setSessionSaveToGallery(bool saveToGallery) {
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.copyWith(
saveToGallery: saveToGallery,
),
),
);
}
/// In addition to [closeSession], this method also
/// - cancels the session on the Rust server so that further uploads fail
/// - notifies the sender that the session has been canceled
void cancelSession() async {
final session = server.getStateOrNull()?.session;
if (session == null) {
// the server is not running
return;
}
// fail further uploads
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId));
// notify sender
final target = session.sender;
try {
server.ref
.read(httpProvider)
.pinnedTo(target.fingerprint)
// ignore: unawaited_futures
.cancel(
protocol: target.getProtocolType(),
ip: target.ip!,
port: target.port,
sessionId: session.sessionId,
);
} catch (e) {
_logger.warning('Failed to notify sender', e);
}
closeSession();
}
void closeSession() {
final sessionId = server.getStateOrNull()?.session?.sessionId;
if (sessionId == null) {
return;
}
TransferNotification.stop(sessionId);
server.setState(
(oldState) => oldState?.copyWith(
session: null,
),
);
server.ref.notifier(progressProvider).removeSession(sessionId);
}
}
void _cancelBySender(ServerUtils server) {
final receiveSession = server.getStateOrNull()?.session;
if (receiveSession == null) {
return;
}
TransferNotification.stop(receiveSession.sessionId);
if (receiveSession.status == SessionStatus.waiting) {
// received cancel during accept/decline
// pop just in case if user is in [ReceiveOptionsPage]
Routerino.context.popUntil(ReceivePage);
}
server.setState(
(oldState) => oldState?.copyWith(
session: oldState.session?.copyWith(
status: SessionStatus.canceledBySender,
endTime: DateTime.now().millisecondsSinceEpoch,
),
),
);
}
extension on ReceiveSessionState {
ReceiveSessionState fileFinished({
required String fileId,
required FileStatus status,
required String? path,
required bool savedToGallery,
required String? errorMessage,
}) {
return copyWith(
files: {...files}
..update(
fileId,
(file) => file.copyWith(
status: status,
path: path,
savedToGallery: savedToGallery,
errorMessage: errorMessage,
),
),
);
}
}