From d81215db730dbfa0450ee0148eaaea4b89d91147 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Mon, 18 May 2026 20:50:23 +0200 Subject: [PATCH] Add iOS save action extension --- docs/SETUP.md | 3 + ios/Runner.xcodeproj/project.pbxproj | 170 +++++++++++++++ ios/Save Action/Info.plist | 41 ++++ ios/Save Action/Save Action.entitlements | 10 + .../SaveActionViewController.swift | 194 ++++++++++++++++++ .../presentation/incoming_share_listener.dart | 7 + 6 files changed, 425 insertions(+) create mode 100644 ios/Save Action/Info.plist create mode 100644 ios/Save Action/Save Action.entitlements create mode 100644 ios/Save Action/SaveActionViewController.swift diff --git a/docs/SETUP.md b/docs/SETUP.md index c9087e8..c2e7001 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -148,6 +148,9 @@ flutter run --dart-define=ENABLE_WHATSAPP_SHARE_INTAKE=false Behavior notes: - iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`) +- iOS also ships a `Save to Relationship Saver` Action extension for the lower + share-sheet action row; use it when that row is easier to reach than the app + share row - shared text is parsed and ingested into: - person profile (auto-create when identity is confident) - source->profile link map for follow-up matching diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3361173..572f333 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ A1B2C3021111111111111111 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30D1111111111111111 /* ShareViewController.swift */; }; A1B2C3031111111111111111 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3181111111111111111 /* MainInterface.storyboard */; }; A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1B2C30C1111111111111111 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4062222222222222222 /* SaveActionViewController.swift */; }; + B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B2C3D4052222222222222222 /* Save Action.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,6 +39,13 @@ remoteGlobalIDString = A1B2C3131111111111111111; remoteInfo = "Share Extension"; }; + B2C3D4042222222222222222 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = B2C3D40B2222222222222222; + remoteInfo = "Save Action"; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -57,6 +66,7 @@ dstSubfolderSpec = 13; files = ( A1B2C3041111111111111111 /* Share Extension.appex in Embed App Extensions */, + B2C3D4022222222222222222 /* Save Action.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -91,6 +101,10 @@ A1B2C30E1111111111111111 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; A1B2C30F1111111111111111 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A1B2C3101111111111111111 /* Share Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Share Extension.entitlements"; sourceTree = ""; }; + B2C3D4052222222222222222 /* Save Action.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Save Action.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + B2C3D4062222222222222222 /* SaveActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveActionViewController.swift; sourceTree = ""; }; + B2C3D4072222222222222222 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B2C3D4082222222222222222 /* Save Action.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Save Action.entitlements"; sourceTree = ""; }; CD3F9117486EC23DF50E2D6A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E9B506D809ABF2A50BF6135B /* 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 = ""; }; F2C7EE505B336533B62B23EB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -112,6 +126,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B2C3D4092222222222222222 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; E2BAEB33085D4BFE88A55D45 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -170,6 +191,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, A1B2C3121111111111111111 /* Share Extension */, + B2C3D40A2222222222222222 /* Save Action */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 3D195739A0A78D037BF244F8 /* Pods */, @@ -182,6 +204,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, A1B2C30C1111111111111111 /* Share Extension.appex */, + B2C3D4052222222222222222 /* Save Action.appex */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -215,6 +238,16 @@ path = "Share Extension"; sourceTree = ""; }; + B2C3D40A2222222222222222 /* Save Action */ = { + isa = PBXGroup; + children = ( + B2C3D4082222222222222222 /* Save Action.entitlements */, + B2C3D4062222222222222222 /* SaveActionViewController.swift */, + B2C3D4072222222222222222 /* Info.plist */, + ); + path = "Save Action"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -255,6 +288,7 @@ ); dependencies = ( A1B2C3171111111111111111 /* PBXTargetDependency */, + B2C3D40E2222222222222222 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -278,6 +312,23 @@ productReference = A1B2C30C1111111111111111 /* Share Extension.appex */; productType = "com.apple.product-type.app-extension"; }; + B2C3D40B2222222222222222 /* Save Action */ = { + isa = PBXNativeTarget; + buildConfigurationList = B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */; + buildPhases = ( + B2C3D40D2222222222222222 /* Sources */, + B2C3D4092222222222222222 /* Frameworks */, + B2C3D40C2222222222222222 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Save Action"; + productName = "Save Action"; + productReference = B2C3D4052222222222222222 /* Save Action.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -299,6 +350,9 @@ A1B2C3131111111111111111 = { CreatedOnToolsVersion = 15.0.1; }; + B2C3D40B2222222222222222 = { + CreatedOnToolsVersion = 15.0.1; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -316,6 +370,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, A1B2C3131111111111111111 /* Share Extension */, + B2C3D40B2222222222222222 /* Save Action */, 331C8080294A63A400263BE5 /* RunnerTests */, ); }; @@ -348,6 +403,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B2C3D40C2222222222222222 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -472,6 +534,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B2C3D40D2222222222222222 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B2C3D4012222222222222222 /* SaveActionViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -485,6 +555,11 @@ target = A1B2C3131111111111111111 /* Share Extension */; targetProxy = A1B2C3051111111111111111 /* PBXContainerItemProxy */; }; + B2C3D40E2222222222222222 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B2C3D40B2222222222222222 /* Save Action */; + targetProxy = B2C3D4042222222222222222 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -520,6 +595,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver; + APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction"; APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; @@ -651,6 +727,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver; + APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction"; APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; @@ -713,6 +790,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APP_BUNDLE_IDENTIFIER = com.rijadzuzo.relationshipSaver; + APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).SaveAction"; APP_GROUP_ID = group.com.rijadzuzo.relationshipSaver.shared; APP_RUNNER_TESTS_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).RunnerTests"; APP_SHARE_EXTENSION_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).ShareExtension"; @@ -895,6 +973,88 @@ }; name = Profile; }; + B2C3D40F2222222222222222 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; + DEVELOPMENT_TEAM = SR3UKQVKP6; + INFOPLIST_FILE = "Save Action/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)"; + PRODUCT_MODULE_NAME = SaveAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B2C3D4102222222222222222 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; + DEVELOPMENT_TEAM = SR3UKQVKP6; + INFOPLIST_FILE = "Save Action/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)"; + PRODUCT_MODULE_NAME = SaveAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B2C3D4112222222222222222 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Save Action/Save Action.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + CUSTOM_GROUP_ID = "$(APP_GROUP_ID)"; + DEVELOPMENT_TEAM = SR3UKQVKP6; + INFOPLIST_FILE = "Save Action/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Save to Relationship Saver"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_ACTION_EXTENSION_BUNDLE_IDENTIFIER)"; + PRODUCT_MODULE_NAME = SaveAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -938,6 +1098,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B2C3D4122222222222222222 /* Build configuration list for PBXNativeTarget "Save Action" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B2C3D40F2222222222222222 /* Debug */, + B2C3D4102222222222222222 /* Release */, + B2C3D4112222222222222222 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ios/Save Action/Info.plist b/ios/Save Action/Info.plist new file mode 100644 index 0000000..eee7456 --- /dev/null +++ b/ios/Save Action/Info.plist @@ -0,0 +1,41 @@ + + + + + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleDisplayName + Save to Relationship Saver + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.ui-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SaveActionViewController + + + diff --git a/ios/Save Action/Save Action.entitlements b/ios/Save Action/Save Action.entitlements new file mode 100644 index 0000000..8b3085f --- /dev/null +++ b/ios/Save Action/Save Action.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(CUSTOM_GROUP_ID) + + + diff --git a/ios/Save Action/SaveActionViewController.swift b/ios/Save Action/SaveActionViewController.swift new file mode 100644 index 0000000..c74c6ec --- /dev/null +++ b/ios/Save Action/SaveActionViewController.swift @@ -0,0 +1,194 @@ +import MobileCoreServices +import UniformTypeIdentifiers +import UIKit + +private let schemePrefix = "ShareMedia" +private let userDefaultsKey = "ShareKey" +private let userDefaultsMessageKey = "ShareMessageKey" +private let appGroupIdKey = "AppGroupId" + +private struct SharedMediaFile: Codable { + let path: String + let mimeType: String? + let thumbnail: String? + let duration: Double? + let message: String? + let type: SharedMediaType +} + +private enum SharedMediaType: String, Codable { + case text + case url +} + +final class SaveActionViewController: UIViewController { + private var hostAppBundleIdentifier = "" + private var appGroupId = "" + private var sharedMedia: [SharedMediaFile] = [] + private let group = DispatchGroup() + private var didStart = false + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !didStart else { + return + } + didStart = true + loadIdentifiers() + loadSharedContent() + } + + private func loadIdentifiers() { + let actionExtensionIdentifier = Bundle.main.bundleIdentifier ?? "" + if let lastDotIndex = actionExtensionIdentifier.lastIndex(of: ".") { + hostAppBundleIdentifier = String(actionExtensionIdentifier[.. { } } + if (file.type == SharedMediaType.url) { + final String url = file.path.trim(); + if (url.isNotEmpty) { + return url; + } + } + final String? mimeType = file.mimeType?.toLowerCase(); if (mimeType != null && mimeType.startsWith('text/')) { final String text = file.path.trim();