0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 03:01:32 -05:00

feat(mobile): share to mechanism (#15229)

* setup ios

* chore: succesfully sent media to the app

* share from Android

* wip: navigate to share screen

* wip: UI for displaying upload candidate

* wip: logic

* wip: upload logic

* wip: up up up we got it up

* wip

* wip

* wip

* upload state

* feat: i18n

* fix: release build ios'

* feat: clear file cache

* pr feedback

* using const for checking download status

---------

Co-authored-by: Alex <alex@pop-os.localdomain>
This commit is contained in:
Alex 2025-01-16 21:20:44 -06:00 committed by GitHub
parent 3a2bf91889
commit fd99bd05cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1368 additions and 55 deletions

View file

@ -43,7 +43,7 @@
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification_icon" />
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop"
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
@ -61,6 +61,34 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--TODO:
Add this filter if you want to handle shared images-->
<intent-filter android:label="Upload to Immich">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="Upload to Immich">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!--TODO:
Add this filter if you want to handle shared videos-->
<intent-filter android:label="Upload to Immich">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="Upload to Immich">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity>
@ -96,4 +124,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View file

@ -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"
"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 ({})"
}

View file

@ -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|

View file

@ -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

View file

@ -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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
65F32F30299BD2F800CE9261 /* BackgroundServicePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundServicePlugin.swift; sourceTree = "<group>"; };
65F32F32299D349D00CE9261 /* BackgroundSyncWorker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundSyncWorker.swift; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
@ -49,9 +76,16 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
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 = "<group>"; };
FAC6F8B22D287F120078CB2F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -81,6 +126,7 @@
isa = PBXGroup;
children = (
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -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 = "<group>";
@ -142,6 +190,17 @@
path = Runner;
sourceTree = "<group>";
};
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup;
children = (
FAC6F8B12D287F120078CB2F /* Info.plist */,
FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */,
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */,
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */,
);
path = ShareExtension;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */ = {
isa = PBXVariantGroup;
children = (
FAC6F8B22D287F120078CB2F /* Base */,
);
name = MainInterface.storyboard;
sourceTree = "<group>";
};
/* 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 */;

View file

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
@ -13,6 +15,24 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -61,6 +81,17 @@
<string>1.124.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>187</string>
<key>FLTEnableImpeller</key>
@ -73,6 +104,8 @@
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
@ -94,6 +127,10 @@
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>

View file

@ -4,5 +4,9 @@
<dict>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -6,5 +6,9 @@
<string>development</string>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>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 &gt; 0
).@count &gt; 0 </string>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,3 @@
import share_handler_ios_models
class ShareViewController: ShareHandlerIosViewController {}

View file

@ -1 +1,3 @@
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;

View file

@ -0,0 +1,7 @@
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
abstract interface class IShareHandlerRepository {
void Function(List<ShareIntentAttachment>)? onSharedMedia;
Future<void> init();
}

View file

@ -0,0 +1,11 @@
import 'package:background_downloader/background_downloader.dart';
abstract interface class IUploadRepository {
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
Future<bool> upload(UploadTask task);
Future<bool> cancel(String id);
Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id);
}

View file

@ -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/providers/asset_viewer/share_intent_upload.provider.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<void> initApp() async {
progressBar: true,
);
FileDownloader().trackTasksInGroup(
await FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
);
await FileDownloader().trackTasks();
}
Future<Isar> loadDb() async {
@ -208,6 +211,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
ref.read(shareIntentUploadProvider.notifier).init();
}
@override

View file

@ -0,0 +1,114 @@
// 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? _fileSize;
String get fileSize => _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<String, dynamic> toMap() {
return <String, dynamic>{
'path': path,
'type': type.index,
'status': status.index,
'uploadProgress': uploadProgress,
};
}
factory ShareIntentAttachment.fromMap(Map<String, dynamic> 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<String, dynamic>,
);
@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;
}
}

View file

@ -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(

View file

@ -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!,
],
),
),
);
}

View file

@ -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);

View file

@ -0,0 +1,263 @@
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<ShareIntentAttachment> 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);
void removeAttachment(ShareIntentAttachment attachment) {
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
}
void addAttachments(List<ShareIntentAttachment> 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: const BorderRadius.all(Radius.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<ShareIntentAttachment> 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(),
);
}
final statusIcon = switch (status) {
UploadStatus.enqueued => Icon(
Icons.check_circle_rounded,
color: context.primaryColor,
semanticLabel: 'enqueued'.tr(),
),
UploadStatus.running => Stack(
alignment: AlignmentDirectional.center,
children: [
SizedBox(
width: 40,
height: 40,
child: TweenAnimationBuilder(
tween: Tween<double>(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,
),
),
],
),
UploadStatus.complete => Icon(
Icons.check_circle_rounded,
color: Colors.green,
semanticLabel: 'completed'.tr(),
),
UploadStatus.notFound || UploadStatus.failed => Icon(
Icons.error_rounded,
color: Colors.red,
semanticLabel: 'failed'.tr(),
),
UploadStatus.canceled => Icon(
Icons.cancel_rounded,
color: Colors.red,
semanticLabel: 'canceled'.tr(),
),
UploadStatus.waitingtoRetry || UploadStatus.paused => Icon(
Icons.pause_circle_rounded,
color: context.primaryColor,
semanticLabel: 'paused'.tr(),
),
};
return statusIcon;
}
}

