mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
726 lines
26 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|