From 1b5424c2307277b72475184be60b0cad0f311d98 Mon Sep 17 00:00:00 2001 From: Encotric Date: Tue, 24 Sep 2024 22:11:31 +0200 Subject: [PATCH 1/5] fix: remove .gitignore entry "**/*sync" to include background sync swift files --- mobile/ios/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index f312f249a39ff..e32cadbf686e0 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -4,7 +4,6 @@ *.moved-aside *.pbxuser *.perspectivev3 -**/*sync/ .sconsign.dblite .tags* **/.vagrant/ From 67df25cc9e56cb17cc63b731068d31fd4317801a Mon Sep 17 00:00:00 2001 From: Encotric Date: Tue, 24 Sep 2024 22:12:39 +0200 Subject: [PATCH 2/5] dev: iOS app intent for background sync --- mobile/ios/Runner.xcodeproj/project.pbxproj | 31 +++++++++++++++++++ mobile/ios/Runner/AppDelegate.swift | 4 +++ .../BackgroundSyncAppShortcut.swift | 20 ++++++++++++ .../BackgroundSyncShortcutIntent.swift | 24 ++++++++++++++ mobile/ios/Runner/Info.plist | 4 +++ mobile/ios/Runner/Runner.entitlements | 5 ++- mobile/ios/Runner/RunnerProfile.entitlements | 2 ++ 7 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift create mode 100644 mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 2d7cdc153cab8..19d1217d6e370 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */; }; 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */; }; + 6FC4C0DB2CA3268700D44B0C /* BackgroundSyncShortcutIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC4C0DA2CA3268700D44B0C /* BackgroundSyncShortcutIntent.swift */; }; + 6FC4C0DD2CA32AF000D44B0C /* BackgroundSyncAppShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FC4C0DC2CA32AF000D44B0C /* BackgroundSyncAppShortcut.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -19,6 +21,16 @@ /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ + 6FC4C0D82CA324C200D44B0C /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -38,6 +50,11 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundServicePlugin.swift; sourceTree = ""; }; 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundSyncWorker.swift; sourceTree = ""; }; + 6FC4C0AE2CA322AB00D44B0C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 6FC4C0B62CA324C100D44B0C /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + 6FC4C0C12CA324C200D44B0C /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; + 6FC4C0DA2CA3268700D44B0C /* BackgroundSyncShortcutIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSyncShortcutIntent.swift; sourceTree = ""; }; + 6FC4C0DC2CA32AF000D44B0C /* BackgroundSyncAppShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSyncAppShortcut.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -80,6 +97,8 @@ isa = PBXGroup; children = ( 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */, + 6FC4C0B62CA324C100D44B0C /* Intents.framework */, + 6FC4C0C12CA324C200D44B0C /* IntentsUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -87,8 +106,10 @@ 65DD438629917FAD0047FFA8 /* BackgroundSync */ = { isa = PBXGroup; children = ( + 6FC4C0DA2CA3268700D44B0C /* BackgroundSyncShortcutIntent.swift */, 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */, 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */, + 6FC4C0DC2CA32AF000D44B0C /* BackgroundSyncAppShortcut.swift */, ); path = BackgroundSync; sourceTree = ""; @@ -126,6 +147,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 6FC4C0AE2CA322AB00D44B0C /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -156,6 +178,7 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */, 6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */, + 6FC4C0D82CA324C200D44B0C /* Embed Foundation Extensions */, ); buildRules = ( ); @@ -173,6 +196,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1530; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -313,6 +337,8 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, + 6FC4C0DB2CA3268700D44B0C /* BackgroundSyncShortcutIntent.swift in Sources */, + 6FC4C0DD2CA32AF000D44B0C /* BackgroundSyncAppShortcut.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -396,6 +422,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; @@ -539,8 +566,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 175; @@ -567,8 +596,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 175; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 05cb061ca58b2..42c5eaad20756 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -42,6 +42,10 @@ import permission_handler_apple } } + if #available(iOS 16.0, *) { + BackgroundSyncAppShortcut.updateAppShortcutParameters() + } + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift new file mode 100644 index 0000000000000..a7d3e2ee7ed79 --- /dev/null +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift @@ -0,0 +1,20 @@ +// +// BackgroundSyncAppShortcut.swift +// Runner +// +// Created by Encotric on 24/09/2024. +// + +import AppIntents + + +@available(iOS 16.0, *) +struct BackgroundSyncAppShortcut: AppShortcutsProvider { + + @AppShortcutsBuilder static var appShortcuts: [AppShortcut] { + AppShortcut(intent: BackgroundSyncShortcutIntent(), phrases: [ + "Upload gallery using \(.applicationName)"], systemImageName: "square.and.arrow.up.on.square") + } + +} + diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift new file mode 100644 index 0000000000000..3480344d511e2 --- /dev/null +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift @@ -0,0 +1,24 @@ +// +// BackgroundSyncShortcutIntent.swift +// Runner +// +// Created by Encotric on 24/09/2024. +// + +import AppIntents +import SwiftUI + +@available(iOS 16.0, *) +struct BackgroundSyncShortcutIntent: AppIntent { + + static var title: LocalizedStringResource = "Sync gallery" + + func perform() async throws -> some IntentResult { + + let backgroundWorker = BackgroundSyncWorker { _ in () } + backgroundWorker.run(maxSeconds: nil) + + return .result() + } + +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 1831798a4288e..79755b7093771 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -90,6 +90,10 @@ We need to manage backup your photos album NSPhotoLibraryUsageDescription We need to manage backup your photos album + NSUserActivityTypes + + IntentIntent + UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 0c67376ebacb4..21d95c45f32e1 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -1,5 +1,8 @@ - + + com.apple.developer.siri + + diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 903def2af5306..434a016c89f92 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -4,5 +4,7 @@ aps-environment development + com.apple.developer.siri + From 7135f986b72816f7a066d002aa7b0d9dac1f12d4 Mon Sep 17 00:00:00 2001 From: Sebastian Wilke Date: Thu, 26 Sep 2024 18:27:17 +0200 Subject: [PATCH 3/5] dev: switch from pub cancellation_token_http to background_downloader to use URLSessionTasks on iOS for background sync --- .../BackgroundSyncAppShortcut.swift | 1 + .../BackgroundSyncShortcutIntent.swift | 5 + mobile/lib/services/backup.service.dart | 117 +++++++++--------- mobile/pubspec.lock | 12 +- mobile/pubspec.yaml | 3 +- 5 files changed, 79 insertions(+), 59 deletions(-) diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift index a7d3e2ee7ed79..71b9f2ea451c0 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncAppShortcut.swift @@ -13,6 +13,7 @@ struct BackgroundSyncAppShortcut: AppShortcutsProvider { @AppShortcutsBuilder static var appShortcuts: [AppShortcut] { AppShortcut(intent: BackgroundSyncShortcutIntent(), phrases: [ + // TODO: localized title "Upload gallery using \(.applicationName)"], systemImageName: "square.and.arrow.up.on.square") } diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift index 3480344d511e2..97cf3d31fa32d 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncShortcutIntent.swift @@ -11,7 +11,12 @@ import SwiftUI @available(iOS 16.0, *) struct BackgroundSyncShortcutIntent: AppIntent { + // TODO: localized title and description static var title: LocalizedStringResource = "Sync gallery" + + static var openAppWhenRun: Bool = true + + static var isDiscoverable: Bool = true func perform() async throws -> some IntentResult { diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773d75..a7328231e7318 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -339,31 +340,29 @@ class BackupService { } } - final fileStream = file.openRead(); - final assetRawUploadData = http.MultipartFile( - "assetData", - fileStream, - file.lengthSync(), - filename: originalFileName, - ); + final fileLength = file.lengthSync(); - final baseRequest = MultipartRequest( - 'POST', - Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), - ); + final (baseDir, dir, _) = await Task.split(file: file); - baseRequest.headers.addAll(ApiService.getRequestHeaders()); - baseRequest.headers["Transfer-Encoding"] = "chunked"; - baseRequest.fields['deviceAssetId'] = asset.localId!; - baseRequest.fields['deviceId'] = deviceId; - baseRequest.fields['fileCreatedAt'] = + final backgroundRequest = UploadTask( + filename: originalFileName, + baseDirectory: baseDir, + directory: dir, + url: '$savedEndpoint/assets', + httpRequestMethod: 'POST', + priority: 10, + ); // Priority 10 for testing purposes; maybe we could change that to a lower value later + + backgroundRequest.headers.addAll(ApiService.getRequestHeaders()); + backgroundRequest.headers["Transfer-Encoding"] = "chunked"; + backgroundRequest.fields['deviceAssetId'] = asset.localId!; + backgroundRequest.fields['deviceId'] = deviceId; + backgroundRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); - baseRequest.fields['fileModifiedAt'] = + backgroundRequest.fields['fileModifiedAt'] = asset.fileModifiedAt.toUtc().toIso8601String(); - baseRequest.fields['isFavorite'] = asset.isFavorite.toString(); - baseRequest.fields['duration'] = asset.duration.toString(); - baseRequest.files.add(assetRawUploadData); + backgroundRequest.fields['isFavorite'] = asset.isFavorite.toString(); + backgroundRequest.fields['duration'] = asset.duration.toString(); onCurrentAsset( CurrentUploadAsset( @@ -383,29 +382,37 @@ class BackupService { livePhotoVideoId = await uploadLivePhotoVideo( originalFileName, livePhotoFile, - baseRequest, + backgroundRequest, cancelToken, ); } if (livePhotoVideoId != null) { - baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; + backgroundRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await httpClient.send( - baseRequest, - cancellationToken: cancelToken, + final response = await FileDownloader().upload( + backgroundRequest, + onProgress: (percentage) => { + // onProgress returns a double in [0.0;1.0] for percentage + if (percentage > 0) + onProgress( + (percentage * fileLength).toInt(), + fileLength, + ), + }, ); - final responseBody = - jsonDecode(await response.stream.bytesToString()); + final responseBody = jsonDecode(response.responseBody ?? "{}"); - if (![200, 201].contains(response.statusCode)) { - final error = responseBody; - final errorMessage = error['message'] ?? error['error']; + if (response.status == TaskStatus.failed || + ![200, 201].contains(response.responseStatusCode)) { + final error = response.exception != null + ? response.exception!.description + : responseBody; debugPrint( - "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", + "Error(${response.responseStatusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | $error", ); onError( @@ -415,11 +422,11 @@ class BackupService { fileCreatedAt: asset.fileCreatedAt, fileName: originalFileName, fileType: _getAssetType(candidate.asset.type), - errorMessage: errorMessage, + errorMessage: error, ), ); - if (errorMessage == "Quota has been exceeded!") { + if (error == "Quota has been exceeded!") { anyErrors = true; break; } @@ -428,7 +435,7 @@ class BackupService { } bool isDuplicate = false; - if (response.statusCode == 200) { + if (response.responseStatusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(asset.localId!); } @@ -478,7 +485,7 @@ class BackupService { Future uploadLivePhotoVideo( String originalFileName, File? livePhotoVideoFile, - MultipartRequest baseRequest, + UploadTask baseRequest, http.CancellationToken cancelToken, ) async { if (livePhotoVideoFile == null) { @@ -488,35 +495,33 @@ class BackupService { originalFileName, p.extension(livePhotoVideoFile.path), ); - final fileStream = livePhotoVideoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( - "assetData", - fileStream, - livePhotoVideoFile.lengthSync(), + + final (baseDir, dir, _) = await Task.split(file: livePhotoVideoFile); + + final backgroundRequest = UploadTask( filename: livePhotoTitle, - ); - final livePhotoReq = MultipartRequest( - baseRequest.method, - baseRequest.url, - onProgress: baseRequest.onProgress, + baseDirectory: baseDir, + directory: dir, + url: baseRequest.url, + httpRequestMethod: baseRequest.httpRequestMethod, + priority: baseRequest.priority, ) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); - livePhotoReq.files.add(livePhotoRawUploadData); - - var response = await httpClient.send( - livePhotoReq, - cancellationToken: cancelToken, - ); + final response = await FileDownloader() + .upload(backgroundRequest); //TODO: onProgress callback? - var responseBody = jsonDecode(await response.stream.bytesToString()); + var responseBody = jsonDecode(response.responseBody ?? "{}"); - if (![200, 201].contains(response.statusCode)) { - var error = responseBody; + if (response.status == TaskStatus.failed || + ![200, 201].contains(response.responseStatusCode)) { + final error = response.exception != null + ? response.exception!.description + : responseBody; debugPrint( - "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | ${error['error']}", + "Error(${error['statusCode']}) uploading livePhoto for assetId | $livePhotoTitle | $error", ); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index aaea00d699bbe..9dadbd1028a64 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0f75463547d6b..a66453a5fcd93 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 From b6fa9ad1b31781ec7718e88832e45d9c8902e168 Mon Sep 17 00:00:00 2001 From: Sebastian Wilke Date: Thu, 3 Oct 2024 18:36:51 +0200 Subject: [PATCH 4/5] fix: foreground backup + background backup --- .../models/backup/upload_request.model.dart | 36 ++++ mobile/lib/services/backup.service.dart | 167 +++++++++++------- 2 files changed, 144 insertions(+), 59 deletions(-) create mode 100644 mobile/lib/models/backup/upload_request.model.dart diff --git a/mobile/lib/models/backup/upload_request.model.dart b/mobile/lib/models/backup/upload_request.model.dart new file mode 100644 index 0000000000000..1205002cded5d --- /dev/null +++ b/mobile/lib/models/backup/upload_request.model.dart @@ -0,0 +1,36 @@ +class UploadRequest { + final String method; + final String url; + int priority = 0; + final Map headers; + final Map fields; + final bool isBackground; + void Function(int, int) onProgress; + + UploadRequest( + {required this.method, + required this.url, + required this.fields, + required this.headers, + required this.isBackground, + required this.onProgress}); + + @override + bool operator ==(covariant UploadRequest other) { + if (identical(this, other)) return true; + + return other.method == method && + other.url == url && + other.fields == fields && + other.headers == headers && + other.onProgress == onProgress; + } + + @override + int get hashCode => + method.hashCode ^ + url.hashCode ^ + priority.hashCode ^ + headers.hashCode ^ + fields.hashCode; +} diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index a7328231e7318..9109f9368ef80 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/models/backup/upload_request.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -340,81 +341,129 @@ class BackupService { } } - final fileLength = file.lengthSync(); + Map fields = { + "deviceAssetId": asset.localId!, + 'deviceId': deviceId, + 'fileCreatedAt': asset.fileCreatedAt.toUtc().toIso8601String(), + 'fileModifiedAt': asset.fileModifiedAt.toUtc().toIso8601String(), + 'isFavorite': asset.isFavorite.toString(), + 'duration': asset.duration.toString(), + }; - final (baseDir, dir, _) = await Task.split(file: file); + Map headers = ApiService.getRequestHeaders(); - final backgroundRequest = UploadTask( - filename: originalFileName, - baseDirectory: baseDir, - directory: dir, + UploadRequest baseRequest = UploadRequest( + method: 'POST', url: '$savedEndpoint/assets', - httpRequestMethod: 'POST', - priority: 10, - ); // Priority 10 for testing purposes; maybe we could change that to a lower value later - - backgroundRequest.headers.addAll(ApiService.getRequestHeaders()); - backgroundRequest.headers["Transfer-Encoding"] = "chunked"; - backgroundRequest.fields['deviceAssetId'] = asset.localId!; - backgroundRequest.fields['deviceId'] = deviceId; - backgroundRequest.fields['fileCreatedAt'] = - asset.fileCreatedAt.toUtc().toIso8601String(); - backgroundRequest.fields['fileModifiedAt'] = - asset.fileModifiedAt.toUtc().toIso8601String(); - backgroundRequest.fields['isFavorite'] = asset.isFavorite.toString(); - backgroundRequest.fields['duration'] = asset.duration.toString(); - - onCurrentAsset( - CurrentUploadAsset( - id: asset.localId!, - fileCreatedAt: asset.fileCreatedAt.year == 1970 - ? asset.fileModifiedAt - : asset.fileCreatedAt, - fileName: originalFileName, - fileType: _getAssetType(asset.type), - fileSize: file.lengthSync(), - iCloudAsset: false, - ), + fields: fields, + headers: headers, + isBackground: isBackground, + onProgress: onProgress, ); + int statusCode; + final fileLength = file.lengthSync(); + dynamic body; + String? livePhotoVideoId; if (asset.local!.isLivePhoto && livePhotoFile != null) { livePhotoVideoId = await uploadLivePhotoVideo( originalFileName, livePhotoFile, - backgroundRequest, + baseRequest, cancelToken, ); } if (livePhotoVideoId != null) { - backgroundRequest.fields['livePhotoVideoId'] = livePhotoVideoId; + baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await FileDownloader().upload( - backgroundRequest, - onProgress: (percentage) => { - // onProgress returns a double in [0.0;1.0] for percentage - if (percentage > 0) - onProgress( - (percentage * fileLength).toInt(), - fileLength, - ), - }, - ); + if (!isBackground) { + print("BACKGROUND SYNC"); + final (baseDir, dir, filename) = await Task.split(file: file); + + // Priority 10 for testing purposes; maybe we could change that to a lower value later + baseRequest.priority = 10; + + final backgroundRequest = UploadTask( + filename: filename, + baseDirectory: baseDir, + directory: dir, + fileField: "assetData", + mimeType: "application/octet-stream", + url: baseRequest.url, + httpRequestMethod: baseRequest.method, + priority: baseRequest.priority, + fields: baseRequest.fields, + headers: baseRequest.headers, + updates: Updates.statusAndProgress, + ); - final responseBody = jsonDecode(response.responseBody ?? "{}"); + final response = await FileDownloader().upload( + backgroundRequest, + onProgress: (percentage) => { + // onProgress returns a double in [0.0;1.0] for percentage + if (percentage > 0) + baseRequest.onProgress( + (percentage * fileLength).toInt(), + fileLength, + ), + }, + ); - if (response.status == TaskStatus.failed || - ![200, 201].contains(response.responseStatusCode)) { - final error = response.exception != null - ? response.exception!.description - : responseBody; + body = jsonDecode(response.responseBody ?? "{}"); + statusCode = response.responseStatusCode ?? 500; + } else { + print("FOREGROUND SYNC"); + final fileStream = file.openRead(); + final assetRawUploadData = http.MultipartFile( + "assetData", + fileStream, + fileLength, + filename: originalFileName, + ); - debugPrint( - "Error(${response.responseStatusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | $error", + final foregroundRequest = MultipartRequest( + baseRequest.method, + Uri.parse(baseRequest.url), + onProgress: ((bytes, totalBytes) => + baseRequest.onProgress(bytes, totalBytes)), + ); + foregroundRequest.headers["Transfer-Encoding"] = "chunked"; + foregroundRequest.headers.addAll(baseRequest.headers); + foregroundRequest.fields.addAll(baseRequest.fields); + foregroundRequest.files.add(assetRawUploadData); + + final response = await httpClient.send( + foregroundRequest, + cancellationToken: cancelToken, ); + body = jsonDecode(await response.stream.bytesToString()); + statusCode = response.statusCode; + } + + onCurrentAsset( + CurrentUploadAsset( + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, + fileName: originalFileName, + fileType: _getAssetType(asset.type), + fileSize: file.lengthSync(), + iCloudAsset: false, + ), + ); + + if (![200, 201].contains(statusCode)) { + final error = body; + + // debugPrint( + // "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", + // ); + onError( ErrorUploadAsset( asset: asset, @@ -422,7 +471,7 @@ class BackupService { fileCreatedAt: asset.fileCreatedAt, fileName: originalFileName, fileType: _getAssetType(candidate.asset.type), - errorMessage: error, + errorMessage: error['error'], ), ); @@ -435,7 +484,7 @@ class BackupService { } bool isDuplicate = false; - if (response.responseStatusCode == 200) { + if (statusCode == 200) { isDuplicate = true; duplicatedAssetIds.add(asset.localId!); } @@ -443,7 +492,7 @@ class BackupService { onSuccess( SuccessUploadAsset( candidate: candidate, - remoteAssetId: responseBody['id'] as String, + remoteAssetId: body['id'] as String, isDuplicate: isDuplicate, ), ); @@ -451,7 +500,7 @@ class BackupService { if (shouldSyncAlbums) { await _albumService.syncUploadAlbums( candidate.albumNames, - [responseBody['id'] as String], + [body['id'] as String], ); } } @@ -485,7 +534,7 @@ class BackupService { Future uploadLivePhotoVideo( String originalFileName, File? livePhotoVideoFile, - UploadTask baseRequest, + UploadRequest baseRequest, http.CancellationToken cancelToken, ) async { if (livePhotoVideoFile == null) { @@ -503,7 +552,7 @@ class BackupService { baseDirectory: baseDir, directory: dir, url: baseRequest.url, - httpRequestMethod: baseRequest.httpRequestMethod, + httpRequestMethod: baseRequest.method, priority: baseRequest.priority, ) ..headers.addAll(baseRequest.headers) From 66b70d630d31990d936b77f23385166368ee220c Mon Sep 17 00:00:00 2001 From: Sebastian Wilke Date: Sat, 5 Oct 2024 23:28:53 +0200 Subject: [PATCH 5/5] dev: started implementation of batch upload tasks for background sync --- mobile/lib/services/backup.service.dart | 173 ++++++++++++++---------- 1 file changed, 99 insertions(+), 74 deletions(-) diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 9109f9368ef80..0a0e276b7752d 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -277,11 +278,13 @@ class BackupService { } List candidates = assets.toList(); + List backgroundTasks = []; + if (isBackground) { candidates = _sortPhotosFirst(candidates); } - for (final candidate in candidates) { + for (final (i, candidate) in candidates.indexed) { final Asset asset = candidate.asset; File? file; File? livePhotoFile; @@ -361,9 +364,7 @@ class BackupService { onProgress: onProgress, ); - int statusCode; final fileLength = file.lengthSync(); - dynamic body; String? livePhotoVideoId; if (asset.local!.isLivePhoto && livePhotoFile != null) { @@ -379,7 +380,20 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - if (!isBackground) { + onCurrentAsset( + CurrentUploadAsset( + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, + fileName: originalFileName, + fileType: _getAssetType(asset.type), + fileSize: file.lengthSync(), + iCloudAsset: false, + ), + ); + + if (isBackground) { print("BACKGROUND SYNC"); final (baseDir, dir, filename) = await Task.split(file: file); @@ -398,22 +412,10 @@ class BackupService { fields: baseRequest.fields, headers: baseRequest.headers, updates: Updates.statusAndProgress, + metaData: i.toString(), ); - final response = await FileDownloader().upload( - backgroundRequest, - onProgress: (percentage) => { - // onProgress returns a double in [0.0;1.0] for percentage - if (percentage > 0) - baseRequest.onProgress( - (percentage * fileLength).toInt(), - fileLength, - ), - }, - ); - - body = jsonDecode(response.responseBody ?? "{}"); - statusCode = response.responseStatusCode ?? 500; + backgroundTasks.add(backgroundRequest); } else { print("FOREGROUND SYNC"); final fileStream = file.openRead(); @@ -440,68 +442,34 @@ class BackupService { cancellationToken: cancelToken, ); - body = jsonDecode(await response.stream.bytesToString()); - statusCode = response.statusCode; - } + dynamic body = jsonDecode(await response.stream.bytesToString()); + int statusCode = response.statusCode; + if ((anyErrors = + !_handleUploadError(asset, body, statusCode, onError)) == + true) { + break; + } - onCurrentAsset( - CurrentUploadAsset( - id: asset.localId!, - fileCreatedAt: asset.fileCreatedAt.year == 1970 - ? asset.fileModifiedAt - : asset.fileCreatedAt, - fileName: originalFileName, - fileType: _getAssetType(asset.type), - fileSize: file.lengthSync(), - iCloudAsset: false, - ), - ); + bool isDuplicate = false; + if (statusCode == 200) { + isDuplicate = true; + duplicatedAssetIds.add(asset.localId!); + } - if (![200, 201].contains(statusCode)) { - final error = body; - - // debugPrint( - // "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", - // ); - - onError( - ErrorUploadAsset( - asset: asset, - id: asset.localId!, - fileCreatedAt: asset.fileCreatedAt, - fileName: originalFileName, - fileType: _getAssetType(candidate.asset.type), - errorMessage: error['error'], + onSuccess( + SuccessUploadAsset( + candidate: candidate, + remoteAssetId: body['id'] as String, + isDuplicate: isDuplicate, ), ); - if (error == "Quota has been exceeded!") { - anyErrors = true; - break; + if (shouldSyncAlbums) { + await _albumService.syncUploadAlbums( + candidate.albumNames, + [body['id'] as String], + ); } - - continue; - } - - bool isDuplicate = false; - if (statusCode == 200) { - isDuplicate = true; - duplicatedAssetIds.add(asset.localId!); - } - - onSuccess( - SuccessUploadAsset( - candidate: candidate, - remoteAssetId: body['id'] as String, - isDuplicate: isDuplicate, - ), - ); - - if (shouldSyncAlbums) { - await _albumService.syncUploadAlbums( - candidate.albumNames, - [body['id'] as String], - ); } } } on http.CancelledException { @@ -524,6 +492,32 @@ class BackupService { } } + if (isBackground) { + final response = await FileDownloader().uploadBatch( + backgroundTasks, + taskProgressCallback: (update) { + onProgress((update.progress * update.expectedFileSize).toInt(), + update.expectedFileSize); + }, + taskStatusCallback: (update) { + // BackupCandidate index is stored in task metadata + int i = update.task.metaData.toInt(); + if (update.status.isFinalState) { + dynamic body = jsonDecode(update.responseBody ?? "{}"); + int statusCode = update.responseStatusCode ?? 500; + if (_handleUploadError( + candidates[i].asset, + body, + statusCode, + onError, + )) { + // TODO: Cancel queue because quota exceeded + } + } + }, + ); + } + if (duplicatedAssetIds.isNotEmpty) { await _saveDuplicatedAssetIds(duplicatedAssetIds); } @@ -531,6 +525,37 @@ class BackupService { return !anyErrors; } + bool _handleUploadError( + Asset asset, + dynamic body, + int statusCode, + void Function(ErrorUploadAsset error) onError, + ) { + if (![200, 201].contains(statusCode)) { + final error = body; + + debugPrint( + "Error(${error['statusCode']}) uploading ${asset.localId} | ${asset.fileName} | Created on ${asset.fileCreatedAt} | ${error['error']}", + ); + + onError( + ErrorUploadAsset( + asset: asset, + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt, + fileName: asset.fileName, + fileType: _getAssetType(asset.type), + errorMessage: error['error'], + ), + ); + + if (error == "Quota has been exceeded!") { + return false; + } + } + return true; + } + Future uploadLivePhotoVideo( String originalFileName, File? livePhotoVideoFile,