diff --git a/doc/ios-share-extension-git-commit-guide.md b/doc/ios-share-extension-git-commit-guide.md new file mode 100644 index 0000000000..a0652d457f --- /dev/null +++ b/doc/ios-share-extension-git-commit-guide.md @@ -0,0 +1,139 @@ +# iOS Share Extension - Git Commit Guide + +**Date:** 2025-01-27 +**Purpose:** Clarify which Xcode manual changes should be committed to the repository + +## Quick Answer + +**YES, most manual Xcode changes SHOULD be committed.** The only exceptions are user-specific settings that are already gitignored. + +## What Gets Modified (and Should Be Committed) + +When you create the Share Extension target and configure App Groups in Xcode, the following files are modified: + +### 1. `ios/App/App.xcodeproj/project.pbxproj` ✅ **COMMIT THIS** + +This is the main Xcode project file that tracks: +- **New targets** (Share Extension target) +- **File references** (which files belong to which targets) +- **Build settings** (compiler flags, deployment targets, etc.) +- **Build phases** (compile sources, link frameworks, etc.) +- **Capabilities** (App Groups configuration) +- **Target dependencies** + +**This file IS tracked in git** (not in `.gitignore`), so changes should be committed. + +### 2. Entitlements Files ✅ **COMMIT THESE** + +When you enable App Groups capability, Xcode creates/modifies: +- `ios/App/App/App.entitlements` (for main app) +- `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` (for extension) + +These files contain the App Group identifiers and should be committed. + +### 3. Share Extension Source Files ✅ **ALREADY COMMITTED** + +The following files are already in the repo: +- `ios/App/TimeSafariShareExtension/ShareViewController.swift` +- `ios/App/TimeSafariShareExtension/Info.plist` +- `ios/App/App/ShareImageBridge.swift` + +These should already be committed (they were created as part of the implementation). + +## What Should NOT Be Committed + +### 1. User-Specific Settings ❌ **ALREADY GITIGNORED** + +These are in `ios/.gitignore`: +- `xcuserdata/` - User-specific scheme selections, breakpoints, etc. +- `*.xcuserstate` - User's current Xcode state + +### 2. Signing Identities ❌ **USER-SPECIFIC** + +While the **App Groups capability** should be committed (it's in `project.pbxproj` and entitlements), your **personal signing identity/team** is user-specific and Xcode handles this automatically per developer. + +## What Happens When You Commit + +When you commit the changes: + +1. **Other developers** who pull the changes will: + - ✅ Get the new Share Extension target automatically + - ✅ Get the App Groups capability configuration + - ✅ Get file references and build settings + - ✅ See the Share Extension in their Xcode project + +2. **They will still need to:** + - Configure their own signing team/identity (Xcode prompts for this) + - Build the project (which may trigger CocoaPods updates) + - But they **won't** need to manually create the target or configure App Groups + +## Step-by-Step: What to Commit + +After completing the Xcode setup steps: + +```bash +# Check what changed +git status + +# You should see: +# - ios/App/App.xcodeproj/project.pbxproj (modified) +# - ios/App/App/App.entitlements (new or modified) +# - ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements (new) +# - Possibly other project-related files + +# Review the changes +git diff ios/App/App.xcodeproj/project.pbxproj + +# Commit the changes +git add ios/App/App.xcodeproj/project.pbxproj +git add ios/App/App/App.entitlements +git add ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements +git commit -m "Add iOS Share Extension target and App Groups configuration" +``` + +## Important Notes + +### Merge Conflicts in project.pbxproj + +The `project.pbxproj` file can have merge conflicts because: +- It's auto-generated by Xcode +- Multiple developers might modify it +- It uses UUIDs that can conflict + +**If you get merge conflicts:** +1. Open the project in Xcode +2. Xcode will often auto-resolve conflicts +3. Or manually resolve by keeping both sets of changes +4. Test that the project builds + +### Team/Developer IDs + +The `DEVELOPMENT_TEAM` setting in `project.pbxproj` might be user-specific: +- Some teams commit this (if everyone uses the same team) +- Some teams use `.xcconfig` files to override per developer +- Check with your team's practices + +If you see `DEVELOPMENT_TEAM = GM3FS5JQPH;` in the project file, this is already committed, so your team likely commits team IDs. + +## Verification + +After committing, verify that: +1. The Share Extension target appears in Xcode for other developers +2. App Groups capability is configured +3. The project builds successfully +4. No user-specific files were accidentally committed + +## Summary + +| Change Type | Commit? | Reason | +|------------|---------|--------| +| New target creation | ✅ Yes | Modifies `project.pbxproj` | +| App Groups capability | ✅ Yes | Creates/modifies entitlements files | +| File target membership | ✅ Yes | Modifies `project.pbxproj` | +| Build settings | ✅ Yes | Modifies `project.pbxproj` | +| Source files (Swift, plist) | ✅ Yes | Already in repo | +| User scheme selections | ❌ No | In `xcuserdata/` (gitignored) | +| Personal signing identity | ⚠️ Maybe | Depends on team practice | + +**Bottom line:** Commit all the Xcode project configuration changes. Other developers will get the Share Extension target automatically when they pull, and they'll only need to configure their personal signing settings. + diff --git a/doc/ios-share-extension-setup.md b/doc/ios-share-extension-setup.md new file mode 100644 index 0000000000..83f49007a3 --- /dev/null +++ b/doc/ios-share-extension-setup.md @@ -0,0 +1,140 @@ +# iOS Share Extension Setup Instructions + +**Date:** 2025-01-27 +**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode + +## Prerequisites + +- Xcode installed +- iOS project already set up with Capacitor +- Access to Apple Developer account (for App Groups) + +## Step 1: Create Share Extension Target + +1. Open `ios/App/App.xcodeproj` in Xcode +2. In the Project Navigator, select the **App** project (top-level item) +3. Click the **+** button at the bottom of the Targets list +4. Select **iOS** → **Share Extension** +5. Click **Next** +6. Configure: + - **Product Name:** `TimeSafariShareExtension` + - **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix) + - **Language:** Swift +7. Click **Finish** + +## Step 2: Configure Share Extension Files + +The following files have been created in `ios/App/TimeSafariShareExtension/`: + +- `ShareViewController.swift` - Main extension logic +- `Info.plist` - Extension configuration + +**Verify these files exist and are added to the Share Extension target.** + +## Step 3: Configure App Groups + +App Groups allow the Share Extension and main app to share data. + +### For Main App Target: + +1. Select the **App** target in Xcode +2. Go to **Signing & Capabilities** tab +3. Click **+ Capability** +4. Select **App Groups** +5. Click **+** to add a new group +6. Enter: `group.app.timesafari` +7. Ensure it's checked/enabled + +### For Share Extension Target: + +1. Select the **TimeSafariShareExtension** target +2. Go to **Signing & Capabilities** tab +3. Click **+ Capability** +4. Select **App Groups** +5. Click **+** to add a new group +6. Enter: `group.app.timesafari` (same as main app) +7. Ensure it's checked/enabled + +**Important:** Both targets must use the **exact same** App Group identifier. + +## Step 4: Configure Share Extension Info.plist + +The `Info.plist` file should already be configured, but verify: + +1. Select `TimeSafariShareExtension/Info.plist` in Xcode +2. Ensure it contains: + - `NSExtensionPointIdentifier` = `com.apple.share-services` + - `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController` + - `NSExtensionActivationSupportsImageWithMaxCount` = `1` + +## Step 5: Add ShareImageBridge to Main App + +1. The file `ios/App/App/ShareImageBridge.swift` has been created +2. Ensure it's added to the **App** target (not the Share Extension target) +3. In Xcode, select the file and check the **Target Membership** in the File Inspector + +## Step 6: Build and Test + +1. Select the **App** scheme (not the Share Extension scheme) +2. Build and run on a device or simulator +3. Open Photos app +4. Select an image +5. Tap **Share** button +6. Look for **TimeSafari Share** in the share sheet +7. Select it +8. The app should open and navigate to the shared photo view + +## Step 7: Troubleshooting + +### Share Extension doesn't appear in share sheet + +- Verify the Share Extension target builds successfully +- Check that `Info.plist` is correctly configured +- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension` +- Clean build folder (Product → Clean Build Folder) + +### App Group access fails + +- Verify both targets have the same App Group identifier +- Check that App Groups capability is enabled for both targets +- Ensure you're signed in with a valid Apple Developer account +- For development, you may need to enable App Groups in your Apple Developer account + +### Shared image not appearing + +- Check Xcode console for errors +- Verify `ShareViewController.swift` is correctly implemented +- Ensure the deep link `timesafari://shared-photo` is being handled +- Check that the native bridge method is being called + +### Build errors + +- Ensure Swift version matches between targets +- Check that all required frameworks are linked +- Verify deployment targets match between main app and extension + +## Step 8: Native Bridge Implementation (TODO) + +Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of: + +1. **Option A:** Create a minimal Capacitor plugin +2. **Option B:** Use Capacitor's existing bridge mechanisms +3. **Option C:** Expose the method via a custom URL scheme parameter + +The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed. + +## Next Steps + +After the Share Extension is set up and working: + +1. Complete the native bridge implementation to read from App Group +2. Test end-to-end flow: Share image → Extension stores → App reads → Displays +3. Implement Android version +4. Add error handling and edge cases + +## References + +- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social) +- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups) +- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins) + diff --git a/doc/ios-share-implementation-status.md b/doc/ios-share-implementation-status.md new file mode 100644 index 0000000000..2b182b7bd9 --- /dev/null +++ b/doc/ios-share-implementation-status.md @@ -0,0 +1,93 @@ +# iOS Share Extension Implementation Status + +**Date:** 2025-01-27 +**Status:** In Progress - Native Code Complete, Bridge Pending + +## Completed + +✅ **Share Extension Files Created:** +- `ios/App/TimeSafariShareExtension/ShareViewController.swift` - Handles image sharing +- `ios/App/TimeSafariShareExtension/Info.plist` - Extension configuration + +✅ **Native Bridge Created:** +- `ios/App/App/ShareImageBridge.swift` - Native method to read from App Group + +✅ **JavaScript Integration Started:** +- `src/services/nativeShareHandler.ts` - Service to handle native shared images +- `src/main.capacitor.ts` - Updated to check for native shared images on deep link + +✅ **Documentation:** +- `doc/native-share-target-implementation.md` - Complete implementation guide +- `doc/ios-share-extension-setup.md` - Xcode setup instructions + +## Pending + +⚠️ **Xcode Configuration (Manual Steps Required):** +1. Create Share Extension target in Xcode +2. Configure App Groups for both main app and extension +3. Add ShareImageBridge.swift to App target +4. Build and test + +⚠️ **JavaScript-Native Bridge:** +The current implementation has a placeholder for calling the native `ShareImageBridge.getSharedImageData()` method from JavaScript. This needs to be completed using one of: + +**Option A: Minimal Capacitor Plugin** (Recommended for Option 1) +- Create a small plugin that exposes the method +- Clean and maintainable +- Follows Capacitor patterns + +**Option B: Direct Bridge Call** +- Use Capacitor's executePlugin or similar mechanism +- Requires understanding Capacitor's internal bridge +- Less maintainable + +**Option C: AppDelegate Integration** +- Have AppDelegate check on launch and expose via a different mechanism +- Workaround approach +- Less clean but functional + +## Next Steps + +1. **Complete Xcode Setup:** + - Follow `doc/ios-share-extension-setup.md` + - Create Share Extension target + - Configure App Groups + - Build and verify extension appears in share sheet + +2. **Implement JavaScript-Native Bridge:** + - Choose one of the options above + - Complete the `checkAndStoreNativeSharedImage()` function in `main.capacitor.ts` + - Test end-to-end flow + +3. **Testing:** + - Share image from Photos app + - Verify Share Extension appears + - Verify app opens and displays shared image + - Test "Record Gift" and "Save as Profile" flows + +## Current Flow + +1. ✅ User shares image → Share Extension receives +2. ✅ Share Extension converts to base64 +3. ✅ Share Extension stores in App Group UserDefaults +4. ✅ Share Extension opens app with `timesafari://shared-photo?fileName=...` +5. ⚠️ App receives deep link (handled) +6. ⚠️ App checks App Group UserDefaults (bridge needed) +7. ⚠️ App stores in temp database (pending bridge) +8. ✅ SharedPhotoView reads from temp database (already works) + +## Code Locations + +- **Share Extension:** `ios/App/TimeSafariShareExtension/` +- **Native Bridge:** `ios/App/App/ShareImageBridge.swift` +- **JavaScript Handler:** `src/services/nativeShareHandler.ts` +- **Deep Link Integration:** `src/main.capacitor.ts` +- **View Component:** `src/views/SharedPhotoView.vue` (already complete) + +## Notes + +- The Share Extension code is complete and ready to use +- The main missing piece is the JavaScript-to-native bridge +- Once the bridge is complete, the entire flow should work end-to-end +- The existing `SharedPhotoView.vue` doesn't need changes - it already handles images from temp storage + diff --git a/doc/native-share-target-implementation.md b/doc/native-share-target-implementation.md new file mode 100644 index 0000000000..7a30979c37 --- /dev/null +++ b/doc/native-share-target-implementation.md @@ -0,0 +1,507 @@ +# 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) + diff --git a/doc/xcode-26-cocoapods-workaround.md b/doc/xcode-26-cocoapods-workaround.md new file mode 100644 index 0000000000..2db40f4411 --- /dev/null +++ b/doc/xcode-26-cocoapods-workaround.md @@ -0,0 +1,76 @@ +# Xcode 26 / CocoaPods Compatibility Workaround + +**Date:** 2025-01-27 +**Issue:** CocoaPods `xcodeproj` gem (1.27.0) doesn't support Xcode 26's project format version 70 + +## The Problem + +Xcode 26.1.1 uses project format version 70, but the `xcodeproj` gem (1.27.0) only supports up to version 56. This causes CocoaPods to fail with: + +``` +ArgumentError - [Xcodeproj] Unable to find compatibility version string for object version `70`. +``` + +## Solutions + +### Option 1: Temporarily Downgrade Project Format (Recommended for Now) + +**Before running `pod install` or `npm run build:ios`:** + +1. Edit `ios/App/App.xcodeproj/project.pbxproj` +2. Change line 6 from: `objectVersion = 70;` to: `objectVersion = 56;` +3. Run your build/sync command +4. Change it back to: `objectVersion = 70;` (Xcode will likely change it back automatically) + +**Warning:** Xcode may automatically upgrade the format back to 70 when you open the project. This is okay - just repeat the process when needed. + +### Option 2: Wait for xcodeproj Update + +The `xcodeproj` gem maintainers will eventually release a version that supports format 70. You can: +- Check for updates: `bundle update xcodeproj` +- Monitor: https://github.com/CocoaPods/Xcodeproj/issues + +### Option 3: Use Xcode Directly (Bypass CocoaPods for Now) + +Since the Share Extension is already set up: +1. Open the project in Xcode +2. Build directly from Xcode (Product → Build) +3. Skip `npm run build:ios` for now +4. Test the Share Extension functionality + +### Option 4: Automated Workaround (Integrated into Build Script) ✅ + +The workaround is now **automatically integrated** into `scripts/build-ios.sh`. When you run: + +```bash +npm run build:ios +``` + +The build script will: +1. Automatically detect if the project format is version 70 +2. Temporarily downgrade to version 56 +3. Run `pod install` +4. Restore to version 70 +5. Continue with the build + +**No manual steps required!** The workaround is transparent and only applies when needed. + +To remove the workaround in the future: +1. Check if `xcodeproj` gem supports format 70: `bundle exec gem list xcodeproj` +2. Test if `pod install` works without the workaround +3. If it works, remove the `run_pod_install_with_workaround()` function from `scripts/build-ios.sh` +4. Replace it with a simple `pod install` call + +## Current Status + +- ✅ Share Extension target exists +- ✅ Share Extension files are in place +- ✅ Workaround integrated into build script +- ✅ `npm run build:ios` works automatically + +## Recommendation + +**Use `npm run build:ios`** - the workaround is handled automatically. No manual intervention needed. + +Once `xcodeproj` is updated to support format 70, the workaround can be removed from the build script. + diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index ce46d1f6c5..7214d4a44c 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -15,8 +15,35 @@ 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; }; + C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86585E72ED45A3D00824752 /* ShareImageBridge.swift */; }; + C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = C86585D42ED456DE00824752; + remoteInfo = TimeSafariShareExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; @@ -28,10 +55,28 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; + C86585E72ED45A3D00824752 /* ShareImageBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImageBridge.swift; sourceTree = ""; }; + C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImagePlugin.swift; sourceTree = ""; }; E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 504EC3011FED79650016851F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -41,6 +86,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C86585D22ED456DE00824752 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -56,6 +108,7 @@ isa = PBXGroup; children = ( 504EC3061FED79650016851F /* App */, + C86585D62ED456DE00824752 /* TimeSafariShareExtension */, 504EC3051FED79650016851F /* Products */, BA325FFCDCE8D334E5C7AEBE /* Pods */, 4B546315E668C7A13939F417 /* Frameworks */, @@ -66,6 +119,7 @@ isa = PBXGroup; children = ( 504EC3041FED79650016851F /* App.app */, + C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */, ); name = Products; sourceTree = ""; @@ -73,6 +127,9 @@ 504EC3061FED79650016851F /* App */ = { isa = PBXGroup; children = ( + C8D7E2CB2ED46A3B00DD738D /* ShareImagePlugin.swift */, + C86585E72ED45A3D00824752 /* ShareImageBridge.swift */, + C86585E52ED4577F00824752 /* App.entitlements */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, @@ -108,16 +165,40 @@ 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */, 3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */, 96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */, + C86585E02ED456DE00824752 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + C86585DE2ED456DE00824752 /* PBXTargetDependency */, ); name = App; productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; }; + C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */; + buildPhases = ( + C86585D12ED456DE00824752 /* Sources */, + C86585D22ED456DE00824752 /* Frameworks */, + C86585D32ED456DE00824752 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C86585D62ED456DE00824752 /* TimeSafariShareExtension */, + ); + name = TimeSafariShareExtension; + packageProductDependencies = ( + ); + productName = TimeSafariShareExtension; + productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -125,7 +206,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 920; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1630; TargetAttributes = { 504EC3031FED79650016851F = { @@ -133,6 +214,9 @@ LastSwiftMigration = 1100; ProvisioningStyle = Automatic; }; + C86585D42ED456DE00824752 = { + CreatedOnToolsVersion = 26.1.1; + }; }; }; buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; @@ -149,6 +233,7 @@ projectRoot = ""; targets = ( 504EC3031FED79650016851F /* App */, + C86585D42ED456DE00824752 /* TimeSafariShareExtension */, ); }; /* End PBXProject section */ @@ -167,6 +252,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C86585D32ED456DE00824752 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -253,12 +345,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C8D7E2CC2ED46A3B00DD738D /* ShareImagePlugin.swift in Sources */, + C86585E82ED45A3E00824752 /* ShareImageBridge.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + C86585D12ED456DE00824752 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + C86585DE2ED456DE00824752 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */; + targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 504EC30B1FED79650016851F /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -402,6 +511,7 @@ baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = GM3FS5JQPH; @@ -429,6 +539,7 @@ baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 48; DEVELOPMENT_TEAM = GM3FS5JQPH; @@ -450,6 +561,80 @@ }; name = Release; }; + C86585E12ED456DE00824752 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GM3FS5JQPH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimeSafariShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + 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.timesafari.TimeSafariShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C86585E22ED456DE00824752 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GM3FS5JQPH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TimeSafariShareExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TimeSafariShareExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.1; + 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.timesafari.TimeSafariShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -471,6 +656,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C86585E12ED456DE00824752 /* Debug */, + C86585E22ED456DE00824752 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements new file mode 100644 index 0000000000..68633259f5 --- /dev/null +++ b/ios/App/App/App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.timesafari + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 7a1b41b323..33937d3fad 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -39,6 +39,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Check if this is a shared-photo deep link and store image data in a way JS can access + if url.scheme == "timesafari" && url.host == "shared-photo" { + // Try to get shared image from App Group and store it in a temp file that JS can read + // This is a workaround until the plugin is properly registered + if let sharedData = getSharedImageData() { + // Write to a temp file in the app's Documents directory that JavaScript can read via Filesystem plugin + let fileManager = FileManager.default + if let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let tempFileURL = documentsDir.appendingPathComponent("timesafari_shared_photo.json") + + // Create JSON data + let jsonData: [String: String] = [ + "base64": sharedData["base64"] ?? "", + "fileName": sharedData["fileName"] ?? "" + ] + + if let json = try? JSONSerialization.data(withJSONObject: jsonData, options: []) { + do { + try json.write(to: tempFileURL) + } catch { + // Error writing temp file - will be handled by JS layer + } + } + } + } + } + // Called when the app was launched with a url. Feel free to add additional processing here, // but if you want the App API to support tracking app url opens, make sure to keep this call return ApplicationDelegateProxy.shared.application(app, open: url, options: options) @@ -50,5 +77,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // tracking app url opens, make sure to keep this call return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } + + /** + * Check for shared image from Share Extension + * Reads from App Group UserDefaults and returns shared image data if available + * + * @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image + */ + func getSharedImageData() -> [String: String]? { + let appGroupIdentifier = "group.app.timesafari" + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + return nil + } + + guard let base64 = userDefaults.string(forKey: "sharedPhotoBase64"), + let fileName = userDefaults.string(forKey: "sharedPhotoFileName") else { + return nil + } + + // Clear the shared data after reading + userDefaults.removeObject(forKey: "sharedPhotoBase64") + userDefaults.removeObject(forKey: "sharedPhotoFileName") + userDefaults.synchronize() + + return ["base64": base64, "fileName": fileName] + } } diff --git a/ios/App/App/ShareImageBridge.swift b/ios/App/App/ShareImageBridge.swift new file mode 100644 index 0000000000..88ad874295 --- /dev/null +++ b/ios/App/App/ShareImageBridge.swift @@ -0,0 +1,48 @@ +import Foundation + +/** + * Share Image Bridge + * + * Provides a bridge between JavaScript and native iOS code to access + * shared images stored in App Group UserDefaults by the Share Extension. + * + * This bridge allows the JavaScript layer to read shared image data + * that was stored by the Share Extension. + * + * Note: This class doesn't need Capacitor - it's a simple Swift utility + * that reads from App Group UserDefaults. The JavaScript bridge will be + * implemented separately. + */ +@objc(ShareImageBridge) +public class ShareImageBridge: NSObject { + + private static let appGroupIdentifier = "group.app.timesafari" + private static let sharedPhotoBase64Key = "sharedPhotoBase64" + private static let sharedPhotoFileNameKey = "sharedPhotoFileName" + + /** + * Get shared image data from App Group UserDefaults + * Called from JavaScript via Capacitor bridge + * + * @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image + */ + @objc public static func getSharedImageData() -> [String: String]? { + guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { + print("ShareImageBridge: Failed to access App Group UserDefaults") + return nil + } + + guard let base64 = userDefaults.string(forKey: sharedPhotoBase64Key), + let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) else { + return nil + } + + // Clear the shared data after reading + userDefaults.removeObject(forKey: sharedPhotoBase64Key) + userDefaults.removeObject(forKey: sharedPhotoFileNameKey) + userDefaults.synchronize() + + return ["base64": base64, "fileName": fileName] + } +} + diff --git a/ios/App/App/ShareImagePlugin.swift b/ios/App/App/ShareImagePlugin.swift new file mode 100644 index 0000000000..9073de7f6e --- /dev/null +++ b/ios/App/App/ShareImagePlugin.swift @@ -0,0 +1,28 @@ +import Foundation +import Capacitor + +/** + * Share Image Plugin + * + * Capacitor plugin that exposes ShareImageBridge functionality to JavaScript. + * Allows JavaScript to retrieve shared images from App Group UserDefaults. + */ +@objc(ShareImagePlugin) +public class ShareImagePlugin: CAPPlugin { + + @objc func getSharedImageData(_ call: CAPPluginCall) { + guard let sharedData = ShareImageBridge.getSharedImageData() else { + call.resolve(["success": false, "data": NSNull()]) + return + } + + call.resolve([ + "success": true, + "data": [ + "base64": sharedData["base64"] ?? "", + "fileName": sharedData["fileName"] ?? "" + ] + ]) + } +} + diff --git a/ios/App/TimeSafariShareExtension/Info.plist b/ios/App/TimeSafariShareExtension/Info.plist new file mode 100644 index 0000000000..9230ac2404 --- /dev/null +++ b/ios/App/TimeSafariShareExtension/Info.plist @@ -0,0 +1,21 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/ios/App/TimeSafariShareExtension/ShareViewController.swift b/ios/App/TimeSafariShareExtension/ShareViewController.swift new file mode 100644 index 0000000000..9090d128ea --- /dev/null +++ b/ios/App/TimeSafariShareExtension/ShareViewController.swift @@ -0,0 +1,170 @@ +// +// ShareViewController.swift +// TimeSafariShareExtension +// +// Created by Aardimus on 11/24/25. +// + +import UIKit +import Social +import UniformTypeIdentifiers + +class ShareViewController: SLComposeServiceViewController { + + private let appGroupIdentifier = "group.app.timesafari" + private let sharedPhotoBase64Key = "sharedPhotoBase64" + private let sharedPhotoFileNameKey = "sharedPhotoFileName" + + override func viewDidLoad() { + super.viewDidLoad() + + // Set placeholder text (required for SLComposeServiceViewController) + self.placeholder = "Share image to TimeSafari" + + // Validate content on load + self.validateContent() + } + + override func isContentValid() -> Bool { + // Validate that we have image attachments + guard let extensionContext = extensionContext else { + return false + } + + guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else { + return false + } + + for item in inputItems { + if let attachments = item.attachments { + for attachment in attachments { + if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + return true + } + } + } + } + + return false + } + + override func didSelectPost() { + // Extract and process the shared image + guard let extensionContext = extensionContext else { + return + } + + guard let inputItems = extensionContext.inputItems as? [NSExtensionItem] else { + extensionContext.completeRequest(returningItems: [], completionHandler: nil) + return + } + + // Process the first image found + processSharedImage(from: inputItems) { [weak self] success in + guard let self = self else { + extensionContext.completeRequest(returningItems: [], completionHandler: nil) + return + } + + if success { + // Open the main app via deep link + self.openMainApp() + } + + // Complete the extension context + extensionContext.completeRequest(returningItems: [], completionHandler: nil) + } + } + + private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) { + // Find the first image attachment + for item in items { + guard let attachments = item.attachments else { + continue + } + + for attachment in attachments { + if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in + guard let self = self else { + completion(false) + return + } + + if let error = error { + completion(false) + return + } + + // Handle different image data types + var imageData: Data? + var fileName: String = "shared-image.jpg" + + if let url = data as? URL { + // Image provided as file URL + imageData = try? Data(contentsOf: url) + fileName = url.lastPathComponent + } else if let image = data as? UIImage { + // Image provided as UIImage + imageData = image.jpegData(compressionQuality: 0.9) + fileName = "shared-image.jpg" + } else if let data = data as? Data { + // Image provided as raw Data + imageData = data + fileName = "shared-image.jpg" + } + + guard let finalImageData = imageData else { + completion(false) + return + } + + // Convert to base64 + let base64String = finalImageData.base64EncodedString() + + // Store in App Group UserDefaults + guard let userDefaults = UserDefaults(suiteName: self.appGroupIdentifier) else { + completion(false) + return + } + + userDefaults.set(base64String, forKey: self.sharedPhotoBase64Key) + userDefaults.set(fileName, forKey: self.sharedPhotoFileNameKey) + userDefaults.synchronize() + + completion(true) + } + return // Process only the first image + } + } + } + + // No image found + completion(false) + } + + private func openMainApp() { + // Open the main app via deep link + guard let url = URL(string: "timesafari://shared-photo") else { + return + } + + var responder: UIResponder? = self + while responder != nil { + if let application = responder as? UIApplication { + application.open(url, options: [:], completionHandler: nil) + return + } + responder = responder?.next + } + + // Fallback: use extension context + extensionContext?.open(url, completionHandler: nil) + } + + override func configurationItems() -> [Any]! { + // No additional configuration options needed + return [] + } + +} diff --git a/ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements b/ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements new file mode 100644 index 0000000000..68633259f5 --- /dev/null +++ b/ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.timesafari + + + diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index b327152c6c..fde5ee7d0c 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -404,8 +404,141 @@ elif [ "$BUILD_MODE" = "production" ]; then safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 fi -# Step 6: Sync with Capacitor -safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6 +# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround) +# =================================================================== +# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue +# =================================================================== +# Xcode 26 uses project format version 70, but CocoaPods' xcodeproj gem +# (1.27.0) only supports up to version 56. This causes pod install to fail. +# +# This workaround temporarily downgrades the project format to 56, runs +# pod install, then restores it to 70. Xcode will automatically upgrade +# it back to 70 when opened, which is fine. +# +# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install +# internally) need this workaround. See run_pod_install_with_workaround() +# and run_cap_sync_with_workaround() functions below. +# +# TO REMOVE THIS WORKAROUND IN THE FUTURE: +# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj +# 2. Test if pod install works without the workaround +# 3. If it works, remove both workaround functions below +# 4. Replace with: +# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6 +# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6 +# 5. Update this comment to indicate the workaround has been removed +# =================================================================== +run_pod_install_with_workaround() { + local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj" + + log_info "Installing CocoaPods dependencies (with Xcode 26 workaround)..." + + # Check if project file exists + if [ ! -f "$PROJECT_FILE" ]; then + log_error "Project file not found: $PROJECT_FILE" + return 1 + fi + + # Check current format version + local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "") + + if [ -z "$current_version" ]; then + log_error "Could not determine project format version" + return 1 + fi + + log_debug "Current project format version: $current_version" + + # Only apply workaround if format is 70 + if [ "$current_version" = "70" ]; then + log_debug "Applying Xcode 26 workaround: temporarily downgrading to format 56" + + # Downgrade to format 56 (supported by CocoaPods) + if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then + log_error "Failed to downgrade project format" + return 1 + fi + + # Run pod install + log_info "Running pod install..." + if ! (cd ios/App && bundle exec pod install && cd ../..); then + log_error "pod install failed" + # Try to restore format even on failure + sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true + return 1 + fi + + # Restore to format 70 + log_debug "Restoring project format to 70..." + if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then + log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)" + fi + + log_success "CocoaPods dependencies installed successfully" + else + # Format is not 70, run pod install normally + log_debug "Project format is $current_version, running pod install normally" + if ! (cd ios/App && bundle exec pod install && cd ../..); then + log_error "pod install failed" + return 1 + fi + log_success "CocoaPods dependencies installed successfully" + fi +} + +safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6 + +# Step 6.5: Sync with Capacitor (also needs workaround since it runs pod install internally) +# Capacitor sync internally runs pod install, so we need to apply the workaround here too +run_cap_sync_with_workaround() { + local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj" + + # Check current format version + local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "") + + if [ -z "$current_version" ]; then + log_error "Could not determine project format version for Capacitor sync" + return 1 + fi + + # Only apply workaround if format is 70 + if [ "$current_version" = "70" ]; then + log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56" + + # Downgrade to format 56 (supported by CocoaPods) + if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then + log_error "Failed to downgrade project format for Capacitor sync" + return 1 + fi + + # Run Capacitor sync (which will run pod install internally) + log_info "Running Capacitor sync..." + if ! npx cap sync ios; then + log_error "Capacitor sync failed" + # Try to restore format even on failure + sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true + return 1 + fi + + # Restore to format 70 + log_debug "Restoring project format to 70 after Capacitor sync..." + if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then + log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)" + fi + + log_success "Capacitor sync completed successfully" + else + # Format is not 70, run sync normally + log_debug "Project format is $current_version, running Capacitor sync normally" + if ! npx cap sync ios; then + log_error "Capacitor sync failed" + return 1 + fi + log_success "Capacitor sync completed successfully" + fi +} + +safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6 # Step 7: Generate assets safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 diff --git a/src/interfaces/deepLinks.ts b/src/interfaces/deepLinks.ts index 0fe5c68dd9..60c0826ce0 100644 --- a/src/interfaces/deepLinks.ts +++ b/src/interfaces/deepLinks.ts @@ -68,6 +68,7 @@ export const deepLinkPathSchemas = { "user-profile": z.object({ id: z.string(), }), + "shared-photo": z.object({}), }; export const deepLinkQuerySchemas = { diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index f091770b1c..3ef253a546 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -30,11 +30,15 @@ import { initializeApp } from "./main.common"; import { App as CapacitorApp } from "@capacitor/app"; +import { Capacitor } from "@capacitor/core"; +import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import router from "./router"; import { handleApiError } from "./services/api"; import { AxiosError } from "axios"; import { DeepLinkHandler } from "./services/deepLinks"; import { logger, safeStringify } from "./utils/logger"; +import { PlatformServiceFactory } from "./services/PlatformServiceFactory"; +import { SHARED_PHOTO_BASE64_KEY } from "./libs/util"; import "./utils/safeAreaInset"; logger.log("[Capacitor] 🚀 Starting initialization"); @@ -67,11 +71,220 @@ const deepLinkHandler = new DeepLinkHandler(router); * * @throws {Error} If URL format is invalid */ +/** + * Check for native shared image from iOS App Group UserDefaults + * and store in temp database before routing to shared-photo view + */ +async function checkAndStoreNativeSharedImage(): Promise<{ + success: boolean; + fileName?: string; +}> { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "ios") { + return { success: false }; + } + + try { + logger.debug("[Main] Checking for iOS shared image from App Group"); + + // Use Capacitor's native bridge to call the ShareImagePlugin + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const capacitor = (window as any).Capacitor; + + if (!capacitor || !capacitor.Plugins) { + logger.debug("[Main] Capacitor plugins not available"); + return { success: false }; + } + + // WORKAROUND: Since the plugin isn't being auto-discovered, use a temp file bridge + // AppDelegate writes the shared image data to a temp file, and we read it here + try { + const tempFilePath = "timesafari_shared_photo.json"; + const fileContent = await Filesystem.readFile({ + path: tempFilePath, + directory: Directory.Documents, + encoding: Encoding.UTF8, + }); + + if (fileContent.data) { + const sharedData = JSON.parse(fileContent.data as string); + const base64 = sharedData.base64; + const fileName = sharedData.fileName || "shared-image.jpg"; + + if (base64) { + // Store in temp database using dbExec directly + logger.info( + "[Main] Native shared image found (via temp file), storing in temp DB", + ); + const platformService = PlatformServiceFactory.getInstance(); + + // Convert raw base64 to data URL format that base64ToBlob expects + // base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..." + // Try to detect image type from base64 or default to jpeg + let mimeType = "image/jpeg"; // default + if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) { + // JPEG or PNG + mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png"; + } + const dataUrl = `data:${mimeType};base64,${base64}`; + + // Use INSERT OR REPLACE to handle existing records + await platformService.dbExec( + "INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)", + [SHARED_PHOTO_BASE64_KEY, dataUrl], + ); + + // Delete the temp file + try { + await Filesystem.deleteFile({ + path: tempFilePath, + directory: Directory.Documents, + }); + } catch (deleteError) { + logger.error("[Main] Failed to delete temp file:", deleteError); + } + + logger.info(`[Main] Stored shared image: ${fileName}`); + return { success: true, fileName }; + } + } + } catch (fileError: unknown) { + // File doesn't exist or can't be read - that's okay, try plugin method + logger.debug( + "[Main] Temp file not found or unreadable (this is normal if plugin works)", + ); + } + + // NOTE: Plugin registration issue - ShareImage plugin is not being auto-discovered + // This is a known issue that needs to be resolved. For now, we use the temp file workaround above. + + // Try multiple methods to call the plugin (fallback if temp file method fails) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plugins = (capacitor as any).Plugins; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shareImagePlugin = (plugins as any)?.ShareImage; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let result: any = null; + + if ( + shareImagePlugin && + typeof shareImagePlugin.getSharedImageData === "function" + ) { + logger.debug("[Main] Using direct plugin method"); + try { + result = await shareImagePlugin.getSharedImageData(); + } catch (pluginError) { + logger.error("[Main] Plugin call failed:", pluginError); + return { success: false }; + } + } else { + // Method 2: Use Capacitor's execute method + logger.debug( + "[Main] Plugin not found directly, trying Capacitor.execute", + ); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bridge = (capacitor as any).getBridge?.(); + if (bridge) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result = await bridge.execute({ + pluginId: "ShareImage", + methodName: "getSharedImageData", + options: {}, + }); + } else { + // Method 3: Try execute on Plugins object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((plugins as any)?.execute) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result = await (plugins as any).execute( + "ShareImage", + "getSharedImageData", + {}, + ); + } + } + } catch (executeError) { + logger.error("[Main] Execute method failed:", executeError); + return { success: false }; + } + } + + if (!result || !result.success || !result.data) { + logger.debug("[Main] No shared image data found in result"); + return { success: false }; + } + + const { base64, fileName } = result.data; + + if (!base64) { + logger.debug("[Main] Shared image data missing base64"); + return { success: false }; + } + + logger.info("[Main] Native shared image found, storing in temp DB"); + + // Store in temp database (similar to web flow) + const platformService = PlatformServiceFactory.getInstance(); + // $insertEntity is available via PlatformServiceMixin + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (platformService as any).$insertEntity( + "temp", + { id: SHARED_PHOTO_BASE64_KEY, blobB64: base64 }, + ["id", "blobB64"], + ); + + logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`); + return { success: true, fileName: fileName || "shared-image.jpg" }; + } catch (error) { + logger.error("[Main] Error checking for native shared image:", error); + return { success: false }; + } +} + const handleDeepLink = async (data: { url: string }) => { const { url } = data; logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`); try { + // Check if this is a shared-photo deep link from native share + const isSharedPhotoLink = url.includes("timesafari://shared-photo"); + + if ( + isSharedPhotoLink && + Capacitor.isNativePlatform() && + Capacitor.getPlatform() === "ios" + ) { + logger.debug( + "[Main] 📸 Shared photo deep link detected, checking for native shared image", + ); + + // Try to get shared image from App Group and store in temp database + try { + const imageResult = await checkAndStoreNativeSharedImage(); + + if (imageResult.success) { + logger.info("[Main] ✅ Native shared image stored in temp database"); + + // Add fileName to the URL as a query parameter if we have it + if (imageResult.fileName) { + const urlObj = new URL(url); + urlObj.searchParams.set("fileName", imageResult.fileName); + const modifiedUrl = urlObj.toString(); + data.url = modifiedUrl; + logger.debug(`[Main] Added fileName to URL: ${modifiedUrl}`); + } + } else { + logger.debug( + "[Main] ℹ️ No native shared image found (may be from web or already processed)", + ); + } + } catch (error) { + logger.error("[Main] Error processing native shared image:", error); + // Continue with normal deep link processing even if native check fails + } + } + // Wait for router to be ready logger.debug(`[Main] ⏳ Waiting for router to be ready...`); await router.isReady();