diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e49cf5b8daa91..ac57884eef34e 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -43,7 +43,7 @@ android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon" /> - @@ -61,6 +61,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -96,4 +124,4 @@ - + \ No newline at end of file diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 1ff40b3566e00..6c3f92c53d825 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -657,5 +657,15 @@ "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack", "wifi_name": "WiFi Name", - "your_wifi_name": "Your WiFi name" -} \ No newline at end of file + "your_wifi_name": "Your WiFi name", + "upload": "Upload", + "uploading": "Uploading", + "shared_intent_upload_button_progress_text": "{} / {} Uploaded", + "enqueued": "Enqueued", + "not_selected": "Not selected", + "completed": "Completed", + "failed": "Failed", + "paused": "Paused", + "canceled": "Canceled", + "upload_to_immich": "Upload to Immich ({})" +} diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile index b048c0bb0c87b..a98032db2009d 100644 --- a/mobile/ios/Podfile +++ b/mobile/ios/Podfile @@ -32,6 +32,13 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # share_handler addition start + target 'ShareExtension' do + inherit! :search_paths + pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models" + end + # share_handler addition end end post_install do |installer| diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index bc65bd4b7f919..00a63be8d7b28 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -82,9 +82,17 @@ PODS: - Flutter - FlutterMacOS - SAMKeychain (1.5.3) - - SDWebImage (5.19.4): - - SDWebImage/Core (= 5.19.4) - - SDWebImage/Core (5.19.4) + - SDWebImage (5.20.0): + - SDWebImage/Core (= 5.20.0) + - SDWebImage/Core (5.20.0) + - share_handler_ios (0.0.14): + - Flutter + - share_handler_ios/share_handler_ios_models (= 0.0.14) + - share_handler_ios_models + - share_handler_ios/share_handler_ios_models (0.0.14): + - Flutter + - share_handler_ios_models + - share_handler_ios_models (0.0.9) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -94,7 +102,7 @@ PODS: - Flutter - FlutterMacOS - SwiftyGif (5.4.5) - - Toast (4.0.0) + - Toast (4.1.1) - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -123,6 +131,8 @@ DEPENDENCIES: - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) + - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) + - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) @@ -184,6 +194,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" + share_handler_ios: + :path: ".symlinks/plugins/share_handler_ios/ios" + share_handler_ios_models: + :path: ".symlinks/plugins/share_handler_ios/ios/Models" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -222,15 +236,17 @@ SPEC CHECKSUMS: permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d + SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 + share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c + share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 -PODFILE CHECKSUM: 2282844f7aed70427ae663932332dad1225156c8 +PODFILE CHECKSUM: 03b7eead4ee77b9e778179eeb0f3b5513617451c COCOAPODS: 1.15.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index b3da30f108ab2..10133cc330549 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */; }; 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */; }; 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -16,8 +17,21 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; + FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; + FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = FAC6F88F2D287C890078CB2F; + remoteInfo = ShareExtension; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -29,13 +43,26 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.release.xcconfig"; 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -49,9 +76,16 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = ""; }; FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + FAC6F8902D287C890078CB2F /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + FAC6F8B12D287F120078CB2F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FAC6F8B22D287F120078CB2F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; + FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; + FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ @@ -64,6 +98,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FAC6F88D2D287C890078CB2F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3B6A31FED0FC846D6BD69BBC /* Pods_ShareExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -73,6 +115,9 @@ 2E3441B73560D0F6FD25E04F /* Pods-Runner.debug.xcconfig */, E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */, F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */, + F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */, + 571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */, + B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -81,6 +126,7 @@ isa = PBXGroup; children = ( 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */, + 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -110,6 +156,7 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + FAC6F8B62D287F120078CB2F /* ShareExtension */, 97C146EF1CF9000F007C117D /* Products */, 0FB772A5B9601143383626CA /* Pods */, 1754452DD81DA6620E279E51 /* Frameworks */, @@ -120,6 +167,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Immich-Debug.app */, + FAC6F8902D287C890078CB2F /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -142,6 +190,17 @@ path = Runner; sourceTree = ""; }; + FAC6F8B62D287F120078CB2F /* ShareExtension */ = { + isa = PBXGroup; + children = ( + FAC6F8B12D287F120078CB2F /* Info.plist */, + FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */, + FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */, + FAC6F8B52D287F120078CB2F /* ShareViewController.swift */, + ); + path = ShareExtension; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -155,6 +214,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D218A34AEE62BC1EF119F5B0 /* [CP] Embed Pods Frameworks */, 6724EEB7D74949FA08581154 /* [CP] Copy Pods Resources */, @@ -162,12 +222,31 @@ buildRules = ( ); dependencies = ( + FAC6F8992D287C890078CB2F /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */; productType = "com.apple.product-type.application"; }; + FAC6F88F2D287C890078CB2F /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */, + FAC6F88C2D287C890078CB2F /* Sources */, + FAC6F88D2D287C890078CB2F /* Frameworks */, + FAC6F88E2D287C890078CB2F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + productName = ShareExtension; + productReference = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -175,6 +254,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -182,6 +262,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + FAC6F88F2D287C890078CB2F = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -198,6 +281,7 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + FAC6F88F2D287C890078CB2F /* ShareExtension */, ); }; /* End PBXProject section */ @@ -214,6 +298,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FAC6F88E2D287C890078CB2F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -233,6 +325,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 3BEF3D71D97E337D921C0EB5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ShareExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 4044AF030EF7D8721844FFBA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -318,8 +432,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FAC6F88C2D287C890078CB2F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + FAC6F8992D287C890078CB2F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FAC6F88F2D287C890078CB2F /* ShareExtension */; + targetProxy = FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -337,6 +467,14 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */ = { + isa = PBXVariantGroup; + children = ( + FAC6F8B22D287F120078CB2F /* Base */, + ); + name = MainInterface.storyboard; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -404,6 +542,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 187; + CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -547,6 +686,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 187; + CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -576,6 +716,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 187; + CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -594,6 +735,129 @@ }; name = Release; }; + FAC6F89C2D287C890078CB2F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = group.app.immich.share; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FAC6F89D2D287C890078CB2F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 571EAA93D77181C7C98C2EA6 /* Pods-ShareExtension.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = group.app.immich.share; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FAC6F89E2D287C890078CB2F /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = group.app.immich.share; + DEVELOPMENT_TEAM = 2F67MQ8R79; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -617,6 +881,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FAC6F89C2D287C890078CB2F /* Debug */, + FAC6F89D2D287C890078CB2F /* Release */, + FAC6F89E2D287C890078CB2F /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index f3aed115b25bc..be5ec5d9d7069 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + AppGroupId + $(CUSTOM_GROUP_ID) BGTaskSchedulerPermittedIdentifiers app.alextran.immich.backgroundFetch @@ -13,6 +15,24 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + ShareHandler + LSHandlerRank + Alternate + LSItemContentTypes + + public.file-url + public.image + public.text + public.movie + public.url + public.data + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -61,6 +81,17 @@ 1.124.0 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + CFBundleVersion 187 FLTEnableImpeller @@ -73,6 +104,8 @@ LSRequiresIPhoneOS + LSSupportsOpeningDocumentsInPlace + No MGLMapboxMetricsEnabledSettingShownInApp NSAppTransportSecurity @@ -94,6 +127,10 @@ We need to manage backup your photos album NSPhotoLibraryUsageDescription We need to manage backup your photos album + NSUserActivityTypes + + INSendMessageIntent + UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index ba21fbdaf2902..d558e35e0a036 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ com.apple.developer.networking.wifi-info + com.apple.security.application-groups + + group.app.immich.share + diff --git a/mobile/ios/Runner/RunnerProfile.entitlements b/mobile/ios/Runner/RunnerProfile.entitlements index 75e36a143e4f0..d44633db4087c 100644 --- a/mobile/ios/Runner/RunnerProfile.entitlements +++ b/mobile/ios/Runner/RunnerProfile.entitlements @@ -6,5 +6,9 @@ development com.apple.developer.networking.wifi-info + com.apple.security.application-groups + + group.app.immich.share + diff --git a/mobile/ios/ShareExtension/Base.lproj/MainInterface.storyboard b/mobile/ios/ShareExtension/Base.lproj/MainInterface.storyboard new file mode 100644 index 0000000000000..286a50894d878 --- /dev/null +++ b/mobile/ios/ShareExtension/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/ShareExtension/Info.plist b/mobile/ios/ShareExtension/Info.plist new file mode 100644 index 0000000000000..0f52fbffdf9c7 --- /dev/null +++ b/mobile/ios/ShareExtension/Info.plist @@ -0,0 +1,35 @@ + + + + + AppGroupId + $(CUSTOM_GROUP_ID) + NSExtension + + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + NSExtensionActivationRule + SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, + $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY + $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 + ).@count > 0 + PHSupportedMediaTypes + + Video + Image + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + \ No newline at end of file diff --git a/mobile/ios/ShareExtension/ShareExtension.entitlements b/mobile/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000000000..4ad1a257d8b72 --- /dev/null +++ b/mobile/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.immich.share + + + diff --git a/mobile/ios/ShareExtension/ShareViewController.swift b/mobile/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000000000..b1b38efc79eae --- /dev/null +++ b/mobile/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,3 @@ +import share_handler_ios_models + +class ShareViewController: ShareHandlerIosViewController {} \ No newline at end of file diff --git a/mobile/lib/interfaces/share_handler.interface.dart b/mobile/lib/interfaces/share_handler.interface.dart new file mode 100644 index 0000000000000..6d0eb9170c412 --- /dev/null +++ b/mobile/lib/interfaces/share_handler.interface.dart @@ -0,0 +1,7 @@ +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; + +abstract interface class IShareHandlerRepository { + void Function(List)? onSharedMedia; + + Future init(); +} diff --git a/mobile/lib/interfaces/upload.interface.dart b/mobile/lib/interfaces/upload.interface.dart new file mode 100644 index 0000000000000..d4b2298a148e8 --- /dev/null +++ b/mobile/lib/interfaces/upload.interface.dart @@ -0,0 +1,11 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IUploadRepository { + void Function(TaskStatusUpdate)? onUploadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future upload(UploadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 807212fc655eb..e90d2d7b55958 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:timezone/data/latest.dart'; import 'package:isar/isar.dart'; @@ -107,10 +108,12 @@ Future initApp() async { progressBar: true, ); - FileDownloader().trackTasksInGroup( + await FileDownloader().trackTasksInGroup( downloadGroupLivePhoto, markDownloadedComplete: false, ); + + await FileDownloader().trackTasks(); } Future loadDb() async { @@ -208,6 +211,8 @@ class ImmichAppState extends ConsumerState // needs to be delayed so that EasyLocalization is working ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); }); + + ref.read(shareIntentServiceProvider).init(); } @override diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart new file mode 100644 index 0000000000000..bd9cee55cdb0e --- /dev/null +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -0,0 +1,112 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; +import 'dart:io'; + +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:path/path.dart'; + +enum ShareIntentAttachmentType { + image, + video, +} + +enum UploadStatus { + enqueued, + running, + complete, + notFound, + failed, + canceled, + waitingtoRetry, + paused, +} + +class ShareIntentAttachment { + final String path; + + // enum + final ShareIntentAttachmentType type; + + // enum + final UploadStatus status; + + final double uploadProgress; + + final int fileLength; + + ShareIntentAttachment({ + required this.path, + required this.type, + required this.status, + this.uploadProgress = 0, + this.fileLength = 0, + }); + + int get id => hash(path); + + File get file => File(path); + + String get fileName => basename(file.path); + + bool get isImage => type == ShareIntentAttachmentType.image; + + bool get isVideo => type == ShareIntentAttachmentType.video; + + String get fileSize => formatHumanReadableBytes(fileLength, 2); + + ShareIntentAttachment copyWith({ + String? path, + ShareIntentAttachmentType? type, + UploadStatus? status, + double? uploadProgress, + }) { + return ShareIntentAttachment( + path: path ?? this.path, + type: type ?? this.type, + status: status ?? this.status, + uploadProgress: uploadProgress ?? this.uploadProgress, + ); + } + + Map toMap() { + return { + 'path': path, + 'type': type.index, + 'status': status.index, + 'uploadProgress': uploadProgress, + }; + } + + factory ShareIntentAttachment.fromMap(Map map) { + return ShareIntentAttachment( + path: map['path'] as String, + type: ShareIntentAttachmentType.values[map['type'] as int], + status: UploadStatus.values[map['status'] as int], + uploadProgress: map['uploadProgress'] as double, + ); + } + + String toJson() => json.encode(toMap()); + + factory ShareIntentAttachment.fromJson(String source) => + ShareIntentAttachment.fromMap( + json.decode(source) as Map, + ); + + @override + String toString() { + return 'ShareIntentAttachment(path: $path, type: $type, status: $status, uploadProgress: $uploadProgress)'; + } + + @override + bool operator ==(covariant ShareIntentAttachment other) { + if (identical(this, other)) return true; + + return other.path == path && other.type == type; + } + + @override + int get hashCode { + return path.hashCode ^ type.hashCode; + } +} diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart index 4cb9804e25bc3..f417f9fb38c3a 100644 --- a/mobile/lib/pages/album/album_shared_user_icons.dart +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -32,7 +32,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget { } return GestureDetector( - onTap: () => context.pushRoute(AlbumOptionsRoute()), + onTap: () => context.pushRoute(const AlbumOptionsRoute()), child: SizedBox( height: 50, child: ListView.builder( diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart index c6bbeb2e7df24..f2cb9f19aea32 100644 --- a/mobile/lib/pages/common/large_leading_tile.dart +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -13,6 +13,9 @@ class LargeLeadingTile extends StatelessWidget { horizontal: 16.0, ), this.borderRadius = 20.0, + this.trailing, + this.selected = false, + this.disabled = false, }); final Widget leading; @@ -21,30 +24,43 @@ class LargeLeadingTile extends StatelessWidget { final Widget? subtitle; final EdgeInsetsGeometry leadingPadding; final double borderRadius; - + final Widget? trailing; + final bool selected; + final bool disabled; @override Widget build(BuildContext context) { return InkWell( borderRadius: BorderRadius.circular(borderRadius), - onTap: onTap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: leadingPadding, - child: leading, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: context.width * 0.6, - child: title, + onTap: disabled ? null : onTap, + child: Container( + decoration: BoxDecoration( + color: selected + ? Theme.of(context).primaryColor.withAlpha(30) + : Colors.transparent, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: leadingPadding, + child: leading, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: context.width * 0.6, + child: title, + ), + subtitle ?? const SizedBox.shrink(), + ], ), - subtitle ?? const SizedBox.shrink(), - ], - ), - ], + ), + if (trailing != null) trailing!, + ], + ), ), ); } diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 30fe1ab3f2939..9e15b0193e749 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -34,10 +34,12 @@ class PhotosPage extends HookConsumerWidget { Future(() => ref.read(assetProvider.notifier).getAllAsset()); Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums()); ref.read(serverInfoProvider.notifier).getServerInfo(); + return; }, [], ); + Widget buildLoadingIndicator() { Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1); diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart new file mode 100644 index 0000000000000..77249cd486d75 --- /dev/null +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -0,0 +1,281 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; + +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/entities/store.entity.dart' as db_store; + +@RoutePage() +class ShareIntentPage extends HookConsumerWidget { + const ShareIntentPage({super.key, required this.attachments}); + + final List attachments; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentEndpoint = + db_store.Store.get(db_store.StoreKey.serverEndpoint); + final candidates = ref.watch(shareIntentUploadProvider); + final isUploaded = useState(false); + + useEffect( + () { + Future.microtask(() { + ref + .read(shareIntentUploadProvider.notifier) + .addAttachments(attachments); + }); + return () {}; + }, + const [], + ); + + void removeAttachment(ShareIntentAttachment attachment) { + ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment); + } + + void addAttachments(List attachments) { + ref.read(shareIntentUploadProvider.notifier).addAttachments(attachments); + } + + void upload() async { + for (final attachment in candidates) { + await ref + .read(shareIntentUploadProvider.notifier) + .upload(attachment.file); + } + + isUploaded.value = true; + } + + bool isSelected(ShareIntentAttachment attachment) { + return candidates.contains(attachment); + } + + void toggleSelection(ShareIntentAttachment attachment) { + if (isSelected(attachment)) { + removeAttachment(attachment); + } else { + addAttachments([attachment]); + } + } + + return Scaffold( + appBar: AppBar( + title: Column( + children: [ + const Text('upload_to_immich').tr( + args: [ + candidates.length.toString(), + ], + ), + Text( + currentEndpoint, + style: context.textTheme.labelMedium?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), + ), + ), + ], + ), + ), + body: ListView.builder( + itemCount: attachments.length, + itemBuilder: (context, index) { + final attachment = attachments[index]; + final target = candidates.firstWhere( + (element) => element.id == attachment.id, + orElse: () => attachment, + ); + + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 16, + ), + child: LargeLeadingTile( + onTap: () => toggleSelection(attachment), + disabled: isUploaded.value, + selected: isSelected(attachment), + leading: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: attachment.isImage + ? Image.file( + attachment.file, + width: 64, + height: 64, + fit: BoxFit.cover, + ) + : const SizedBox( + width: 64, + height: 64, + child: Center( + child: Icon( + Icons.videocam, + color: Colors.white, + ), + ), + ), + ), + if (attachment.isImage) + const Positioned( + top: 8, + right: 8, + child: Icon( + Icons.image, + color: Colors.white, + size: 20, + shadows: [ + Shadow( + offset: Offset(0, 0), + blurRadius: 8.0, + color: Colors.black45, + ), + ], + ), + ), + ], + ), + title: Text( + attachment.fileName, + style: context.textTheme.titleSmall, + ), + subtitle: Text( + attachment.fileSize, + style: context.textTheme.labelLarge, + ), + trailing: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: UploadStatusIcon( + selected: isSelected(attachment), + status: target.status, + progress: target.uploadProgress, + ), + ), + ), + ); + }, + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 48, + child: ElevatedButton( + onPressed: isUploaded.value ? null : upload, + child: isUploaded.value + ? UploadingText(candidates: candidates) + : const Text('upload').tr(), + ), + ), + ), + ), + ); + } +} + +class UploadingText extends StatelessWidget { + const UploadingText({super.key, required this.candidates}); + final List candidates; + + @override + Widget build(BuildContext context) { + final uploadedCount = candidates.where((element) { + return element.status == UploadStatus.complete; + }).length; + + return const Text("shared_intent_upload_button_progress_text") + .tr(args: [uploadedCount.toString(), candidates.length.toString()]); + } +} + +class UploadStatusIcon extends StatelessWidget { + const UploadStatusIcon({ + super.key, + required this.status, + required this.selected, + this.progress = 0, + }); + + final UploadStatus status; + final double progress; + final bool selected; + + @override + Widget build(BuildContext context) { + if (!selected) { + return Icon( + Icons.check_circle_outline_rounded, + color: context.colorScheme.onSurface.withAlpha(100), + semanticLabel: 'not_selected'.tr(), + ); + } + + switch (status) { + case UploadStatus.enqueued: + return Icon( + Icons.check_circle_rounded, + color: context.primaryColor, + semanticLabel: 'enqueued'.tr(), + ); + case UploadStatus.running: + return Stack( + alignment: AlignmentDirectional.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 500), + builder: (context, value, _) => CircularProgressIndicator( + backgroundColor: context.colorScheme.surfaceContainerLow, + strokeWidth: 3, + value: value, + semanticsLabel: 'uploading'.tr(), + ), + ), + ), + Text( + (progress * 100).toStringAsFixed(0), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + case UploadStatus.complete: + return Icon( + Icons.check_circle_rounded, + color: Colors.green, + semanticLabel: 'completed'.tr(), + ); + case UploadStatus.notFound: + case UploadStatus.failed: + return Icon( + Icons.error_rounded, + color: Colors.red, + semanticLabel: 'failed'.tr(), + ); + case UploadStatus.canceled: + return Icon( + Icons.cancel_rounded, + color: Colors.red, + semanticLabel: 'canceled'.tr(), + ); + case UploadStatus.waitingtoRetry: + case UploadStatus.paused: + return Icon( + Icons.pause_circle_rounded, + color: context.primaryColor, + semanticLabel: 'paused'.tr(), + ); + } + } +} diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart new file mode 100644 index 0000000000000..49392928c0fd1 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; +import 'package:immich_mobile/services/upload.service.dart'; + +final shareIntentUploadProvider = StateNotifierProvider.autoDispose< + ShareIntentUploadStateNotifier, List>( + ((ref) => ShareIntentUploadStateNotifier( + ref.watch(uploadServiceProvider), + )), +); + +class ShareIntentUploadStateNotifier + extends StateNotifier> { + final UploadService _uploadService; + + ShareIntentUploadStateNotifier( + this._uploadService, + ) : super([]) { + _uploadService.onUploadStatus = _uploadStatusCallback; + _uploadService.onTaskProgress = _taskProgressCallback; + } + + void addAttachments(List attachments) { + state = [...state, ...attachments]; + } + + void removeAttachment(ShareIntentAttachment attachment) { + state = state.where((element) => element != attachment).toList(); + } + + void clearAttachments() { + state = []; + } + + void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async { + if (status == TaskStatus.canceled) { + return; + } + + final taskId = task.task.taskId; + final uploadStatus = switch (task.status) { + TaskStatus.complete => UploadStatus.complete, + TaskStatus.failed => UploadStatus.failed, + TaskStatus.canceled => UploadStatus.canceled, + TaskStatus.enqueued => UploadStatus.enqueued, + TaskStatus.running => UploadStatus.running, + TaskStatus.paused => UploadStatus.paused, + TaskStatus.notFound => UploadStatus.notFound, + TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry + }; + + state = [ + for (final attachment in state) + if (attachment.id == taskId.toInt()) + attachment.copyWith(status: uploadStatus) + else + attachment, + ]; + } + + void _uploadStatusCallback(TaskStatusUpdate update) { + _updateUploadStatus(update, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.responseStatusCode == 200) { + kDebugMode + ? debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE") + : null; + } else { + kDebugMode ? debugPrint("[COMPLETE] ${update.task.taskId}") : null; + } + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + final taskId = update.task.taskId; + state = [ + for (final attachment in state) + if (attachment.id == taskId.toInt()) + attachment.copyWith(uploadProgress: update.progress) + else + attachment, + ]; + } + + Future upload(File file) { + return _uploadService.upload(file); + } + + Future cancelUpload(String id) { + return _uploadService.cancelUpload(id); + } +} diff --git a/mobile/lib/repositories/share_handler.repository.dart b/mobile/lib/repositories/share_handler.repository.dart new file mode 100644 index 0000000000000..4c07b662a8ea9 --- /dev/null +++ b/mobile/lib/repositories/share_handler.repository.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/share_handler.interface.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; +import 'package:share_handler/share_handler.dart'; + +final shareHandlerRepositoryProvider = Provider( + (ref) => ShareHandlerRepository(), +); + +class ShareHandlerRepository implements IShareHandlerRepository { + ShareHandlerRepository(); + + @override + void Function(List attachments)? onSharedMedia; + + @override + Future init() async { + final handler = ShareHandlerPlatform.instance; + final media = await handler.getInitialSharedMedia(); + + if (media != null && media.attachments != null) { + onSharedMedia?.call(_buildPayload(media.attachments!)); + } + + handler.sharedMediaStream.listen((SharedMedia media) { + if (media.attachments != null) { + onSharedMedia?.call(_buildPayload(media.attachments!)); + } + }); + } + + List _buildPayload( + List attachments, + ) { + final payload = []; + + for (final attachment in attachments) { + if (attachment == null) { + continue; + } + + final type = attachment.type == SharedAttachmentType.image + ? ShareIntentAttachmentType.image + : ShareIntentAttachmentType.video; + + final fileLength = File(attachment.path).lengthSync(); + + payload.add( + ShareIntentAttachment( + path: attachment.path, + type: type, + status: UploadStatus.enqueued, + uploadProgress: 0.0, + fileLength: fileLength, + ), + ); + } + + return payload; + } +} diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart new file mode 100644 index 0000000000000..6445d144f627c --- /dev/null +++ b/mobile/lib/repositories/upload.repository.dart @@ -0,0 +1,42 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/upload.interface.dart'; +import 'package:immich_mobile/utils/upload.dart'; + +final uploadRepositoryProvider = Provider((ref) => UploadRepository()); + +class UploadRepository implements IUploadRepository { + @override + void Function(TaskStatusUpdate)? onUploadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + UploadRepository() { + FileDownloader().registerCallbacks( + group: uploadGroup, + taskStatusCallback: (update) => onUploadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future upload(UploadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 5adfeb4061db2..3078a0dc1a714 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/pages/backup/album_preview.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; @@ -57,6 +58,7 @@ import 'package:immich_mobile/pages/library/partner/partner.page.dart'; import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; +import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -277,6 +279,10 @@ class AppRouter extends RootStackRouter { page: NativeVideoViewerRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: ShareIntentRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 3bd89661753f9..48528fdfe2ffd 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -136,15 +136,10 @@ class AlbumAssetSelectionRouteArgs { /// generated route for /// [AlbumOptionsPage] -class AlbumOptionsRoute extends PageRouteInfo { - AlbumOptionsRoute({ - Key? key, - List? children, - }) : super( +class AlbumOptionsRoute extends PageRouteInfo { + const AlbumOptionsRoute({List? children}) + : super( AlbumOptionsRoute.name, - args: AlbumOptionsRouteArgs( - key: key, - ), initialChildren: children, ); @@ -153,25 +148,11 @@ class AlbumOptionsRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs(); - return AlbumOptionsPage( - key: args.key, - ); + return const AlbumOptionsPage(); }, ); } -class AlbumOptionsRouteArgs { - const AlbumOptionsRouteArgs({this.key}); - - final Key? key; - - @override - String toString() { - return 'AlbumOptionsRouteArgs{key: $key}'; - } -} - /// generated route for /// [AlbumPreviewPage] class AlbumPreviewRoute extends PageRouteInfo { @@ -1453,6 +1434,52 @@ class SettingsSubRouteArgs { } } +/// generated route for +/// [ShareIntentPage] +class ShareIntentRoute extends PageRouteInfo { + ShareIntentRoute({ + Key? key, + required List attachments, + List? children, + }) : super( + ShareIntentRoute.name, + args: ShareIntentRouteArgs( + key: key, + attachments: attachments, + ), + initialChildren: children, + ); + + static const String name = 'ShareIntentRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ShareIntentPage( + key: args.key, + attachments: args.attachments, + ); + }, + ); +} + +class ShareIntentRouteArgs { + const ShareIntentRouteArgs({ + this.key, + required this.attachments, + }); + + final Key? key; + + final List attachments; + + @override + String toString() { + return 'ShareIntentRouteArgs{key: $key, attachments: $attachments}'; + } +} + /// generated route for /// [SharedLinkEditPage] class SharedLinkEditRoute extends PageRouteInfo { diff --git a/mobile/lib/services/share_intent_service.dart b/mobile/lib/services/share_intent_service.dart new file mode 100644 index 0000000000000..31158de49eda4 --- /dev/null +++ b/mobile/lib/services/share_intent_service.dart @@ -0,0 +1,27 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; +import 'package:immich_mobile/repositories/share_handler.repository.dart'; +import 'package:immich_mobile/routing/router.dart'; + +final shareIntentServiceProvider = Provider( + (ref) => ShareIntentService( + ref.watch(appRouterProvider), + ref.watch(shareHandlerRepositoryProvider), + ), +); + +class ShareIntentService { + final AppRouter router; + final ShareHandlerRepository shareHandlerRepository; + + ShareIntentService(this.router, this.shareHandlerRepository); + + Future init() { + shareHandlerRepository.onSharedMedia = navigateToShareIntentRoute; + return shareHandlerRepository.init(); + } + + void navigateToShareIntentRoute(List attachments) { + router.push(ShareIntentRoute(attachments: attachments)); + } +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart new file mode 100644 index 0000000000000..78f143658763c --- /dev/null +++ b/mobile/lib/services/upload.service.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/upload.interface.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/upload.dart'; +import 'package:path/path.dart'; +// import 'package:logging/logging.dart'; + +final uploadServiceProvider = Provider( + (ref) => UploadService( + ref.watch(uploadRepositoryProvider), + ), +); + +class UploadService { + final IUploadRepository _uploadRepository; + // final Logger _log = Logger("UploadService"); + void Function(TaskStatusUpdate)? onUploadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + UploadService( + this._uploadRepository, + ) { + _uploadRepository.onUploadStatus = _onUploadCallback; + _uploadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onUploadCallback(TaskStatusUpdate update) { + onUploadStatus?.call(update); + } + + Future cancelUpload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future upload(File file) async { + final task = await _buildUploadTask( + hash(file.path).toString(), + file, + ); + + await _uploadRepository.upload(task); + } + + Future _buildUploadTask( + String id, + File file, { + Map? fields, + }) async { + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final url = Uri.parse('$serverEndpoint/assets').toString(); + final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); + + final (baseDirectory, directory, filename) = + await Task.split(filePath: file.path); + final stats = await file.stat(); + final fileCreatedAt = stats.changed; + final fileModifiedAt = stats.modified; + + final fieldsMap = { + 'filename': filename, + 'deviceAssetId': id, + 'deviceId': deviceId, + 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), + 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), + 'isFavorite': 'false', + 'duration': '0', + if (fields != null) ...fields, + }; + + return UploadTask( + taskId: id, + httpRequestMethod: 'POST', + url: url, + headers: headers, + filename: filename, + fields: fieldsMap, + baseDirectory: baseDirectory, + directory: directory, + fileField: 'assetData', + group: uploadGroup, + updates: Updates.statusAndProgress, + ); + } +} diff --git a/mobile/lib/utils/bytes_units.dart b/mobile/lib/utils/bytes_units.dart index ea9d0f5cf5eb4..3a73e5b3200e6 100644 --- a/mobile/lib/utils/bytes_units.dart +++ b/mobile/lib/utils/bytes_units.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + String formatBytes(int bytes) { const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']; @@ -14,3 +16,10 @@ String formatBytes(int bytes) { return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}"; } + +String formatHumanReadableBytes(int bytes, int decimals) { + if (bytes <= 0) return "0 B"; + const suffixes = ["B", "KB", "MB", "GB", "TB"]; + var i = (log(bytes) / log(1024)).floor(); + return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; +} diff --git a/mobile/lib/utils/upload.dart b/mobile/lib/utils/upload.dart new file mode 100644 index 0000000000000..a0b77f1d93b45 --- /dev/null +++ b/mobile/lib/utils/upload.dart @@ -0,0 +1 @@ +const uploadGroup = 'upload_group'; diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 7c36ebc21d4e4..b058f29e7d286 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -206,7 +206,7 @@ class AlbumViewerAppbar extends HookConsumerWidget ), ListTile( leading: const Icon(Icons.settings_rounded), - onTap: () => context.navigateTo(AlbumOptionsRoute()), + onTap: () => context.navigateTo(const AlbumOptionsRoute()), title: const Text( "translated_text_options", style: TextStyle(fontWeight: FontWeight.w500), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 34eb217828102..91c068ec2669d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1328,6 +1328,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.8" + share_handler: + dependency: "direct main" + description: + name: share_handler + sha256: "76575533be04df3fecbebd3c5b5325a8271b5973131f8b8b0ab8490c395a5d37" + url: "https://pub.dev" + source: hosted + version: "0.0.22" + share_handler_android: + dependency: transitive + description: + name: share_handler_android + sha256: "124dcc914fb7ecd89076d3dc28435b98fe2129a988bf7742f7a01dcb66a95667" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + share_handler_ios: + dependency: transitive + description: + name: share_handler_ios + sha256: cdc21f88f336a944157a8e9ceb191525cee3b082d6eb6c2082488e4f09dc3ece + url: "https://pub.dev" + source: hosted + version: "0.0.15" + share_handler_platform_interface: + dependency: transitive + description: + name: share_handler_platform_interface + sha256: "7a4df95a87b326b2f07458d937f2281874567c364b7b7ebe4e7d50efaae5f106" + url: "https://pub.dev" + source: hosted + version: "0.0.6" share_plus: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index c69290b29913b..6d9779b3e66a4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -77,6 +77,7 @@ dependencies: image_picker: ^1.0.7 # only used to select user profile image from system gallery -> we can simply select an image from within immich? logging: ^1.2.0 file_picker: ^8.0.0+1 + share_handler: ^0.0.22 # This is uncommented in F-Droid build script # Taken from https://github.com/Myzel394/locus/blob/445013d22ec1d759027d4303bd65b30c5c8588c8/pubspec.yaml#L105