# Native Share Target Implementation Guide **Date:** 2025-01-27 **Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps ## Current State The app currently supports **PWA/web share target** functionality: - Service worker intercepts POST to `/share-target` - Images stored in temp database as base64 - `SharedPhotoView.vue` processes and displays shared images **This does NOT work for native iOS/Android builds** because: - Service workers don't run in native app contexts - Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android) ## Required Changes ### 1. iOS Implementation #### 1.1 Create Share Extension Target 1. Open `ios/App/App.xcodeproj` in Xcode 2. File → New → Target 3. Select "Share Extension" template 4. Name it "TimeSafariShareExtension" 5. Bundle Identifier: `app.timesafari.shareextension` 6. Language: Swift #### 1.2 Configure Share Extension Info.plist Add to `ios/App/TimeSafariShareExtension/Info.plist`: ```xml NSExtension NSExtensionPointIdentifier com.apple.share-services NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).ShareViewController NSExtensionActivationRule NSExtensionActivationSupportsImageWithMaxCount 1 NSExtensionActivationSupportsFileWithMaxCount 1 ``` #### 1.3 Implement ShareViewController Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`: ```swift import UIKit import Social import MobileCoreServices import Capacitor class ShareViewController: SLComposeServiceViewController { override func viewDidLoad() { super.viewDidLoad() self.title = "Share to TimeSafari" } override func isContentValid() -> Bool { return true } override func didSelectPost() { guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, let itemProvider = extensionItem.attachments?.first else { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) return } // Handle image sharing if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in guard let self = self else { return } if let url = item as? URL { // Handle file URL self.handleSharedImage(url: url) } else if let image = item as? UIImage { // Handle UIImage directly self.handleSharedImage(image: image) } else if let data = item as? Data { // Handle image data self.handleSharedImage(data: data) } } } } private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) { var imageData: Data? var fileName: String? if let url = url { imageData = try? Data(contentsOf: url) fileName = url.lastPathComponent } else if let image = image { imageData = image.jpegData(compressionQuality: 0.8) fileName = "shared-image.jpg" } else if let data = data { imageData = data fileName = "shared-image.jpg" } guard let imageData = imageData else { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) return } // Convert to base64 let base64String = imageData.base64EncodedString() // Store in shared UserDefaults (accessible by main app) let userDefaults = UserDefaults(suiteName: "group.app.timesafari") userDefaults?.set(base64String, forKey: "sharedPhotoBase64") userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName") userDefaults?.synchronize() // Open main app with deep link let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")! var responder = self as UIResponder? while responder != nil { if let application = responder as? UIApplication { application.open(url, options: [:], completionHandler: nil) break } responder = responder?.next } // Close share extension self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) } override func configurationItems() -> [Any]! { return [] } } ``` #### 1.4 Configure App Groups 1. In Xcode, select main app target → Signing & Capabilities 2. Add "App Groups" capability 3. Create group: `group.app.timesafari` 4. Repeat for Share Extension target with same group name #### 1.5 Update Main App to Read from App Group The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript. ### 2. Android Implementation #### 2.1 Update AndroidManifest.xml Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`: ```xml ... existing intent filters ... ``` #### 2.2 Handle Intent in MainActivity Update `android/app/src/main/java/app/timesafari/MainActivity.java`: ```java package app.timesafari; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.util.Base64; import android.util.Log; import com.getcapacitor.BridgeActivity; import com.getcapacitor.Plugin; import java.io.InputStream; import java.io.ByteArrayOutputStream; public class MainActivity extends BridgeActivity { private static final String TAG = "MainActivity"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); handleShareIntent(getIntent()); } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); handleShareIntent(intent); } private void handleShareIntent(Intent intent) { if (intent == null) return; String action = intent.getAction(); String type = intent.getType(); if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) { Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM); if (imageUri != null) { handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT)); } } else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) { // Handle multiple images (optional - for now just take first) java.util.ArrayList imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); if (imageUris != null && !imageUris.isEmpty()) { handleSharedImage(imageUris.get(0), null); } } } private void handleSharedImage(Uri imageUri, String fileName) { try { // Read image data InputStream inputStream = getContentResolver().openInputStream(imageUri); if (inputStream == null) { Log.e(TAG, "Failed to open input stream for shared image"); return; } ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[8192]; int nRead; while ((nRead = inputStream.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } buffer.flush(); byte[] imageBytes = buffer.toByteArray(); // Convert to base64 String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP); // Extract filename from URI or use default String actualFileName = fileName; if (actualFileName == null || actualFileName.isEmpty()) { String path = imageUri.getPath(); if (path != null) { int lastSlash = path.lastIndexOf('/'); if (lastSlash >= 0 && lastSlash < path.length() - 1) { actualFileName = path.substring(lastSlash + 1); } } if (actualFileName == null || actualFileName.isEmpty()) { actualFileName = "shared-image.jpg"; } } // Store in SharedPreferences (accessible by JavaScript via Capacitor) android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE); android.content.SharedPreferences.Editor editor = prefs.edit(); editor.putString("sharedPhotoBase64", base64String); editor.putString("sharedPhotoFileName", actualFileName); editor.apply(); // Trigger JavaScript event or navigate to shared-photo route // This will be handled by JavaScript checking for shared data on app launch Log.d(TAG, "Shared image stored, filename: " + actualFileName); } catch (Exception e) { Log.e(TAG, "Error handling shared image", e); } } } ``` #### 2.3 Add Required Permissions Ensure `AndroidManifest.xml` has: ```xml ``` ### 3. JavaScript Layer Updates #### 3.1 Create Native Share Handler Create `src/services/nativeShareHandler.ts`: ```typescript /** * Native Share Handler * Handles shared images from native iOS and Android platforms */ import { Capacitor } from "@capacitor/core"; import { App } from "@capacitor/app"; import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { logger } from "../utils/logger"; import { SHARED_PHOTO_BASE64_KEY } from "../libs/util"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; /** * Check for shared images from native platforms and store in temp database */ export async function checkForNativeSharedImage( platformService: InstanceType ): Promise { if (!Capacitor.isNativePlatform()) { return false; } try { if (Capacitor.getPlatform() === "ios") { return await checkIOSSharedImage(platformService); } else if (Capacitor.getPlatform() === "android") { return await checkAndroidSharedImage(platformService); } } catch (error) { logger.error("Error checking for native shared image:", error); } return false; } /** * Check for shared image on iOS (from App Group UserDefaults) */ async function checkIOSSharedImage( platformService: InstanceType ): Promise { // iOS uses App Groups to share data between extension and main app // We need to use a Capacitor plugin or native code to read from App Group // For now, this is a placeholder - requires native plugin implementation // Option 1: Use Capacitor plugin to read from App Group // Option 2: Use native code bridge logger.debug("Checking for iOS shared image (not yet implemented)"); return false; } /** * Check for shared image on Android (from SharedPreferences) */ async function checkAndroidSharedImage( platformService: InstanceType ): Promise { // Android stores in SharedPreferences // We need a Capacitor plugin to read from SharedPreferences // For now, this is a placeholder - requires native plugin implementation logger.debug("Checking for Android shared image (not yet implemented)"); return false; } /** * Store shared image in temp database */ async function storeSharedImage( base64Data: string, fileName: string, platformService: InstanceType ): Promise { try { const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY); if (existing) { await platformService.$updateEntity( "temp", { blobB64: base64Data }, "id = ?", [SHARED_PHOTO_BASE64_KEY] ); } else { await platformService.$insertEntity( "temp", { id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data }, ["id", "blobB64"] ); } logger.debug("Stored shared image in temp database"); } catch (error) { logger.error("Error storing shared image:", error); throw error; } } ``` #### 3.2 Update main.capacitor.ts Add check for shared images on app launch: ```typescript // In main.capacitor.ts, after app mount: import { checkForNativeSharedImage } from "./services/nativeShareHandler"; // Check for shared images when app becomes active App.addListener("appStateChange", async (state) => { if (state.isActive) { // Check for native shared images const hasSharedImage = await checkForNativeSharedImage(/* platformService */); if (hasSharedImage) { // Navigate to shared-photo view await router.push({ name: "shared-photo", query: { source: "native" } }); } } }); // Also check on initial launch App.getLaunchUrl().then((result) => { if (result?.url) { // Handle deep link } else { // Check for shared image checkForNativeSharedImage(/* platformService */).then((hasShared) => { if (hasShared) { router.push({ name: "shared-photo", query: { source: "native" } }); } }); } }); ``` #### 3.3 Update SharedPhotoView.vue The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources. ### 4. Alternative Approach: Capacitor Plugin Instead of implementing native code directly, consider creating a Capacitor plugin: 1. **Create plugin**: `@capacitor-community/share-target` or custom plugin 2. **Plugin methods**: - `checkForSharedImage()`: Returns shared image data if available - `clearSharedImage()`: Clears shared image data after processing This would be cleaner and more maintainable. ### 5. Testing Checklist - [ ] Test sharing image from Photos app on iOS - [ ] Test sharing image from Gallery app on Android - [ ] Test sharing from other apps (Safari, Chrome, etc.) - [ ] Verify image appears in SharedPhotoView - [ ] Test "Record Gift" flow with shared image - [ ] Test "Save as Profile" flow with shared image - [ ] Test cancel flow - [ ] Verify temp storage cleanup - [ ] Test app launch with shared image pending - [ ] Test app already running when image is shared ### 6. Implementation Priority **Phase 1: Android (Simpler)** 1. Update AndroidManifest.xml 2. Implement MainActivity intent handling 3. Create JavaScript handler 4. Test end-to-end **Phase 2: iOS (More Complex)** 1. Create Share Extension target 2. Implement ShareViewController 3. Configure App Groups 4. Create JavaScript handler 5. Test end-to-end ### 7. Notes - **App Groups (iOS)**: Required for sharing data between Share Extension and main app - **SharedPreferences (Android)**: Standard way to share data between app components - **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility - **File Size Limits**: Consider large image handling and memory management - **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE` ### 8. References - [iOS Share Extensions](https://developer.apple.com/documentation/social) - [Android Share Targets](https://developer.android.com/training/sharing/receive) - [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app) - [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)