View file

@ -0,0 +1,143 @@
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/constants/constants.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
final shareIntentUploadProvider = StateNotifierProvider<
ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.watch(uploadServiceProvider),
ref.watch(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier
extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
ShareIntentUploadStateNotifier(
this.router,
this._uploadService,
this._shareIntentService,
) : super([]) {
_uploadService.onUploadStatus = _uploadStatusCallback;
_uploadService.onTaskProgress = _taskProgressCallback;
}
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState =
state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
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) {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE");
}
} else {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId}");
}
}
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed
if (update.progress == downloadFailed ||
update.progress == downloadCompleted) {
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<void> upload(File file) {
return _uploadService.upload(file);
}
Future<bool> cancelUpload(String id) {
return _uploadService.cancelUpload(id);
}
}

View file

@ -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<ShareIntentAttachment> attachments)? onSharedMedia;
@override
Future<void> 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<ShareIntentAttachment> _buildPayload(
List<SharedAttachment?> attachments,
) {
final payload = <ShareIntentAttachment>[];
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;
}
}

View file

@ -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<bool> upload(UploadTask task) {
return FileDownloader().enqueue(task);
}
@override
Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords();
}
@override
Future<bool> cancel(String id) {
return FileDownloader().cancelTaskWithId(id);
}
@override
Future<void> deleteRecordsWithIds(List<String> ids) {
return FileDownloader().database.deleteRecordsWithIds(ids);
}
}

View file

@ -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],
),
];
}

View file

@ -136,15 +136,10 @@ class AlbumAssetSelectionRouteArgs {
/// generated route for
/// [AlbumOptionsPage]
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
AlbumOptionsRoute({
Key? key,
List<PageRouteInfo>? children,
}) : super(
class AlbumOptionsRoute extends PageRouteInfo<void> {
const AlbumOptionsRoute({List<PageRouteInfo>? children})
: super(
AlbumOptionsRoute.name,
args: AlbumOptionsRouteArgs(
key: key,
),
initialChildren: children,
);
@ -153,25 +148,11 @@ class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<AlbumOptionsRouteArgs>();
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<AlbumPreviewRouteArgs> {
@ -1453,6 +1434,52 @@ class SettingsSubRouteArgs {
}
}
/// generated route for
/// [ShareIntentPage]
class ShareIntentRoute extends PageRouteInfo<ShareIntentRouteArgs> {
ShareIntentRoute({
Key? key,
required List<ShareIntentAttachment> attachments,
List<PageRouteInfo>? 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<ShareIntentRouteArgs>();
return ShareIntentPage(
key: args.key,
attachments: args.attachments,
);
},
);
}
class ShareIntentRouteArgs {
const ShareIntentRouteArgs({
this.key,
required this.attachments,
});
final Key? key;
final List<ShareIntentAttachment> attachments;
@override
String toString() {
return 'ShareIntentRouteArgs{key: $key, attachments: $attachments}';
}
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {

View file

@ -0,0 +1,23 @@
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';
final shareIntentServiceProvider = Provider(
(ref) => ShareIntentService(
ref.watch(shareHandlerRepositoryProvider),
),
);
class ShareIntentService {
final ShareHandlerRepository shareHandlerRepository;
void Function(List<ShareIntentAttachment> attachments)? onSharedMedia;
ShareIntentService(
this.shareHandlerRepository,
);
void init() {
shareHandlerRepository.onSharedMedia = onSharedMedia;
shareHandlerRepository.init();
}
}

View file

@ -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<bool> cancelUpload(String id) {
return FileDownloader().cancelTaskWithId(id);
}
Future<void> upload(File file) async {
final task = await _buildUploadTask(
hash(file.path).toString(),
file,
);
await _uploadRepository.upload(task);
}
Future<UploadTask> _buildUploadTask(
String id,
File file, {
Map<String, String>? 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,
);
}
}

View file

@ -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]}';
}

View file

@ -0,0 +1 @@
const uploadGroup = 'upload_group';

View file

@ -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),

View file

@ -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:

View file

@ -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