Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions mobile/apps/photos/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- grace_window_ios (1.0.0):
- Flutter
- home_widget (0.0.1):
- Flutter
- in_app_purchase_storekit (0.0.1):
Expand Down Expand Up @@ -292,6 +294,7 @@ DEPENDENCIES:
- flutter_sodium (from `.symlinks/plugins/flutter_sodium/ios`)
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- grace_window_ios (from `.symlinks/plugins/grace_window_ios/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
Expand Down Expand Up @@ -409,6 +412,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_timezone/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
grace_window_ios:
:path: ".symlinks/plugins/grace_window_ios/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
in_app_purchase_storekit:
Expand Down Expand Up @@ -525,6 +530,7 @@ SPEC CHECKSUMS:
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
grace_window_ios: b98b82feaabeee818af7b7e0e3ff49b2acd7942a
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
Expand Down
2 changes: 2 additions & 0 deletions mobile/apps/photos/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@
"${BUILT_PRODUCTS_DIR}/flutter_sodium/flutter_sodium.framework",
"${BUILT_PRODUCTS_DIR}/flutter_timezone/flutter_timezone.framework",
"${BUILT_PRODUCTS_DIR}/fluttertoast/fluttertoast.framework",
"${BUILT_PRODUCTS_DIR}/grace_window_ios/grace_window_ios.framework",
"${BUILT_PRODUCTS_DIR}/home_widget/home_widget.framework",
"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
"${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework",
Expand Down Expand Up @@ -754,6 +755,7 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_sodium.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_timezone.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/fluttertoast.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grace_window_ios.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/home_widget.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework",
Expand Down
8 changes: 8 additions & 0 deletions mobile/apps/photos/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import "package:photos/services/home_widget_service.dart";
import "package:photos/services/memory_home_widget_service.dart";
import "package:photos/services/people_home_widget_service.dart";
import 'package:photos/services/sync/sync_service.dart';
import 'package:photos/services/upload_background_coordinator.dart';
import 'package:photos/ui/tabs/home_widget.dart';
import "package:photos/ui/viewer/actions/file_viewer.dart";
import "package:photos/utils/bg_task_utils.dart";
Expand Down Expand Up @@ -193,14 +194,21 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final String stateChangeReason = 'app -> $state';
final wasForeground = AppLifecycleService.instance.isForeground;
if (state == AppLifecycleState.resumed) {
final lastAppOpenTime = AppLifecycleService.instance.getLastAppOpenTime();
AppLifecycleService.instance
.onAppInForeground(stateChangeReason + ': sync now');
if (!wasForeground) {
unawaited(onUploadAppForeground());
}
unawaited(_reloadCachesUpdatedInBackground(lastAppOpenTime));
SyncService.instance.sync();
} else {
AppLifecycleService.instance.onAppInBackground(stateChangeReason);
if (wasForeground) {
unawaited(onUploadAppBackground());
}
}
}

Expand Down
130 changes: 130 additions & 0 deletions mobile/apps/photos/lib/services/upload_background_coordinator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import "dart:async";
import "dart:io";

import "package:grace_window_ios/grace_window_ios.dart";
import "package:logging/logging.dart";
import "package:photos/db/upload_locks_db.dart";
import "package:photos/service_locator.dart";
import "package:photos/utils/bg_task_utils.dart";
import "package:photos/utils/file_uploader.dart";
import "package:shared_preferences/shared_preferences.dart";

const _keyIOSUploadGraceActive = "ios_bg_upload_grace_active";

final _logger = Logger("UploadBackgroundCoordinator");
bool _isGraceWindowActiveInProcess = false;

Future<void> onUploadAppBackground() async {
if (!Platform.isIOS || !flagService.enableIOSBackgroundHandoff) {
return;
}

if (FileUploader.instance.hasActiveUploads) {
if (!flagService.enableIOSBackgroundGraceWindow) {
return;
}

final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_keyIOSUploadGraceActive) ?? false) {
if (_isGraceWindowActiveInProcess) {
return;
}

_logger.info("Clearing stale iOS upload grace window marker");
await prefs.remove(_keyIOSUploadGraceActive);
}

_logger.info("Starting iOS upload grace window");
await GraceWindowIos.beginGraceWindow("ente-upload-grace-window");
await prefs.setBool(_keyIOSUploadGraceActive, true);
_isGraceWindowActiveInProcess = true;
await BgTaskUtils.scheduleIOSBackgroundProcessingTask(
source: "graceWindowStarted",
initialDelay: BgTaskUtils.continuationDelay(),
reason: BgTaskUtils.iOSBackgroundProcessingReasonContinuation,
);
unawaited(_waitForGraceWindowExpiration());
return;
}

if (await BgTaskUtils.isIOSBackupEligible()) {
await BgTaskUtils.scheduleIOSBackgroundProcessingTask(
source: "appBackground:maintenance",
initialDelay: BgTaskUtils.maintenanceDelay(),
reason: BgTaskUtils.iOSBackgroundProcessingReasonMaintenance,
);
}
}

Future<void> onUploadAppForeground() async {
if (!Platform.isIOS || !flagService.enableIOSBackgroundHandoff) {
return;
}

if (!flagService.enableIOSBackgroundGraceWindow) {
await BgTaskUtils.cancelIOSBackgroundProcessingTask(
source: "appForeground",
);
return;
}

final prefs = await SharedPreferences.getInstance();
final graceWasActive = prefs.getBool(_keyIOSUploadGraceActive) ?? false;

if (graceWasActive) {
final cutoffMicros = DateTime.now().microsecondsSinceEpoch;

_logger.info("Finishing iOS upload grace window and reconciling uploads");
await GraceWindowIos.endGraceWindow();
final didExpireGraceWindow = await GraceWindowIos.consumeExpiredState();
await prefs.remove(_keyIOSUploadGraceActive);
_isGraceWindowActiveInProcess = false;
if (didExpireGraceWindow) {
_logger.info(
"Grace window expiration was observed natively before foreground recovery",
);
await UploadLocksDB.instance.releaseLocksAcquiredByOwnerBefore(
ProcessType.foreground.toString(),
cutoffMicros,
);
}
await FileUploader.instance.reconcileAfterBackground();
}

await BgTaskUtils.cancelIOSBackgroundProcessingTask(
source: "appForeground",
);
}

/// Best-effort same-process expiration detection via a pending MethodChannel
/// call. Swift holds the result and completes it when the native expiration
/// handler fires. If this path misses (e.g. process suspended before
/// delivery), [onUploadAppForeground] catches it via [consumeExpiredState].
Future<void> _waitForGraceWindowExpiration() async {
final expired = await GraceWindowIos.awaitExpiration();
if (!expired) {
Comment on lines +104 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add durable fallback when grace-window await can be dropped

This flow relies on awaitExpiration() resolving to enqueue the BGProcessing continuation task, but the comment already notes delivery can be missed when the process is suspended. In that case this await never resumes, so scheduleIOSBackgroundProcessingTask(...) is never called and active uploads can remain stranded in background until the app is foregrounded again (or a later periodic trigger happens). Please add a fallback path that does not depend solely on this pending MethodChannel reply (for example, durable scheduling on grace-window start or native-side expiry handling).

Useful? React with 👍 / 👎.

return;
}

final cutoffMicros = DateTime.now().microsecondsSinceEpoch;

_logger.info("iOS upload grace window expired (via pending call)");

// Consume the durable marker *before* releasing locks so that
// onUploadAppForeground (which can run between any two awaits here) won't
// see the marker and do a second lock release against freshly
// reacquired locks.
final didOwnExpiration = await GraceWindowIos.consumeExpiredState();
if (!didOwnExpiration) {
return;
}

await UploadLocksDB.instance.releaseLocksAcquiredByOwnerBefore(
ProcessType.foreground.toString(),
cutoffMicros,
);

final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyIOSUploadGraceActive);
_isGraceWindowActiveInProcess = false;
}
123 changes: 123 additions & 0 deletions mobile/apps/photos/lib/utils/file_uploader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,129 @@ class FileUploader {
await _pollBackgroundUploadStatus();
});
}

Future<void> reconcileAfterBackground({
Future<EnteFile?> Function(int generatedID)? lookupFile,
}) async {
bool changed = false;
bool queueChanged = false;
final resolveFile = lookupFile ?? FilesDB.instance.getFile;
final backupEntries = _allBackups.entries.toList(growable: false);
for (final entry in backupEntries) {
final backup = entry.value;
final generatedID = backup.file.generatedID;
if (generatedID == null) {
continue;
}

final dbFile = await resolveFile(generatedID);
if (dbFile?.uploadedFileID == null) {
final queueItem = _queue[entry.key];
if (queueItem == null) {
continue;
}

final localID = backup.file.localID!;
bool shouldReconcile = false;

if (queueItem.status == UploadStatus.inBackground) {
final isStillLockedInBackground = await _uploadLocks.isLocked(
localID,
ProcessType.background.toString(),
);
shouldReconcile = !isStillLockedInBackground;
} else if (queueItem.status == UploadStatus.inProgress) {
final isStillLockedInForeground = await _uploadLocks.isLocked(
localID,
ProcessType.foreground.toString(),
);
if (!isStillLockedInForeground) {
final isTakenOverByBackground = await _uploadLocks.isLocked(
localID,
ProcessType.background.toString(),
);
shouldReconcile = !isTakenOverByBackground;
}
}

if (!shouldReconcile) {
continue;
}

_logger.info(
"Foreground reconciliation detected incomplete upload "
"(status=${queueItem.status}) ${backup.file.tag}",
);

if (queueItem.status == UploadStatus.inProgress) {
// Leave the queue entry in place so the active worker can unwind and
// complete its normal queue/completer cleanup without null asserts.
_allBackups[entry.key] = backup.copyWith(
status: BackupItemStatus.retry,
error: SilentlyCancelUploadsError(),
);
changed = true;
continue;
}

await _uploadLocks.releaseLock(
localID,
ProcessType.foreground.toString(),
);
final removedQueueItem = _queue.remove(entry.key);
if (removedQueueItem != null) {
queueChanged = true;
if (!removedQueueItem.completer.isCompleted) {
removedQueueItem.completer.completeError(
SilentlyCancelUploadsError(),
);
}
}
_allBackups[entry.key] = backup.copyWith(
status: BackupItemStatus.retry,
error: SilentlyCancelUploadsError(),
);
changed = true;
continue;
}

final queueItem = _queue[entry.key];
if (queueItem?.status == UploadStatus.inProgress) {
// Let the active worker finish its normal success cleanup. Removing the
// queue entry here would trip the worker's `_queue.remove(localID)!`.
if (backup.status != BackupItemStatus.uploaded) {
_allBackups[entry.key] = backup.copyWith(
status: BackupItemStatus.uploaded,
file: dbFile,
);
changed = true;
}
continue;
}

final removedQueueItem = _queue.remove(entry.key);
if (removedQueueItem != null) {
queueChanged = true;
if (!removedQueueItem.completer.isCompleted) {
removedQueueItem.completer.complete(dbFile!);
}
}
if (backup.status != BackupItemStatus.uploaded) {
_allBackups[entry.key] = backup.copyWith(
status: BackupItemStatus.uploaded,
file: dbFile,
);
changed = true;
}
}

if (changed) {
Bus.instance.fire(BackupUpdatedEvent(_allBackups));
}
if (queueChanged) {
_pollQueue();
}
}
}

class FileUploadItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class FlagService {
bool get enableIOSBackgroundHandoff => internalUser;
bool get enableBgLocalUploadPriority => internalUser;
bool get syncRecoveryDiagnostics => internalUser;
bool get enableIOSBackgroundGraceWindow => internalUser;

Future<void> tryRefreshFlags() async {
try {
Expand Down
Loading
Loading