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.
This commit is contained in:
283
doc/ios-share-extension-improvements.md
Normal file
283
doc/ios-share-extension-improvements.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user