From 525504bc479bc6a4491b1e5b76ea610390e9105c Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 19:23:37 -0400 Subject: [PATCH] iOS: Share Extension (share to Noo) via an App Group inbox ShareExtension target copies shared files into group.dev.ayushya.noo and leaves a manifest; the app consumes it on launch/activation through the existing share_intent channel into the destination picker. Extension is embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*. Co-Authored-By: Claude Sonnet 5.5 --- .claude/context/server.md | 45 +++- ios/Runner.xcodeproj/project.pbxproj | 218 ++++++++++++++++++ ios/Runner/Native/NativeServices.swift | 56 ++++- ios/Runner/Native/TransferManager.swift | 5 + ios/Runner/Runner.entitlements | 10 + ios/RunnerTests/RunnerTests.swift | 48 ++++ ios/ShareExtension/Info.plist | 43 ++++ .../ShareExtension.entitlements | 10 + ios/ShareExtension/ShareViewController.swift | 122 ++++++++++ ios/Shared/SharedInbox.swift | 92 ++++++++ 10 files changed, 637 insertions(+), 12 deletions(-) create mode 100644 ios/Runner/Runner.entitlements create mode 100644 ios/ShareExtension/Info.plist create mode 100644 ios/ShareExtension/ShareExtension.entitlements create mode 100644 ios/ShareExtension/ShareViewController.swift create mode 100644 ios/Shared/SharedInbox.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index 17681b4..6cc1370 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -817,9 +817,10 @@ files, 15 for folders), keeping the existing reshare bit, plus Remove access. The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status `EventChannel`s) were written for Android in Kotlin. iOS implements -`upload_service` (+ its status stream) and `download_service` so far -(`ios/Runner/Native/`, see below); for the other three there's no handler, so -calls throw `MissingPluginException`. `lib/services/native_channel.dart` +`upload_service` (+ its status stream), `download_service` and +`share_intent` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see +below); for `pick_intent` and `sync_service` there's no handler, so calls +throw `MissingPluginException`. `lib/services/native_channel.dart` makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start @@ -871,5 +872,39 @@ iOS. `OfflineController` and `localSyncedFilePath` both go through it. ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS Simulator,id='`). -Not built on iOS yet: Share Extension (needs an App Group), the picker -(File Provider), and sync (`BGTaskScheduler`) - see the iOS handoff notes. +### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`) + +"Share to Noo" from any app's share sheet. An extension can't pick a +destination, hold a session, or launch its host app, so it only *hands off*: + +1. `ShareViewController` (extension) copies each shared file + (`NSItemProvider.loadFileRepresentation`, public.item) into the App Group + container (`group.dev.ayushya.noo`) under `SharedInbox//`, appends + them to `SharedInbox/pending.json`, and posts a local notification ("Open + Noo to choose where to upload N files") since it can't open the app. +2. The app reads that manifest through `SharedInbox.consume` - once on cold + start via `share_intent.getInitialShare`, and whenever it becomes active + (`UIApplication.didBecomeActiveNotification`) via the `share_intent/new` + event stream - and from there it's the same Dart flow as an Android + share: `main.dart`'s `_handleSharedFiles` -> `ShareUploadView` -> + `UploadService`. The `file://` URI points into the App Group container; + `TransferManager.startUpload` deletes that copy once it has staged it. +3. `ios/Shared/SharedInbox.swift` is compiled into both targets (manifest + read/append/consume under an `NSFileCoordinator`, entries whose file is + gone are dropped, each share is delivered once) and unit-tested in + `RunnerTests`. + +Both targets carry `group.dev.ayushya.noo` in their `.entitlements` +(`Runner/Runner.entitlements`, `ShareExtension/ShareExtension.entitlements`); +the extension's bundle id is `dev.ayushya.noo.ShareExtension`, and its +version/build come from the same `FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` +xcconfig as the app (iOS rejects a mismatch). The extension is embedded via an +"Embed Foundation Extensions" phase placed *before* Flutter's script phases +(after them Xcode reports a dependency cycle). The simulator honours the App +Group without a signing team; a real device needs a team that owns the group +id and both bundle ids registered. To try it without the share sheet, put a +file and a `pending.json` in the simulator's group container +(`xcrun simctl get_app_container dev.ayushya.noo groups`) and launch. + +Not built on iOS yet: the picker (File Provider) and sync +(`BGTaskScheduler`) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 325749d..080248d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,16 +8,21 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F798220CB598B611CA03EFC /* WebDAV.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; }; /* End PBXBuildFile section */ @@ -29,9 +34,27 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = F9BE9B2F18B10324D7B84776; + remoteInfo = ShareExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -45,11 +68,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 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 = ""; }; + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 6F798220CB598B611CA03EFC /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -57,6 +85,8 @@ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = ""; }; + 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = ""; }; + 857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -68,6 +98,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 807C81744907462A25A6C1F8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -79,6 +117,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2B7F16B0F0EA2C5A842ED860 /* iOS */ = { + isa = PBXGroup; + children = ( + 0AE6F681A3100A461B4A5318 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -87,6 +133,17 @@ path = RunnerTests; sourceTree = ""; }; + 3664F08C433ECEB00D1C4BDB /* ShareExtension */ = { + isa = PBXGroup; + children = ( + 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */, + 49213989F0FA1B83B0B8A523 /* Info.plist */, + 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */, + ); + name = ShareExtension; + path = ShareExtension; + sourceTree = ""; + }; 5055E53DC55C0BCABD5F04F4 /* Native */ = { isa = PBXGroup; children = ( @@ -98,6 +155,15 @@ path = Native; sourceTree = ""; }; + 5DF0A9032A8500B67D6C89AE /* Shared */ = { + isa = PBXGroup; + children = ( + 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */, + ); + name = Shared; + path = Shared; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -117,6 +183,9 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 5DF0A9032A8500B67D6C89AE /* Shared */, + 3664F08C433ECEB00D1C4BDB /* ShareExtension */, + A5B80AB7F4AB17F90B745D0D /* Frameworks */, ); sourceTree = ""; }; @@ -125,6 +194,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 405A5408F4071B4D12F08B21 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -142,10 +212,19 @@ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 5055E53DC55C0BCABD5F04F4 /* Native */, + 857A0E7637776A71AFBB0614 /* Runner.entitlements */, ); path = Runner; sourceTree = ""; }; + A5B80AB7F4AB17F90B745D0D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2B7F16B0F0EA2C5A842ED860 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -170,6 +249,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -180,6 +260,7 @@ buildRules = ( ); dependencies = ( + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -189,6 +270,23 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + F9BE9B2F18B10324D7B84776 /* ShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */; + buildPhases = ( + 3CD40C8747E650E6F817CDDB /* Sources */, + 807C81744907462A25A6C1F8 /* Frameworks */, + 4EDBC7D61EA0C0974DA27BD6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ShareExtension; + productName = ShareExtension; + productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -227,6 +325,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + F9BE9B2F18B10324D7B84776 /* ShareExtension */, ); }; /* End PBXProject section */ @@ -239,6 +338,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4EDBC7D61EA0C0974DA27BD6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -295,6 +401,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3CD40C8747E650E6F817CDDB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */, + 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -305,6 +420,7 @@ 66D3CBDE6B3CD3DC578CECBE /* WebDAV.swift in Sources */, FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */, 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */, + F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -316,6 +432,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ShareExtension; + target = F9BE9B2F18B10324D7B84776 /* ShareExtension */; + targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -396,6 +518,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -458,6 +581,89 @@ }; name = Profile; }; + 3B057BF66761E30DEABF8B30 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 6BF252CEA7A55024A2F58A36 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 6FB3F72FB5A94DE254FF4FBF /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -575,6 +781,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -597,6 +804,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -645,6 +853,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6BF252CEA7A55024A2F58A36 /* Release */, + 6FB3F72FB5A94DE254FF4FBF /* Debug */, + 3B057BF66761E30DEABF8B30 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index cdc24b8..6430998 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -1,8 +1,10 @@ import Flutter import Foundation +import UIKit -/// Pushes upload-batch completions to Dart's `UploadService.completions`. -final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { +/// Holds the one native-side listener of an `EventChannel` so Swift code can +/// push events to Dart whenever they happen. +final class SinkStreamHandler: NSObject, FlutterStreamHandler { var sink: FlutterEventSink? func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) @@ -19,12 +21,12 @@ final class UploadStatusStreamHandler: NSObject, FlutterStreamHandler { } /// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to -/// (Android's lives in `MainActivity.kt`). Only the upload and download -/// services are implemented so far; the rest still throw -/// `MissingPluginException`, which the Dart side treats as "unavailable" -/// (`lib/services/native_channel.dart`). +/// (Android's lives in `MainActivity.kt`). Upload, download and share (the +/// receiving half of the Share Extension) are implemented so far; the rest +/// still throw `MissingPluginException`, which the Dart side treats as +/// "unavailable" (`lib/services/native_channel.dart`). enum NativeServices { - private static let uploadStatus = UploadStatusStreamHandler() + private static let uploadStatus = SinkStreamHandler() static func register(messenger: FlutterBinaryMessenger) { FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger) @@ -72,6 +74,46 @@ enum NativeServices { uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed]) } TransferManager.shared.reconnect() + registerShare(messenger: messenger) + } + + // MARK: - Share Extension inbox + + private static let shareEvents = SinkStreamHandler() + + /// `share_intent`: files the Share Extension left in the App Group inbox. + /// Cold start asks once (`getInitialShare`); a share that arrives while the + /// app is running is pushed when it next becomes active (the extension + /// can't signal the app any other way). + private static func registerShare(messenger: FlutterBinaryMessenger) { + FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) } + result(consumeShared().map(encode)) + } + FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger) + .setStreamHandler(shareEvents) + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { _ in + guard let sink = shareEvents.sink else { return } + let items = consumeShared() + if !items.isEmpty { sink(items.map(encode)) } + } + } + + private static func consumeShared() -> [SharedItem] { + guard let root = AppGroup.containerURL else { return [] } + return SharedInbox.consume(in: root) + } + + private static func encode(_ item: SharedItem) -> [String: Any] { + [ + "uri": URL(fileURLWithPath: item.path).absoluteString, + "name": item.name, + "mimeType": item.mimeType ?? NSNull(), + "size": item.size, + ] } // MARK: - Argument helpers diff --git a/ios/Runner/Native/TransferManager.swift b/ios/Runner/Native/TransferManager.swift index d682b0b..85c4a1d 100644 --- a/ios/Runner/Native/TransferManager.swift +++ b/ios/Runner/Native/TransferManager.swift @@ -100,6 +100,11 @@ final class TransferManager: NSObject { let staged = stageDir.appendingPathComponent("\(index)-\(file.name)") do { try FileManager.default.copyItem(at: source, to: staged) + // A file the Share Extension left in the App Group is ours to clean + // up once it's staged; a picker's temp file isn't. + if source.path.contains("/\(SharedInbox.folderName)/") { + try? FileManager.default.removeItem(at: source) + } } catch { throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)") } diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..cd9db7f --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 602d74c..fca6dfd 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -49,4 +49,52 @@ class RunnerTests: XCTestCase { FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil) XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)") } + + // MARK: - SharedInbox (Share Extension hand-off) + + private func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root + } + + private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem { + let batch = try SharedInbox.makeBatchDirectory(in: root) + let file = batch.appendingPathComponent(name) + try contents.write(to: file, atomically: true, encoding: .utf8) + return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count)) + } + + func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws { + let root = try makeRoot() + let a = try makeSharedFile(in: root, name: "a.txt") + let b = try makeSharedFile(in: root, name: "b.txt") + try SharedInbox.append([a], in: root) + try SharedInbox.append([b], in: root) + + XCTAssertEqual(SharedInbox.consume(in: root), [a, b]) + XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once") + } + + func testInboxConsumeDropsFilesThatNoLongerExist() throws { + let root = try makeRoot() + let kept = try makeSharedFile(in: root, name: "kept.txt") + let gone = try makeSharedFile(in: root, name: "gone.txt") + try SharedInbox.append([kept, gone], in: root) + try FileManager.default.removeItem(atPath: gone.path) + + XCTAssertEqual(SharedInbox.consume(in: root), [kept]) + } + + func testInboxConsumeWithNothingPendingIsEmpty() throws { + XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), []) + } + + func testInboxAppendOfNothingCreatesNoManifest() throws { + let root = try makeRoot() + try SharedInbox.append([], in: root) + XCTAssertEqual(SharedInbox.consume(in: root), []) + } } diff --git a/ios/ShareExtension/Info.plist b/ios/ShareExtension/Info.plist new file mode 100644 index 0000000..4c57b84 --- /dev/null +++ b/ios/ShareExtension/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Noo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsFileWithMaxCount + 50 + NSExtensionActivationSupportsImageWithMaxCount + 50 + NSExtensionActivationSupportsMovieWithMaxCount + 50 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/ShareExtension/ShareExtension.entitlements b/ios/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000..cd9db7f --- /dev/null +++ b/ios/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.ayushya.noo + + + diff --git a/ios/ShareExtension/ShareViewController.swift b/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000..d482f04 --- /dev/null +++ b/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,122 @@ +import UIKit +import UniformTypeIdentifiers +import UserNotifications + +/// "Share to Noo": copies whatever was shared into the App Group container +/// (`SharedInbox`) and finishes. The extension can't pick a destination or +/// upload - it has no session and a tight memory/time budget - so the app +/// does that when it next opens, through its existing destination picker. A +/// notification tells the user to open it, because an extension can't launch +/// its host app. +final class ShareViewController: UIViewController { + private let label = UILabel() + private let spinner = UIActivityIndicatorView(style: .large) + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + label.text = "Saving to Noo…" + label.font = .preferredFont(forTextStyle: .headline) + label.textAlignment = .center + label.numberOfLines = 0 + spinner.startAnimating() + + let stack = UIStackView(arrangedSubviews: [spinner, label]) + stack.axis = .vertical + stack.spacing = 16 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24), + ]) + + Task { await importSharedItems() } + } + + private func importSharedItems() async { + guard let root = AppGroup.containerURL else { + return finish(message: "Noo isn't set up to receive files yet.", saved: 0) + } + let providers = (extensionContext?.inputItems as? [NSExtensionItem])? + .flatMap { $0.attachments ?? [] } ?? [] + + var saved: [SharedItem] = [] + do { + let batch = try SharedInbox.makeBatchDirectory(in: root) + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) { + if let item = try? await copy(provider, into: batch) { saved.append(item) } + } + try SharedInbox.append(saved, in: root) + } catch { + return finish(message: "Couldn't save to Noo: \(error.localizedDescription)", saved: 0) + } + + if saved.isEmpty { + finish(message: "Noo can only receive files and photos.", saved: 0) + } else { + notify(count: saved.count) + finish(message: saved.count == 1 ? "Saved 1 file to Noo" : "Saved \(saved.count) files to Noo", saved: saved.count) + } + } + + /// `loadFileRepresentation`'s URL only lives for the duration of its + /// callback, so the copy happens inside it. + private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in + guard let url else { + return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + do { + let destination = Self.uniqueURL(in: directory, name: url.lastPathComponent) + try FileManager.default.copyItem(at: url, to: destination) + let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0 + let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType + continuation.resume( + returning: SharedItem( + path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func uniqueURL(in directory: URL, name: String) -> URL { + var candidate = directory.appendingPathComponent(name) + var index = 1 + while FileManager.default.fileExists(atPath: candidate.path) { + let ext = (name as NSString).pathExtension + let stem = (name as NSString).deletingPathExtension + candidate = directory.appendingPathComponent(ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)") + index += 1 + } + return candidate + } + + private func notify(count: Int) { + let content = UNMutableNotificationContent() + content.title = "Noo" + content.body = + count == 1 + ? "Open Noo to choose where to upload 1 file." + : "Open Noo to choose where to upload \(count) files." + content.sound = .default + UNUserNotificationCenter.current().add( + UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)) + } + + private func finish(message: String, saved: Int) { + DispatchQueue.main.async { + self.spinner.stopAnimating() + self.label.text = message + DispatchQueue.main.asyncAfter(deadline: .now() + (saved > 0 ? 1.2 : 2.5)) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } + } +} diff --git a/ios/Shared/SharedInbox.swift b/ios/Shared/SharedInbox.swift new file mode 100644 index 0000000..0082cba --- /dev/null +++ b/ios/Shared/SharedInbox.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The App Group both the app and its Share Extension belong to - the one +/// place they can exchange files. +enum AppGroup { + static let identifier = "group.dev.ayushya.noo" + + static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) + } +} + +/// One file the Share Extension copied into the group container. +struct SharedItem: Codable, Equatable { + let path: String + let name: String + let mimeType: String? + let size: Int64 +} + +/// Files the Share Extension received, waiting for the app to ask the user +/// where to upload them. The extension appends; the app consumes (reads and +/// clears) when it launches or becomes active. Compiled into both targets. +/// +/// Layout under `/SharedInbox/`: `pending.json` (the manifest) and one +/// `/` folder per share holding the copied files. +enum SharedInbox { + static let folderName = "SharedInbox" + private static let manifestName = "pending.json" + + static func directory(in root: URL) -> URL { + root.appendingPathComponent(folderName, isDirectory: true) + } + + /// A fresh folder for one share's files. + static func makeBatchDirectory(in root: URL) throws -> URL { + let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + static func append(_ items: [SharedItem], in root: URL) throws { + guard !items.isEmpty else { return } + try withManifestLock(in: root) { + let existing = (try? readManifest(in: root)) ?? [] + try writeManifest(existing + items, in: root) + } + } + + /// Everything pending, dropping entries whose file has gone missing, and + /// clears the manifest so each share is delivered once. + static func consume(in root: URL) -> [SharedItem] { + var items: [SharedItem] = [] + try? withManifestLock(in: root) { + items = ((try? readManifest(in: root)) ?? []).filter { + FileManager.default.fileExists(atPath: $0.path) + } + try? FileManager.default.removeItem(at: manifestURL(in: root)) + } + return items + } + + private static func manifestURL(in root: URL) -> URL { + directory(in: root).appendingPathComponent(manifestName) + } + + private static func readManifest(in root: URL) throws -> [SharedItem] { + let data = try Data(contentsOf: manifestURL(in: root)) + return try JSONDecoder().decode([SharedItem].self, from: data) + } + + private static func writeManifest(_ items: [SharedItem], in root: URL) throws { + try FileManager.default.createDirectory( + at: directory(in: root), withIntermediateDirectories: true) + try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic) + } + + /// The extension and the app are separate processes that can both touch the + /// manifest, so read-modify-write goes through a file coordinator. + private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws { + try FileManager.default.createDirectory( + at: directory(in: root), withIntermediateDirectories: true) + var coordinationError: NSError? + var thrown: Error? + NSFileCoordinator().coordinate( + writingItemAt: directory(in: root), options: [], error: &coordinationError + ) { _ in + do { try body() } catch { thrown = error } + } + if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error } + } +}