forked from jsnbuchanan/crowd-funder-for-time-pwa
Improve iOS share extension implementation to skip interstitial UI and fix issues with subsequent image shares not updating the view. iOS Share Extension Improvements: - Replace SLComposeServiceViewController with custom UIViewController to eliminate interstitial "Post" button UI - Use minimal URL (timesafari://) instead of deep link for app launch - Implement app lifecycle detection via Capacitor appStateChange listener instead of relying solely on deep links Deep Link and Navigation Fixes: - Remove "shared-photo" from deep link schemas (no longer needed) - Add empty path URL handling for share extension launches - Implement processing lock to prevent duplicate image processing - Add retry mechanism (300ms delay) to handle race conditions with AppDelegate writing temp files - Use router.replace() when already on /shared-photo route to force refresh - Clear old images from temp DB before storing new ones - Delete temp file immediately after reading to prevent stale data SharedPhotoView Component: - Add route watcher (@Watch) to reload image when fileName query parameter changes - Extract image loading logic into reusable loadSharedImage() method - Improve error handling to clear image state on failures This fixes the issue where sharing a second image while already on SharedPhotoView would display the previous image instead of the new one.
284 lines
8.7 KiB
Markdown
284 lines
8.7 KiB
Markdown
# iOS Share Extension Improvements
|
|
|
|
**Date:** 2025-11-24
|
|
**Purpose:** Explore alternatives to improve user experience by eliminating interstitial UI and simplifying app launch mechanism
|
|
|
|
## Current Implementation Issues
|
|
|
|
1. **Interstitial UI**: Users see `SLComposeServiceViewController` with a "Post" button before the app opens
|
|
2. **Deep Link Dependency**: App relies on deep link (`timesafari://shared-photo`) to detect shared images, even though data is already in App Group
|
|
|
|
## Improvement 1: Skip Interstitial UI
|
|
|
|
### Current Approach
|
|
- Uses `SLComposeServiceViewController` which shows a UI with "Post" button
|
|
- User must tap "Post" to proceed
|
|
|
|
### Alternative: Custom UIViewController (Headless Processing)
|
|
|
|
Replace `SLComposeServiceViewController` with a custom `UIViewController` that:
|
|
- Processes the image immediately in `viewDidLoad`
|
|
- Shows no UI (or minimal loading indicator)
|
|
- Opens the app automatically
|
|
|
|
**Implementation:**
|
|
|
|
```swift
|
|
import UIKit
|
|
import UniformTypeIdentifiers
|
|
|
|
class ShareViewController: UIViewController {
|
|
|
|
private let appGroupIdentifier = "group.app.timesafari"
|
|
private let sharedPhotoBase64Key = "sharedPhotoBase64"
|
|
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Process image immediately without showing UI
|
|
processAndOpenApp()
|
|
}
|
|
|
|
private func processAndOpenApp() {
|
|
guard let extensionContext = extensionContext,
|
|
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
|
|
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
return
|
|
}
|
|
|
|
processSharedImage(from: inputItems) { [weak self] success in
|
|
guard let self = self else {
|
|
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
return
|
|
}
|
|
|
|
if success {
|
|
self.openMainApp()
|
|
}
|
|
|
|
// Complete immediately - no UI shown
|
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|
|
|
|
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
|
// ... (same implementation as current)
|
|
}
|
|
|
|
private func openMainApp() {
|
|
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
|
|
}
|
|
|
|
extensionContext?.open(url, completionHandler: nil)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Info.plist Changes:**
|
|
- Already configured correctly with `NSExtensionPrincipalClass`
|
|
- No storyboard needed (already removed)
|
|
|
|
**Benefits:**
|
|
- ✅ No interstitial UI - app opens immediately
|
|
- ✅ Faster user experience
|
|
- ✅ More seamless integration
|
|
|
|
**Considerations:**
|
|
- ⚠️ User has less control (can't cancel easily)
|
|
- ⚠️ No visual feedback during processing (could add minimal loading indicator)
|
|
- ⚠️ Apple guidelines: Extensions should provide value even if they don't open the app
|
|
|
|
## Improvement 2: Direct App Launch Without Deep Link
|
|
|
|
### Current Approach
|
|
- Share Extension stores data in App Group UserDefaults
|
|
- Share Extension opens app via deep link (`timesafari://shared-photo`)
|
|
- App receives deep link → checks App Group → processes image
|
|
|
|
### Alternative: App Lifecycle Detection
|
|
|
|
Instead of using deep links, the app can check for shared data when it becomes active:
|
|
|
|
**Option A: Check on App Activation**
|
|
|
|
```swift
|
|
// In AppDelegate.swift
|
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
|
// Check for shared image from Share Extension
|
|
if let sharedData = getSharedImageData() {
|
|
// Store in temp file for JS to read
|
|
writeSharedImageToTempFile(sharedData)
|
|
|
|
// Navigate to shared-photo route directly
|
|
// This would need to be handled in JS layer
|
|
}
|
|
}
|
|
```
|
|
|
|
**Option B: Use Notification (More Reliable)**
|
|
|
|
```swift
|
|
// In ShareViewController.swift (after storing data)
|
|
private func openMainApp() {
|
|
// Store a flag that image is ready
|
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
|
return
|
|
}
|
|
userDefaults.set(true, forKey: "sharedPhotoReady")
|
|
userDefaults.synchronize()
|
|
|
|
// Open app (can use any URL scheme or even just launch the app)
|
|
guard let url = URL(string: "timesafari://") 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
|
|
}
|
|
}
|
|
|
|
// In AppDelegate.swift
|
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
|
let appGroupIdentifier = "group.app.timesafari"
|
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
|
return
|
|
}
|
|
|
|
// Check if shared photo is ready
|
|
if userDefaults.bool(forKey: "sharedPhotoReady") {
|
|
userDefaults.removeObject(forKey: "sharedPhotoReady")
|
|
userDefaults.synchronize()
|
|
|
|
// Process shared image
|
|
if let sharedData = getSharedImageData() {
|
|
writeSharedImageToTempFile(sharedData)
|
|
|
|
// Trigger JS to check for shared image
|
|
// This could be done via Capacitor App plugin or custom event
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Option C: Check on App Launch (Most Direct)**
|
|
|
|
```swift
|
|
// In AppDelegate.swift
|
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
|
// Check for shared image immediately on launch
|
|
checkForSharedImageOnLaunch()
|
|
|
|
return true
|
|
}
|
|
|
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
|
// Also check when app becomes active (in case it was already running)
|
|
checkForSharedImageOnLaunch()
|
|
}
|
|
|
|
private func checkForSharedImageOnLaunch() {
|
|
if let sharedData = getSharedImageData() {
|
|
writeSharedImageToTempFile(sharedData)
|
|
|
|
// Post a notification or use Capacitor to notify JS
|
|
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
|
|
}
|
|
}
|
|
```
|
|
|
|
**JavaScript Integration:**
|
|
|
|
```typescript
|
|
// In main.capacitor.ts
|
|
import { App } from '@capacitor/app';
|
|
|
|
// Listen for app becoming active
|
|
App.addListener('appStateChange', async ({ isActive }) => {
|
|
if (isActive) {
|
|
// Check for shared image when app becomes active
|
|
await checkAndStoreNativeSharedImage();
|
|
}
|
|
});
|
|
|
|
// Also check on initial load
|
|
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios') {
|
|
checkAndStoreNativeSharedImage().then(result => {
|
|
if (result.success) {
|
|
// Navigate to shared-photo route
|
|
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
**Benefits:**
|
|
- ✅ No deep link routing needed
|
|
- ✅ More direct data flow
|
|
- ✅ App can detect shared content even if it was already running
|
|
- ✅ Simpler URL scheme handling
|
|
|
|
**Considerations:**
|
|
- ⚠️ Need to ensure app checks on both launch and activation
|
|
- ⚠️ May need to handle race conditions (app launching vs. share extension writing)
|
|
- ⚠️ Still need some way to open the app (minimal URL scheme still required)
|
|
|
|
## Recommended Approach
|
|
|
|
**Best of Both Worlds:**
|
|
|
|
1. **Use Custom UIViewController** (Improvement 1) - Eliminates interstitial UI
|
|
2. **Use App Lifecycle Detection** (Improvement 2, Option C) - Direct data flow
|
|
|
|
**Combined Implementation:**
|
|
|
|
```swift
|
|
// ShareViewController.swift - Custom UIViewController
|
|
class ShareViewController: UIViewController {
|
|
// Process immediately in viewDidLoad
|
|
// Store data in App Group
|
|
// Open app with minimal URL (just "timesafari://")
|
|
}
|
|
|
|
// AppDelegate.swift
|
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
|
// Check for shared image
|
|
// If found, write to temp file and let JS handle navigation
|
|
}
|
|
```
|
|
|
|
**JavaScript:**
|
|
```typescript
|
|
// Check on app activation
|
|
App.addListener('appStateChange', async ({ isActive }) => {
|
|
if (isActive) {
|
|
const result = await checkAndStoreNativeSharedImage();
|
|
if (result.success) {
|
|
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
This approach:
|
|
- ✅ No interstitial UI
|
|
- ✅ No deep link routing complexity
|
|
- ✅ Direct data flow via App Group
|
|
- ✅ Works whether app is running or launching fresh
|
|
|