Files
crowd-funder-for-time-pwa/doc/ios-share-extension-improvements.md
Jose Olarte III eff4126043 feat(ios): improve share extension UX and fix image reload issues
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.
2025-11-25 18:22:43 +08:00

8.7 KiB

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:

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

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

// 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)

// 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)

// 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:

// 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)

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:

// 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:

// 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