Compare commits
8 Commits
master
...
402bd2681f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402bd2681f | ||
|
|
498a4926bf | ||
|
|
f0ca49b5dc | ||
|
|
07463246f0 | ||
|
|
79ceebbd1d | ||
|
|
ddbd07f315 | ||
|
|
35a6a6bfb3 | ||
|
|
08a55202f5 |
20
AGENTS.md
20
AGENTS.md
@@ -1,20 +0,0 @@
|
||||
# Agent Instructions for crowd-funder-for-time-pwa
|
||||
|
||||
## Android Build — Google Play Services / FOSS Compatibility
|
||||
|
||||
**Firebase is opt-in. Do NOT enable it accidentally.**
|
||||
|
||||
`android/google-services.json` is gitignored and may be present on disk for push notification development, but Firebase is only activated when you explicitly pass `-PfirebaseEnabled` to Gradle:
|
||||
|
||||
- **FOSS / APK / Aurora / Zapstore / F-Droid builds**: just `./gradlew assembleRelease` — Firebase stays off even if `google-services.json` is on disk.
|
||||
- **Firebase / FCM / Play Store builds**: `./gradlew bundleRelease -PfirebaseEnabled` — explicitly opt in.
|
||||
|
||||
This guard is in `android/app/build.gradle`. Do NOT change this conditional to activate Firebase unconditionally based on file presence alone — that was the bug that broke FOSS distribution in June 2026.
|
||||
|
||||
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
|
||||
|
||||
Full details, incident history, and F-Droid notes: `doc/development/android-firebase-gms.md`
|
||||
|
||||
## Android Build — MLKit Barcode Scanner
|
||||
|
||||
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
|
||||
11
BUILDING.md
11
BUILDING.md
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
|
||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 70 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.4;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
cd ios/App && xcrun agvtool new-version 69 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.3;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
@@ -1419,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
|
||||
##### 1. Bump the version in package.json, then update these versions & run:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 70/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.4.4"/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionCode .*/versionCode 69/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.4.3"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
@@ -1458,18 +1458,17 @@ cd -
|
||||
|
||||
- Setup by adding the app/gradle.properties.secrets file (see properties at top
|
||||
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||
- In app/build.gradle, bump the versionCode and maybe the versionName
|
||||
- Then `bundleRelease`:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||
cd -
|
||||
```
|
||||
|
||||
... and find your `aab` file at app/build/outputs/bundle/release
|
||||
|
||||
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
|
||||
|
||||
At play.google.com/console:
|
||||
|
||||
- Go to Production or the Closed Testing and either Create Track or Manage Track.
|
||||
|
||||
@@ -6,11 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [1.4.4] - 2026.06.21
|
||||
### Changed
|
||||
- More checks for Firebase so that it won't break, eg in Aurora store.
|
||||
|
||||
|
||||
## [1.4.3] - 2026.06.19
|
||||
### Removed
|
||||
- Automatic "Check your starred projects" daily notification
|
||||
|
||||
@@ -37,8 +37,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 70
|
||||
versionName "1.4.4"
|
||||
versionCode 69
|
||||
versionName "1.4.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -130,20 +130,11 @@ dependencies {
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
// Firebase / Google Play Services are opt-in. Pass -PfirebaseEnabled to any Gradle command
|
||||
// to activate Firebase (FCM push notifications). Without this flag the build works on
|
||||
// F-Droid, Aurora, Zapstore, and plain APK sideloading even when google-services.json
|
||||
// is present on disk (it is gitignored; see AGENTS.md for the full story).
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
logger.info("Firebase enabled: google-services plugin applied")
|
||||
} else if (servicesJSON.exists() && !project.hasProperty('firebaseEnabled')) {
|
||||
logger.info("google-services.json present but firebaseEnabled not set — skipping Firebase plugin (pass -PfirebaseEnabled to enable)")
|
||||
} else {
|
||||
logger.info("google-services.json not found — Firebase plugin not applied")
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services plugin not applied: ${e.message}")
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Android — Firebase, Google Play Services, and FOSS Distribution
|
||||
|
||||
## Overview
|
||||
|
||||
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM push notifications are an opt-in feature at build time; all other functionality works on GMS-less devices (F-Droid, LineageOS without OpenGApps, etc.).
|
||||
|
||||
## How the opt-in guard works
|
||||
|
||||
`android/app/build.gradle` applies the `com.google.gms.google-services` Gradle plugin only when **both** conditions are true:
|
||||
|
||||
1. `android/google-services.json` is present on disk
|
||||
2. The Gradle property `firebaseEnabled` is explicitly passed
|
||||
|
||||
```groovy
|
||||
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
```
|
||||
|
||||
This means the file can live on disk for development purposes without accidentally activating Firebase.
|
||||
|
||||
## Build commands
|
||||
|
||||
| Target | Command | Firebase |
|
||||
|---|---|---|
|
||||
| APK / sideload / Zapstore | `./gradlew assembleRelease` | off |
|
||||
| Aurora / Play Store without FCM | `./gradlew bundleRelease` | off |
|
||||
| Play Store with FCM push notifications | `./gradlew bundleRelease -PfirebaseEnabled` | on |
|
||||
| F-Droid | `./gradlew assembleRelease` | off (required) |
|
||||
|
||||
## Behavior on non-GMS devices
|
||||
|
||||
When built with `-PfirebaseEnabled`, Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
|
||||
|
||||
- Firebase initializes but detects no GMS
|
||||
- FCM skips token registration silently (no token, no notifications)
|
||||
- The app continues to work normally
|
||||
|
||||
This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
|
||||
|
||||
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The Play Store AAB built with `-PfirebaseEnabled` is correct for Aurora.
|
||||
|
||||
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
|
||||
|
||||
## The `google-services.json` file
|
||||
|
||||
- Gitignored (`android/.gitignore` line 80) — never commit it
|
||||
- Contains Firebase project credentials (project number, app ID, API key)
|
||||
- Safe to leave on disk; has no effect unless `-PfirebaseEnabled` is passed
|
||||
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
|
||||
|
||||
## Known GMS dependency: MLKit barcode scanner
|
||||
|
||||
`@capacitor-mlkit/barcode-scanning` unconditionally depends on `com.google.android.gms:play-services-code-scanner` (present since the plugin was first added at v6.0.0). This merges `com.google.android.gms.version` and `GoogleApiActivity` into the APK manifest regardless of the `-PfirebaseEnabled` flag.
|
||||
|
||||
Practical impact:
|
||||
- **GMS devices**: barcode scanning works normally
|
||||
- **Non-GMS devices**: barcode scanning fails at scan time (not at startup); the app launches and runs normally otherwise
|
||||
|
||||
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
|
||||
|
||||
## Incident: June 2026
|
||||
|
||||
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
|
||||
|
||||
**Fix applied:** deleted `google-services.json` from disk, changed the Gradle conditional to require `-PfirebaseEnabled`, and documented the rule in `AGENTS.md`.
|
||||
@@ -1,308 +0,0 @@
|
||||
# Deep Link Debugging Guide for TimeSafari
|
||||
|
||||
This guide helps you debug and fix deep link issues in the TimeSafari Capacitor application.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Check logs**: Use browser dev tools or device console to see detailed logging
|
||||
2. **Test manually**: Use the testing script or browser console commands
|
||||
3. **Verify configuration**: Ensure all platform configurations are correct
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: App opens but doesn't navigate to the correct page
|
||||
|
||||
**Symptoms:**
|
||||
- Deep link opens the app
|
||||
- App stays on home screen or current page
|
||||
- No error messages visible
|
||||
|
||||
**Debugging Steps:**
|
||||
|
||||
1. **Check console logs** in browser dev tools or device console:
|
||||
```bash
|
||||
# Android
|
||||
adb logcat | grep -E "(TimeSafari|DeepLink|appUrlOpen)"
|
||||
|
||||
# iOS Simulator
|
||||
xcrun simctl spawn booted log stream --predicate 'process == "TimeSafari"'
|
||||
```
|
||||
|
||||
2. **Verify listener registration**:
|
||||
Look for these log messages:
|
||||
```
|
||||
[DeepLink] Registering appUrlOpen listener...
|
||||
[DeepLink] Listener registered successfully
|
||||
```
|
||||
|
||||
3. **Check for event reception**:
|
||||
Look for:
|
||||
```
|
||||
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
|
||||
[DeepLink] URL: timesafari://your/url/here
|
||||
```
|
||||
|
||||
4. **Verify URL parsing**:
|
||||
Check if URL components are parsed correctly:
|
||||
```
|
||||
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"123"},"query":{}}
|
||||
```
|
||||
|
||||
### Issue 2: Listener not receiving events
|
||||
|
||||
**Symptoms:**
|
||||
- No deep link logs appear
|
||||
- App opens but no event processing
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Rebuild and reinstall** the app completely:
|
||||
```bash
|
||||
npm run build:capacitor
|
||||
npx cap sync
|
||||
npx cap run android # or ios
|
||||
```
|
||||
|
||||
2. **Check capacitor.config.json**:
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"App": {
|
||||
"appUrlOpen": {
|
||||
"handlers": [
|
||||
{
|
||||
"url": "timesafari://*",
|
||||
"autoVerify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Verify native configuration**:
|
||||
- **Android**: Check `android/app/src/main/AndroidManifest.xml`
|
||||
- **iOS**: Check `ios/App/App/Info.plist`
|
||||
|
||||
### Issue 3: URL scheme not recognized by OS
|
||||
|
||||
**Symptoms:**
|
||||
- "No app found to handle this link" error
|
||||
- OS doesn't open your app
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Android**: Verify intent filter in AndroidManifest.xml:
|
||||
```xml
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
2. **iOS**: Verify URL types in Info.plist:
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
## Testing Tools
|
||||
|
||||
### 1. Automated Testing Script
|
||||
|
||||
Use the provided testing script:
|
||||
|
||||
```bash
|
||||
# Test all URLs on Android
|
||||
./scripts/test-deep-links.sh android
|
||||
|
||||
# Test specific URL on iOS
|
||||
./scripts/test-deep-links.sh ios "timesafari://claim/test123"
|
||||
|
||||
# Monitor logs
|
||||
./scripts/test-deep-links.sh android-logs
|
||||
```
|
||||
|
||||
### 2. Manual Testing Commands
|
||||
|
||||
**Android Emulator:**
|
||||
```bash
|
||||
adb shell am start -W -a android.intent.action.VIEW -d "timesafari://claim/test123" app.timesafari
|
||||
```
|
||||
|
||||
**iOS Simulator:**
|
||||
```bash
|
||||
xcrun simctl openurl booted "timesafari://claim/test123"
|
||||
```
|
||||
|
||||
### 3. Browser Console Testing
|
||||
|
||||
For web/PWA testing, use the browser console:
|
||||
|
||||
```javascript
|
||||
// Test deep link processing directly
|
||||
window.testSingleDeepLink("timesafari://claim/test123");
|
||||
|
||||
// Run all test URLs
|
||||
window.testDeepLinks();
|
||||
```
|
||||
|
||||
## Debugging Steps Checklist
|
||||
|
||||
### Pre-Testing Setup
|
||||
|
||||
- [ ] App is installed on device/emulator
|
||||
- [ ] App has been launched at least once
|
||||
- [ ] Device/emulator is properly connected
|
||||
- [ ] Debugging tools are accessible
|
||||
|
||||
### During Testing
|
||||
|
||||
- [ ] Check console for initialization logs
|
||||
- [ ] Verify listener registration
|
||||
- [ ] Test with simple URL first (e.g., `timesafari://claim/test`)
|
||||
- [ ] Monitor URL parsing logs
|
||||
- [ ] Check router navigation logs
|
||||
|
||||
### Post-Testing Analysis
|
||||
|
||||
- [ ] Review complete log sequence
|
||||
- [ ] Identify where process fails
|
||||
- [ ] Check error messages for clues
|
||||
- [ ] Test with different URL formats
|
||||
|
||||
## Common Log Patterns
|
||||
|
||||
### Successful Deep Link Flow
|
||||
|
||||
```
|
||||
[DeepLink] Registering appUrlOpen listener...
|
||||
[DeepLink] Listener registered successfully
|
||||
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
|
||||
[DeepLink] URL: timesafari://claim/test123
|
||||
[DeepLinkHandler] Starting handleDeepLink with URL: timesafari://claim/test123
|
||||
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"test123"},"query":{}}
|
||||
[DeepLinkHandler.validateAndRoute] Route validation passed. Route name: claim
|
||||
[DeepLinkHandler.validateAndRoute] Router navigation completed successfully
|
||||
[DeepLink] Deep link handled successfully
|
||||
```
|
||||
|
||||
### Failed URL Parsing
|
||||
|
||||
```
|
||||
[DeepLinkHandler.parseDeepLink] Route not found: invalid-route
|
||||
[DeepLinkHandler.parseDeepLink] Available routes: ["claim","project","contact-import",...]
|
||||
[DeepLinkHandler.validateAndRoute] Redirecting to deep-link-error page
|
||||
```
|
||||
|
||||
### Router Navigation Issues
|
||||
|
||||
```
|
||||
[DeepLinkHandler.validateAndRoute] Error routing to route name claim
|
||||
[DeepLinkHandler.validateAndRoute] Navigation params: {"name":"claim","params":{"id":"test123"}}
|
||||
```
|
||||
|
||||
## Platform-Specific Issues
|
||||
|
||||
### Android
|
||||
|
||||
**Issue**: Deep links work in development but not in production build
|
||||
- **Solution**: Ensure `android:exported="true"` in MainActivity
|
||||
|
||||
**Issue**: App doesn't respond to links when running in background
|
||||
- **Solution**: Check `android:launchMode="singleTask"` in AndroidManifest.xml
|
||||
|
||||
### iOS
|
||||
|
||||
**Issue**: Deep links don't work in iOS simulator
|
||||
- **Solution**: Use `xcrun simctl openurl` instead of opening URLs in Safari
|
||||
|
||||
**Issue**: App launches but doesn't process URL
|
||||
- **Solution**: Check for Associated Domains if using universal links
|
||||
|
||||
## Advanced Debugging
|
||||
|
||||
### Enable Capacitor Native Logging
|
||||
|
||||
Add to `capacitor.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ios": {
|
||||
"loggingBehavior": "debug"
|
||||
},
|
||||
"android": {
|
||||
"loggingBehavior": "debug"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add Custom Debug Points
|
||||
|
||||
Insert additional logging in your deep link handler:
|
||||
|
||||
```typescript
|
||||
// Add at strategic points in DeepLinkHandler
|
||||
console.log('[DEBUG] Custom checkpoint:', { data: yourData });
|
||||
```
|
||||
|
||||
### Network Debugging
|
||||
|
||||
If deep links involve network requests:
|
||||
|
||||
```bash
|
||||
# Monitor network traffic (Android)
|
||||
adb shell dumpsys connectivity
|
||||
|
||||
# Monitor network traffic (iOS)
|
||||
# Use Xcode Network Debugger
|
||||
```
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
### If deep links stop working completely:
|
||||
|
||||
1. **Clean rebuild**:
|
||||
```bash
|
||||
rm -rf node_modules
|
||||
npm install
|
||||
npm run build:capacitor
|
||||
npx cap sync
|
||||
```
|
||||
|
||||
2. **Reset device/emulator**:
|
||||
- Clear app data
|
||||
- Uninstall and reinstall
|
||||
- Restart emulator
|
||||
|
||||
3. **Verify basic functionality**:
|
||||
- Test simple navigation within app
|
||||
- Test URL schemes with minimal URLs
|
||||
- Gradually increase complexity
|
||||
|
||||
## Support Resources
|
||||
|
||||
- [Capacitor Deep Links Documentation](https://capacitorjs.com/docs/guides/deep-links)
|
||||
- [Android Intent Filter Guide](https://developer.android.com/guide/components/intents-filters)
|
||||
- [iOS URL Scheme Guide](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app)
|
||||
|
||||
## Contact and Feedback
|
||||
|
||||
If you encounter issues not covered in this guide:
|
||||
|
||||
1. Check the project's issue tracker
|
||||
2. Review recent commits for deep link changes
|
||||
3. Test with minimal reproduction case
|
||||
4. Document exact steps and environment details
|
||||
449
doc/share-target-ios-audit.md
Normal file
449
doc/share-target-ios-audit.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# iOS Share Target Implementation Audit
|
||||
|
||||
**Generated:** 2026-06-23 17:07:21 PST
|
||||
|
||||
## Overview
|
||||
|
||||
The iOS share target uses a **Share Extension** (`TimeSafariShareExtension`) that writes a shared image to an **App Group** container (`group.app.timesafari.share`), then opens the main app via `timesafari://`. The main app reads the image through a native **Capacitor plugin** (`SharedImagePlugin`) and stores it in the JS temp database before routing to `/shared-photo`.
|
||||
|
||||
### App Group Storage Model
|
||||
|
||||
| Key | Storage | Written by | Purpose |
|
||||
|-----|---------|------------|---------|
|
||||
| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | On-disk filename in container (`<shareId>.<ext>`) |
|
||||
| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Original display filename from source app |
|
||||
| `sharedPhotoShareId` | UserDefaults (suite) | Share Extension | Unique UUID per incoming share (Phase 1A) |
|
||||
| `sharedPhotoReady` | UserDefaults (suite) | Share Extension | Boolean signal that a new share is available |
|
||||
| `sharedPhotoBase64` | UserDefaults (suite) | *(legacy, not written)* | Removed on write for cleanup |
|
||||
| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` (`<shareId>.<ext>`) |
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
```
|
||||
External App (Photos, Safari, etc.)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ TimeSafariShareExtension │
|
||||
│ ShareViewController │
|
||||
│ 1. viewDidLoad → processAndOpenApp │
|
||||
│ 2. processSharedImage (async) │
|
||||
│ 3. storeImageData → file + metadata │
|
||||
│ 4. setSharedPhotoReadyFlag │
|
||||
│ 5. openMainApp (timesafari://) │
|
||||
│ 6. completeRequest │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼ App Group: group.app.timesafari.share
|
||||
│ (UserDefaults keys + image file)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Main App (app.timesafari) │
|
||||
│ │
|
||||
│ Native detection: │
|
||||
│ • AppDelegate.applicationDidBecome │
|
||||
│ Active → checkForSharedImageOn │
|
||||
│ Activation (flag only) │
|
||||
│ • AppDelegate.application(open:) │
|
||||
│ → Capacitor URL handling │
|
||||
│ │
|
||||
│ JS detection (main.capacitor.ts): │
|
||||
│ • setTimeout 1000ms startup check │
|
||||
│ • appStateChange (isActive) │
|
||||
│ • appUrlOpen → timesafari:// │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ SharedImagePlugin.getSharedImage() │
|
||||
│ → SharedImageUtility │
|
||||
│ .getSharedImageData() │
|
||||
│ (read-only; leaves native data intact) │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ main.capacitor.ts │
|
||||
│ storeSharedImageInTempDB() │
|
||||
│ → SQLite temp table │
|
||||
│ → router.push/replace /shared-photo│
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ SharedPhotoView.vue │
|
||||
│ loadSharedImage() │
|
||||
│ → reads temp DB, deletes temp row │
|
||||
│ → displays image, user action │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Share Extension (Writer)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | `viewDidLoad()` | Entry point; triggers share processing on load |
|
||||
| | `processAndOpenApp()` | Orchestrates image extraction, flag set, app open, extension completion |
|
||||
| | `processSharedImage(from:completion:)` | Iterates `NSExtensionItem` attachments; loads first `UTType.image` via `loadItem` |
|
||||
| | `storeImageData(_:fileName:)` | Writes image file to App Group container; writes metadata to UserDefaults |
|
||||
| | `setSharedPhotoReadyFlag()` | Sets `sharedPhotoReady = true` in App Group UserDefaults |
|
||||
| | `openMainApp()` | Opens `timesafari://` via responder chain or `extensionContext.open` |
|
||||
| | `getFileNameWithExtension(_:newExtension:)` | Helper for PNG fallback filename |
|
||||
| `ios/App/TimeSafariShareExtension/Info.plist` | — | Declares share-services extension; accepts 1 image max |
|
||||
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||
|
||||
### Main App Native Layer (Reader)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Read-only: reads file from App Group, returns base64 + fileName; leaves metadata and file intact (Phase 1C) |
|
||||
| | `hasSharedImage()` | Non-destructive existence check (metadata + file on disk) |
|
||||
| | `isSharedPhotoReady()` | Reads `sharedPhotoReady` flag |
|
||||
| | `clearSharedPhotoReadyFlag()` | Removes `sharedPhotoReady` key |
|
||||
| `ios/App/App/SharedImagePlugin.swift` | `getSharedImage(_:)` | Capacitor bridge to `getSharedImageData()` |
|
||||
| | `hasSharedImage(_:)` | Capacitor bridge to `hasSharedImage()` |
|
||||
| `ios/App/App/AppDelegate.swift` | `application(_:didFinishLaunchingWithOptions:)` | Registers `SharedImagePlugin` with retry loop |
|
||||
| | `registerSharedImagePlugin()` | Manually registers plugin instance on Capacitor bridge |
|
||||
| | `applicationDidBecomeActive(_:)` | Calls `checkForSharedImageOnActivation()` |
|
||||
| | `checkForSharedImageOnActivation()` | Checks ready flag, clears it, posts `SharedPhotoReady` NSNotification |
|
||||
| | `application(_:open:options:)` | Forwards URL opens (including `timesafari://`) to Capacitor |
|
||||
| `ios/App/App/App.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||
|
||||
### JavaScript Layer (Consumer)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/plugins/SharedImagePlugin.ts` | — | Registers Capacitor plugin name `SharedImage` |
|
||||
| `src/plugins/definitions.ts` | — | TypeScript interface for `getSharedImage` / `hasSharedImage` |
|
||||
| `src/main.capacitor.ts` | `checkAndStoreNativeSharedImage()` | Calls `SharedImage.getSharedImage()`, stores in temp DB; guarded by `isProcessingSharedImage` lock |
|
||||
| | `storeSharedImageInTempDB()` | Clears old temp row, inserts base64 data URL into SQLite `temp` table |
|
||||
| | `checkForSharedImageAndNavigate()` | Checks native share, navigates to `/shared-photo` on success |
|
||||
| | `handleDeepLink()` | Handles `timesafari://` empty-path URLs from share extension on iOS |
|
||||
| | `registerDeepLinkListener()` | Registers Capacitor `appUrlOpen` listener |
|
||||
| `src/views/SharedPhotoView.vue` | `mounted()` / `onRouteQueryChange()` | Loads image from temp DB for display |
|
||||
| | `loadSharedImage()` | Reads `SHARED_PHOTO_BASE64_KEY` from temp DB; **deletes temp row** after load |
|
||||
| `src/router/index.ts` | — | Defines `/shared-photo` route |
|
||||
| `src/libs/util.ts` | `SHARED_PHOTO_BASE64_KEY` | Temp DB key constant (`"shared-photo-base64"`) |
|
||||
|
||||
---
|
||||
|
||||
## Read / Write / Delete Inventory
|
||||
|
||||
### Writes — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Written |
|
||||
|------|--------|--------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||
| `ShareViewController.swift` | `setSharedPhotoReadyFlag()` | `sharedPhotoReady` (= `true`) |
|
||||
|
||||
### Writes — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `imageData.write(to:)` at `{containerURL}/{actualFileName}` |
|
||||
|
||||
### Reads — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Read |
|
||||
|------|--------|-----------|
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||
| `SharedImageUtility.swift` | `hasSharedImage()` | `sharedPhotoFilePath` |
|
||||
| `SharedImageUtility.swift` | `isSharedPhotoReady()` | `sharedPhotoReady` |
|
||||
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `isSharedPhotoReady()`) |
|
||||
|
||||
### Reads — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `processSharedImage` (URL path) | Reads source image via `Data(contentsOf: url)` from security-scoped URL |
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `Data(contentsOf: fileURL)` from App Group container |
|
||||
| `SharedImageUtility.swift` | `hasSharedImage()` | `FileManager.fileExists(atPath:)` only (no data read) |
|
||||
|
||||
### Deletes — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Removed |
|
||||
|------|--------|--------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoBase64` (legacy cleanup) |
|
||||
| `SharedImageUtility.swift` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` |
|
||||
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) |
|
||||
|
||||
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
|
||||
|
||||
| Keys / files | Mechanism |
|
||||
|--------------|-----------|
|
||||
| `sharedPhotoFilePath` | `userDefaults.removeObject(forKey:)` |
|
||||
| `sharedPhotoFileName` | `userDefaults.removeObject(forKey:)` |
|
||||
| Image file at `sharedPhotoFilePath` | `FileManager.removeItem(at:)` |
|
||||
| `userDefaults.synchronize()` after deletion | Called after metadata/file removal |
|
||||
|
||||
### Deletes — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | Removes previous pending share file at prior `sharedPhotoFilePath` before write |
|
||||
|
||||
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
|
||||
|
||||
| Operation | Mechanism |
|
||||
|-----------|-----------|
|
||||
| Delete image file after successful read | `FileManager.removeItem(at: fileURL)` |
|
||||
|
||||
### Secondary Storage (Post-Native Consumption)
|
||||
|
||||
After native read, image data lives in SQLite `temp` table under key `shared-photo-base64`:
|
||||
|
||||
| File | Method | Operation |
|
||||
|------|--------|-----------|
|
||||
| `main.capacitor.ts` | `storeSharedImageInTempDB()` | **DELETE** old row, then **INSERT OR REPLACE** |
|
||||
| `SharedPhotoView.vue` | `loadSharedImage()` | **READ** then **DELETE** temp row |
|
||||
|
||||
---
|
||||
|
||||
## Timing, Delays, Retries, and Polling
|
||||
|
||||
| Location | Mechanism | Values | Purpose |
|
||||
|----------|-----------|--------|---------|
|
||||
| `AppDelegate.swift` `didFinishLaunching` | Plugin registration retry loop | Initial delay **0.5s**; up to **5** attempts; backoff **0.5s × attempt** | Ensure Capacitor bridge exists before registering `SharedImagePlugin` |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** (iOS only) | Deferred check for shared image on cold launch |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **2000ms** | Deferred registration of `appUrlOpen` deep-link listener |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** | Log app initialization status |
|
||||
| `main.capacitor.ts` | `CapacitorApp.addListener("appStateChange")` | On every `isActive === true` | Re-check shared image when app foregrounds |
|
||||
| `main.capacitor.ts` | `isProcessingSharedImage` flag | Synchronous JS lock | Prevents concurrent `checkAndStoreNativeSharedImage()` calls |
|
||||
| `main.capacitor.ts` | Comment at line 214 | References polling | **Stale comment** — `checkAndStoreNativeSharedImage()` does **not** poll or retry |
|
||||
|
||||
**No native polling or retry** exists for reading App Group data. `hasSharedImage()` is exposed but **never called** from application JS code.
|
||||
|
||||
---
|
||||
|
||||
## Current Startup Detection Points
|
||||
|
||||
| # | Layer | Trigger | File | Method | Action |
|
||||
|---|-------|---------|------|--------|--------|
|
||||
| 1 | Native | App becomes active (cold start + resume) | `AppDelegate.swift` | `applicationDidBecomeActive` → `checkForSharedImageOnActivation` | Reads `sharedPhotoReady` flag; clears flag; posts `SharedPhotoReady` NSNotification *(no JS listener)* |
|
||||
| 2 | JS | Module load + 1000ms delay | `main.capacitor.ts` | `setTimeout` → `checkForSharedImageAndNavigate` | Calls `SharedImage.getSharedImage()`, stores in temp DB, navigates to `/shared-photo` |
|
||||
| 3 | JS | App foreground | `main.capacitor.ts` | `appStateChange` listener → `checkForSharedImageAndNavigate` | Same as above |
|
||||
| 4 | JS | Deep link `timesafari://` | `main.capacitor.ts` | `appUrlOpen` → `handleDeepLink` → `checkAndStoreNativeSharedImage` | iOS-only empty-path URL handling; navigates to `/shared-photo` |
|
||||
| 5 | Native | URL open | `AppDelegate.swift` | `application(_:open:options:)` | Forwards to Capacitor `ApplicationDelegateProxy` (enables #4) |
|
||||
| 6 | Native | Cold launch plugin setup | `AppDelegate.swift` | `didFinishLaunching` → `tryRegister` | Registers `SharedImagePlugin` (not a share check, but required for JS reads) |
|
||||
|
||||
**Note:** Detection points 1–4 can all fire for a single share event. Only the JS paths (#2–4) actually read and consume the image.
|
||||
|
||||
---
|
||||
|
||||
## Current Deletion Points
|
||||
|
||||
### App Group (Native)
|
||||
|
||||
| When | File | Method | What is deleted |
|
||||
|------|------|--------|-----------------|
|
||||
| Before new share write | `ShareViewController.swift` | `storeImageData` | Previous pending share file at prior `sharedPhotoFilePath` |
|
||||
| Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key |
|
||||
| On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag |
|
||||
|
||||
**Removed in Phase 1C** (no longer deleted on retrieve):
|
||||
|
||||
| When | File | Method | What was deleted |
|
||||
|------|------|--------|------------------|
|
||||
| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file |
|
||||
|
||||
### Temp Database (JS)
|
||||
|
||||
| When | File | Method | What is deleted |
|
||||
|------|------|--------|-----------------|
|
||||
| Before storing new share | `main.capacitor.ts` | `storeSharedImageInTempDB` | Prior `shared-photo-base64` temp row |
|
||||
| After view loads image | `SharedPhotoView.vue` | `loadSharedImage` | `shared-photo-base64` temp row |
|
||||
|
||||
**Important:** Native image file and metadata persist after `getSharedImageData()` (Phase 1C). Cleanup is deferred to a later phase. The `sharedPhotoReady` flag is still cleared independently by `AppDelegate` on activation.
|
||||
|
||||
---
|
||||
|
||||
## Potential Race Conditions
|
||||
|
||||
1. **Multiple JS detection paths, repeatable native read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. Since Phase 1C, `getSharedImageData()` is read-only and returns the same data on every call until a new share overwrites metadata or explicit cleanup is added. The `isProcessingSharedImage` JS lock still reduces duplicate temp-DB writes and navigations.
|
||||
|
||||
2. **Deep-link listener registered 2 seconds after mount.** The share extension opens `timesafari://` immediately. If Capacitor does not buffer the launch URL until the `appUrlOpen` listener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check and `appStateChange` paths partially compensate.
|
||||
|
||||
3. **Plugin registration vs. first `getSharedImage()` call.** `SharedImagePlugin` is registered with up to 5 retries starting at T+500ms. A `getSharedImage()` call before registration completes will fail. The 1000ms startup delay usually avoids this, but `appStateChange` can fire earlier.
|
||||
|
||||
4. **`sharedPhotoReady` flag cleared before JS reads image.** `AppDelegate.checkForSharedImageOnActivation` clears the flag and posts `SharedPhotoReady`, but no JavaScript code listens for that NSNotification. The flag is therefore a redundant signal; reliance is entirely on file/metadata presence. If file write failed but flag were set, the flag would be cleared with no image available (current code sets flag only after successful `storeImageData`).
|
||||
|
||||
5. **`SharedPhotoReady` NSNotification is a dead signal.** Posted in `AppDelegate` but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated `getSharedImage()` calls.
|
||||
|
||||
6. **Concurrent share while app is open.** A second share overwrites the App Group file and metadata. If the first share has already been read into temp DB but the user has not yet reached `SharedPhotoView`, the second share can replace native data; navigation refresh via `_refresh` query param handles re-navigation but temp DB overwrite in `storeSharedImageInTempDB` can clobber an in-flight first image.
|
||||
|
||||
7. **Extension `completeRequest` timing.** `completeRequest` runs in the `processSharedImage` completion handler after `storeImageData`, flag set, and `openMainApp` — so the file should exist before the extension exits. However, `loadItem` is asynchronous; if the extension process is terminated aggressively by iOS after `completeRequest`, this is generally safe because all writes complete in the callback before completion.
|
||||
|
||||
8. **Stale comment implies polling that does not exist.** `handleDeepLink` comments reference internal polling in `checkAndStoreNativeSharedImage`, but no retry loop exists. A single failed read at the wrong moment is not retried on iOS (unlike Android's multi-delay startup checks).
|
||||
|
||||
9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls `getSharedImage()` directly. Since Phase 1C both methods are non-destructive on the native layer.
|
||||
|
||||
---
|
||||
|
||||
## Share ID Tracking
|
||||
|
||||
**Implemented:** 2026-06-23 (Phase 1A)
|
||||
|
||||
Phase 1A adds a unique share identifier to the iOS share flow for observability and future reliability work. Existing retrieval and deletion behavior is unchanged.
|
||||
|
||||
### Identifier
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| UserDefaults key | `sharedPhotoShareId` |
|
||||
| Format | `UUID().uuidString` (e.g. `A1B2C3D4-E5F6-7890-ABCD-EF1234567890`) |
|
||||
| Generated in | `ShareViewController.processSharedImage` when the first image attachment is found |
|
||||
| Persisted in | `ShareViewController.storeImageData` alongside `sharedPhotoFilePath` and `sharedPhotoFileName` |
|
||||
|
||||
### Logging
|
||||
|
||||
All log lines use the prefix `[ShareTarget]` and include `shareId=<id>`:
|
||||
|
||||
| Event | File | Method | When |
|
||||
|-------|------|--------|------|
|
||||
| share received | `ShareViewController.swift` | `processSharedImage` | UUID generated before `loadItem` |
|
||||
| file stored | `ShareViewController.swift` | `storeImageData` | After successful `imageData.write(to:)` |
|
||||
| metadata stored | `ShareViewController.swift` | `storeImageData` | After UserDefaults `synchronize()` |
|
||||
| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (Phase 1C log format) |
|
||||
|
||||
Example log sequence for a single share:
|
||||
|
||||
```
|
||||
[ShareTarget] share received shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
|
||||
[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
|
||||
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 retrieved
|
||||
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 left intact after retrieval
|
||||
```
|
||||
|
||||
### Phase 1A Scope (Intentionally Unchanged)
|
||||
|
||||
- `getSharedImageData()` still returns only `base64` and `fileName` to JavaScript
|
||||
- `sharedPhotoShareId` is not deleted on retrieve (cleanup deferred to a later phase)
|
||||
- `hasSharedImage()`, `isSharedPhotoReady()`, and JS consumption paths are unchanged
|
||||
- Android code is unchanged
|
||||
|
||||
### Write Inventory Addition
|
||||
|
||||
| File | Method | Key Written |
|
||||
|------|--------|-------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | `sharedPhotoShareId` |
|
||||
|
||||
### Read Inventory Addition
|
||||
|
||||
| File | Method | Key Read |
|
||||
|------|--------|----------|
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoShareId` (logging only) |
|
||||
|
||||
---
|
||||
|
||||
## Unique Stored Filenames
|
||||
|
||||
**Implemented:** 2026-06-23 (Phase 1B)
|
||||
|
||||
Phase 1B eliminates on-disk filename collisions by storing each shared image under a UUID-based filename while preserving the original filename as metadata for consumers.
|
||||
|
||||
### On-Disk vs Metadata
|
||||
|
||||
| Field | UserDefaults key | Example | Purpose |
|
||||
|-------|------------------|---------|---------|
|
||||
| Stored filename | `sharedPhotoFilePath` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg` | Unique file in App Group container |
|
||||
| Original filename | `sharedPhotoFileName` | `vacation-photo.jpg` | Returned to JS as `fileName` |
|
||||
| Share ID | `sharedPhotoShareId` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890` | Correlates logs across extension and main app |
|
||||
|
||||
Stored filename format: `<shareId>.<extension>`, where extension is taken from the original filename (defaults to `jpg` when absent).
|
||||
|
||||
### Implementation
|
||||
|
||||
| File | Method | Change |
|
||||
|------|--------|--------|
|
||||
| `ShareViewController.swift` | `fileExtension(from:)` | Extracts extension from original filename |
|
||||
| `ShareViewController.swift` | `storedFileName(shareId:originalFileName:)` | Builds `<shareId>.<ext>` |
|
||||
| `ShareViewController.swift` | `storeImageData` | Writes to stored filename; saves original in `sharedPhotoFileName` |
|
||||
| `SharedImageUtility.swift` | `getSharedImageData` | Reads file via `sharedPhotoFilePath`; returns original `sharedPhotoFileName` |
|
||||
|
||||
When a new share arrives before the previous one is retrieved, `storeImageData` removes the file at the previous `sharedPhotoFilePath` before writing, preserving single-pending-share semantics.
|
||||
|
||||
### Logging (Phase 1B)
|
||||
|
||||
Store and retrieve events include all three identifiers:
|
||||
|
||||
```
|
||||
[ShareTarget] file stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] metadata stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] shareId=<id> retrieved
|
||||
[ShareTarget] shareId=<id> left intact after retrieval
|
||||
```
|
||||
|
||||
### Phase 1B Scope (Intentionally Unchanged)
|
||||
|
||||
- `getSharedImageData()` still returns only `base64` and original `fileName` to JavaScript
|
||||
- Android code is unchanged
|
||||
|
||||
---
|
||||
|
||||
## Non-Destructive Retrieval
|
||||
|
||||
**Implemented:** 2026-06-24 (Phase 1C)
|
||||
|
||||
Phase 1C makes native shared-content retrieval read-only. `getSharedImageData()` and `SharedImagePlugin.getSharedImage()` no longer delete App Group metadata or image files after a successful read. Explicit cleanup is deferred to a later phase.
|
||||
|
||||
### Behavior Change
|
||||
|
||||
| Aspect | Before Phase 1C | After Phase 1C |
|
||||
|--------|-----------------|----------------|
|
||||
| `sharedPhotoFilePath` after retrieve | Removed | Retained |
|
||||
| `sharedPhotoFileName` after retrieve | Removed | Retained |
|
||||
| `sharedPhotoShareId` after retrieve | Retained (since Phase 1A) | Retained |
|
||||
| Image file after retrieve | Deleted | Retained |
|
||||
| Return value to JS | `{ base64, fileName }` | Unchanged |
|
||||
| Repeat `getSharedImage()` calls | Return `null` after first success | Return same data until overwritten or cleaned up |
|
||||
|
||||
### Logging
|
||||
|
||||
After a successful read:
|
||||
|
||||
```
|
||||
[ShareTarget] shareId=<id> retrieved
|
||||
[ShareTarget] shareId=<id> left intact after retrieval
|
||||
```
|
||||
|
||||
### Removed Deletion Paths
|
||||
|
||||
All removal logic was in `SharedImageUtility.getSharedImageData()`:
|
||||
|
||||
| # | What was deleted | Code removed |
|
||||
|---|------------------|--------------|
|
||||
| 1 | `sharedPhotoFilePath` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFilePathKey)` |
|
||||
| 2 | `sharedPhotoFileName` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFileNameKey)` |
|
||||
| 3 | Image file at `{container}/{sharedPhotoFilePath}` | `FileManager.default.removeItem(at: fileURL)` |
|
||||
| 4 | Post-deletion UserDefaults flush | `userDefaults.synchronize()` after removals |
|
||||
|
||||
`SharedImagePlugin.getSharedImage(_:)` delegated to `getSharedImageData()` and had no independent deletion logic. Comment updated to reflect read-only behavior.
|
||||
|
||||
### Phase 1C Scope (Intentionally Unchanged)
|
||||
|
||||
- No new cleanup or purge APIs added
|
||||
- `clearSharedPhotoReadyFlag()` and share-extension write-side file removal unchanged
|
||||
- JS temp DB deletion in `main.capacitor.ts` and `SharedPhotoView.vue` unchanged
|
||||
- Android code unchanged
|
||||
|
||||
---
|
||||
|
||||
## Configuration References
|
||||
|
||||
| Resource | Value |
|
||||
|----------|-------|
|
||||
| App Group ID | `group.app.timesafari.share` |
|
||||
| URL scheme | `timesafari://` |
|
||||
| Extension bundle ID | `app.timesafari.TimeSafariShareExtension` |
|
||||
| Main app bundle ID | `app.timesafari` |
|
||||
| Capacitor plugin name | `SharedImage` |
|
||||
| Temp DB key | `shared-photo-base64` (`SHARED_PHOTO_BASE64_KEY`) |
|
||||
| Route | `/shared-photo` |
|
||||
@@ -524,7 +524,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -535,7 +535,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -553,7 +553,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -564,7 +564,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -582,7 +582,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -596,7 +596,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
@@ -620,7 +620,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -634,7 +634,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -24,7 +24,8 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public var pluginMethods: [CAPPluginMethod] {
|
||||
return [
|
||||
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
|
||||
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
|
||||
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise),
|
||||
CAPPluginMethod(#selector(getShareExtensionDiagnostics(_:)), returnType: .promise)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
/**
|
||||
* Get shared image data from App Group UserDefaults
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only: native metadata and file are left intact after retrieval (Phase 1C)
|
||||
*/
|
||||
@objc public func getSharedImage(_ call: CAPPluginCall) {
|
||||
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
||||
@@ -62,5 +63,12 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"hasImage": hasImage
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||
*/
|
||||
@objc public func getShareExtensionDiagnostics(_ call: CAPPluginCall) {
|
||||
call.resolve(SharedImageUtility.getShareExtensionDiagnostics())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,22 +13,44 @@ public class SharedImageUtility {
|
||||
private static let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private static let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||
private static let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||
private static let sharedPhotoReadyKey = "sharedPhotoReady"
|
||||
|
||||
/// Get the App Group container URL for accessing shared files
|
||||
private static var appGroupContainerURL: URL? {
|
||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
}
|
||||
|
||||
|
||||
private static func logShareDiagnostic(method: String, userDefaults: UserDefaults?) {
|
||||
let shareId = userDefaults?.string(forKey: sharedPhotoShareIdKey)
|
||||
let filePath = userDefaults?.string(forKey: sharedPhotoFilePathKey)
|
||||
let metadataExists = filePath != nil
|
||||
let fileExists: Bool
|
||||
if let filePath = filePath, let containerURL = appGroupContainerURL {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
||||
} else {
|
||||
fileExists = false
|
||||
}
|
||||
|
||||
let shareIdLog = shareId ?? "nil"
|
||||
let filePathLog = filePath ?? "nil"
|
||||
print("[ShareTarget] \(method) shareId=\(shareIdLog) sharedPhotoFilePath=\(filePathLog) metadataExists=\(metadataExists) fileExists=\(fileExists)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared image data from App Group container file
|
||||
* All images are stored as files for consistency and to avoid UserDefaults size limits
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only: metadata and file are left intact after retrieval (Phase 1C)
|
||||
*
|
||||
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||
*/
|
||||
static func getSharedImageData() -> [String: String]? {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
logShareDiagnostic(method: "getSharedImageData", userDefaults: userDefaults)
|
||||
|
||||
guard let userDefaults = userDefaults else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,25 +61,21 @@ public class SharedImageUtility {
|
||||
}
|
||||
|
||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
||||
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
|
||||
|
||||
// Read image data from file
|
||||
guard let imageData = try? Data(contentsOf: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let resolvedShareId = shareId ?? "unknown"
|
||||
print("[ShareTarget] shareId=\(resolvedShareId) retrieved")
|
||||
print("[ShareTarget] shareId=\(resolvedShareId) left intact after retrieval")
|
||||
|
||||
// Convert file data to base64 for JavaScript consumption
|
||||
let base64String = imageData.base64EncodedString()
|
||||
|
||||
// Clear the shared data after reading
|
||||
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
|
||||
|
||||
// Remove the file
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
|
||||
return ["base64": base64String, "fileName": fileName]
|
||||
}
|
||||
|
||||
@@ -67,7 +85,10 @@ public class SharedImageUtility {
|
||||
* @returns true if shared image file exists, false otherwise
|
||||
*/
|
||||
static func hasSharedImage() -> Bool {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
logShareDiagnostic(method: "hasSharedImage", userDefaults: userDefaults)
|
||||
|
||||
guard let userDefaults = userDefaults,
|
||||
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
|
||||
let containerURL = appGroupContainerURL else {
|
||||
return false
|
||||
@@ -76,7 +97,42 @@ public class SharedImageUtility {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
return FileManager.default.fileExists(atPath: fileURL.path)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||
* Read-only: does not modify App Group storage
|
||||
*/
|
||||
static func getShareExtensionDiagnostics() -> [String: Any] {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return [
|
||||
"shareExtensionLastStart": NSNull(),
|
||||
"sharedPhotoShareId": NSNull(),
|
||||
"sharedPhotoFilePath": NSNull(),
|
||||
"fileExists": false
|
||||
]
|
||||
}
|
||||
|
||||
let shareExtensionLastStart = userDefaults.string(forKey: shareExtensionLastStartKey)
|
||||
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey)
|
||||
let fileExists: Bool
|
||||
if let filePath = filePath, let containerURL = appGroupContainerURL {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
||||
} else {
|
||||
fileExists = false
|
||||
}
|
||||
|
||||
print("[ShareTarget] getShareExtensionDiagnostics shareExtensionLastStart=\(shareExtensionLastStart ?? "nil") sharedPhotoShareId=\(shareId ?? "nil") sharedPhotoFilePath=\(filePath ?? "nil") fileExists=\(fileExists)")
|
||||
|
||||
return [
|
||||
"shareExtensionLastStart": shareExtensionLastStart ?? NSNull(),
|
||||
"sharedPhotoShareId": shareId ?? NSNull(),
|
||||
"sharedPhotoFilePath": filePath ?? NSNull(),
|
||||
"fileExists": fileExists
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shared photo ready flag is set
|
||||
* This flag is set by the Share Extension when image is ready
|
||||
|
||||
@@ -9,135 +9,200 @@ import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||
private let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||
private let sharedImageFileName = "shared-image"
|
||||
|
||||
|
||||
/// Get the App Group container URL for storing shared files
|
||||
private var appGroupContainerURL: URL? {
|
||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
}
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) {
|
||||
let timestamp = ISO8601DateFormatter().string(from: Date())
|
||||
userDefaults.set(timestamp, forKey: shareExtensionLastStartKey)
|
||||
userDefaults.synchronize()
|
||||
print("[ShareTarget] shareExtensionLastStart=\(timestamp)")
|
||||
}
|
||||
|
||||
print("[ShareTarget] viewDidLoad started")
|
||||
super.viewDidLoad()
|
||||
|
||||
|
||||
// Set a minimal background (transparent or loading indicator)
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
|
||||
// Process image immediately without showing UI
|
||||
processAndOpenApp()
|
||||
print("[ShareTarget] viewDidLoad completed")
|
||||
}
|
||||
|
||||
|
||||
private func processAndOpenApp() {
|
||||
print("[ShareTarget] processAndOpenApp started")
|
||||
|
||||
// extensionContext is automatically available on UIViewController when used as extension principal class
|
||||
guard let context = extensionContext,
|
||||
let inputItems = context.inputItems as? [NSExtensionItem] else {
|
||||
print("[ShareTarget] processAndOpenApp failed: missing extensionContext or inputItems")
|
||||
print("[ShareTarget] completeRequest starting")
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
print("[ShareTarget] completeRequest completed")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let attachmentCount = inputItems.reduce(0) { count, item in
|
||||
count + (item.attachments?.count ?? 0)
|
||||
}
|
||||
print("[ShareTarget] processAndOpenApp inputItems=\(inputItems.count) attachmentCount=\(attachmentCount)")
|
||||
|
||||
processSharedImage(from: inputItems) { [weak self] success in
|
||||
guard let self = self, let context = self.extensionContext else {
|
||||
print("[ShareTarget] processAndOpenApp failed: self or extensionContext unavailable in completion")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if success {
|
||||
// Set flag that shared photo is ready
|
||||
self.setSharedPhotoReadyFlag()
|
||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
||||
self.openMainApp()
|
||||
} else {
|
||||
print("[ShareTarget] processAndOpenApp failed: processSharedImage returned false")
|
||||
}
|
||||
|
||||
|
||||
// Complete immediately - no UI shown
|
||||
print("[ShareTarget] completeRequest starting")
|
||||
context.completeRequest(returningItems: [], completionHandler: nil)
|
||||
print("[ShareTarget] completeRequest completed")
|
||||
print("[ShareTarget] processAndOpenApp completed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func setSharedPhotoReadyFlag() {
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag started")
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag failed: UserDefaults unavailable for app group")
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||
return
|
||||
}
|
||||
userDefaults.set(true, forKey: "sharedPhotoReady")
|
||||
userDefaults.synchronize()
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag success")
|
||||
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||
}
|
||||
|
||||
|
||||
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
||||
let attachmentCount = items.reduce(0) { count, item in
|
||||
count + (item.attachments?.count ?? 0)
|
||||
}
|
||||
print("[ShareTarget] processSharedImage started attachmentCount=\(attachmentCount)")
|
||||
|
||||
// Find the first image attachment
|
||||
for item in items {
|
||||
guard let attachments = item.attachments else {
|
||||
print("[ShareTarget] processSharedImage skipping item with no attachments")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
for attachment in attachments {
|
||||
// Skip non-image attachments
|
||||
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
||||
print("[ShareTarget] processSharedImage skipping non-image attachment")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
let shareId = UUID().uuidString
|
||||
print("[ShareTarget] processSharedImage found image attachment shareId=\(shareId) UTType=\(UTType.image.identifier)")
|
||||
print("[ShareTarget] share received shareId=\(shareId)")
|
||||
|
||||
// Try to load raw data first to preserve original format
|
||||
// This preserves the original image format without conversion
|
||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if error != nil {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName) {
|
||||
completion(true)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
guard let self = self else {
|
||||
print("[ShareTarget] processSharedImage failed: self unavailable in loadItem callback shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
return // Process only the first image
|
||||
|
||||
if let error = error {
|
||||
print("[ShareTarget] processSharedImage failed: loadItem error shareId=\(shareId) error=\(error.localizedDescription)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
print("[ShareTarget] processSharedImage loadItem returned URL shareId=\(shareId) url=\(url.lastPathComponent)")
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
print("[ShareTarget] processSharedImage using UIImage PNG fallback shareId=\(shareId)")
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
} else if imageData == nil {
|
||||
print("[ShareTarget] processSharedImage failed: could not read image data from URL shareId=\(shareId)")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
print("[ShareTarget] processSharedImage loadItem returned Data shareId=\(shareId)")
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
} else {
|
||||
print("[ShareTarget] processSharedImage failed: loadItem returned unexpected type shareId=\(shareId) type=\(String(describing: type(of: data)))")
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
print("[ShareTarget] processSharedImage failed: no image data shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
print("[ShareTarget] image loaded bytes=\(finalImageData.count) filename=\(fileName) shareId=\(shareId)")
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName, shareId: shareId) {
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=true")
|
||||
completion(true)
|
||||
} else {
|
||||
print("[ShareTarget] processSharedImage failed: storeImageData returned false shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
return // Process only the first image
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// No image found
|
||||
print("[ShareTarget] processSharedImage failed: no image attachment found attachmentCount=\(attachmentCount)")
|
||||
print("[ShareTarget] processSharedImage completed success=false")
|
||||
completion(false)
|
||||
}
|
||||
|
||||
|
||||
/// Helper to get filename with a new extension, preserving base name
|
||||
private func getFileNameWithExtension(_ originalName: String, newExtension: String) -> String {
|
||||
if let nameWithoutExt = originalName.components(separatedBy: ".").first, !nameWithoutExt.isEmpty {
|
||||
@@ -145,63 +210,98 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
return "shared-image.\(newExtension)"
|
||||
}
|
||||
|
||||
|
||||
/// Extract file extension from original filename, defaulting to jpg when absent
|
||||
private func fileExtension(from fileName: String) -> String {
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
return ext.isEmpty ? "jpg" : ext.lowercased()
|
||||
}
|
||||
|
||||
/// Build unique on-disk filename: <shareId>.<extension>
|
||||
private func storedFileName(shareId: String, originalFileName: String) -> String {
|
||||
return "\(shareId).\(fileExtension(from: originalFileName))"
|
||||
}
|
||||
|
||||
/// Store image data as a file in the App Group container
|
||||
/// All images are stored as files regardless of size for consistency and simplicity
|
||||
/// Returns true if successful, false otherwise
|
||||
private func storeImageData(_ imageData: Data, fileName: String) -> Bool {
|
||||
private func storeImageData(_ imageData: Data, fileName: String, shareId: String) -> Bool {
|
||||
print("[ShareTarget] storeImageData started shareId=\(shareId) bytes=\(imageData.count) filename=\(fileName)")
|
||||
|
||||
guard let containerURL = appGroupContainerURL else {
|
||||
print("[ShareTarget] storeImageData failed: app group container unavailable shareId=\(shareId)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create file URL in the container using the actual filename
|
||||
// Extract extension from fileName if present, otherwise use sharedImageFileName
|
||||
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
|
||||
let fileURL = containerURL.appendingPathComponent(actualFileName)
|
||||
|
||||
// Remove old file if it exists
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
|
||||
let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
|
||||
let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
|
||||
let fileURL = containerURL.appendingPathComponent(storedFileName)
|
||||
|
||||
// Remove previously pending share file (metadata tracks one share at a time)
|
||||
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let previousPath = userDefaults.string(forKey: sharedPhotoFilePathKey) {
|
||||
let previousURL = containerURL.appendingPathComponent(previousPath)
|
||||
if previousURL != fileURL {
|
||||
try? FileManager.default.removeItem(at: previousURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Write image data to file
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
} catch {
|
||||
print("[ShareTarget] storeImageData failed: file write error shareId=\(shareId) error=\(error.localizedDescription)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||
return false
|
||||
}
|
||||
|
||||
print("[ShareTarget] file stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
|
||||
|
||||
// Store file path and filename in UserDefaults (small data, safe to store)
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
print("[ShareTarget] storeImageData failed: UserDefaults unavailable shareId=\(shareId)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||
return false
|
||||
}
|
||||
|
||||
// Store relative path and filename
|
||||
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
|
||||
|
||||
|
||||
// sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name
|
||||
userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey)
|
||||
userDefaults.set(shareId, forKey: sharedPhotoShareIdKey)
|
||||
|
||||
// Clean up any old base64 data that might exist
|
||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||
|
||||
|
||||
userDefaults.synchronize()
|
||||
print("[ShareTarget] metadata stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
|
||||
print("[ShareTarget] storeImageData success shareId=\(shareId)")
|
||||
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=true")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
private func openMainApp() {
|
||||
print("[ShareTarget] openMainApp starting")
|
||||
|
||||
// Open the main app with minimal URL - app will detect shared data on activation
|
||||
guard let url = URL(string: "timesafari://") else {
|
||||
print("[ShareTarget] openMainApp failed: could not create timesafari:// URL")
|
||||
print("[ShareTarget] openMainApp completed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
application.open(url, options: [:], completionHandler: nil)
|
||||
print("[ShareTarget] openMainApp completed via UIApplication")
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
|
||||
|
||||
// Fallback: use extension context
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
print("[ShareTarget] openMainApp completed via extensionContext fallback")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "giftopia",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "giftopia",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "^7.0.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
{
|
||||
"name": "giftopia",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.3",
|
||||
"description": "Giftopia App",
|
||||
"author": {
|
||||
"name": "Gift Economies Team"
|
||||
|
||||
@@ -359,6 +359,23 @@ async function checkForSharedImageAndNavigate() {
|
||||
}
|
||||
|
||||
try {
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
if (Capacitor.getPlatform() === "ios") {
|
||||
try {
|
||||
const diagnostics = await SharedImage.getShareExtensionDiagnostics();
|
||||
logger.info(`[ShareTarget] Diagnostics ${JSON.stringify(diagnostics)}`);
|
||||
} catch (diagnosticsError) {
|
||||
logger.info(
|
||||
`[ShareTarget] Diagnostics ${JSON.stringify({
|
||||
error:
|
||||
diagnosticsError instanceof Error
|
||||
? diagnosticsError.message
|
||||
: String(diagnosticsError),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("[Main] 🔍 Checking for shared image on app activation");
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { WebPlugin } from "@capacitor/core";
|
||||
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
|
||||
import type {
|
||||
SharedImagePlugin,
|
||||
SharedImageResult,
|
||||
ShareExtensionDiagnostics,
|
||||
} from "./definitions";
|
||||
|
||||
export class SharedImagePluginWeb
|
||||
extends WebPlugin
|
||||
@@ -18,4 +22,13 @@ export class SharedImagePluginWeb
|
||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||
return { hasImage: false };
|
||||
}
|
||||
|
||||
async getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics> {
|
||||
return {
|
||||
shareExtensionLastStart: null,
|
||||
sharedPhotoShareId: null,
|
||||
sharedPhotoFilePath: null,
|
||||
fileExists: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,18 @@ export interface SharedImageResult {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface ShareExtensionDiagnostics {
|
||||
shareExtensionLastStart: string | null;
|
||||
sharedPhotoShareId: string | null;
|
||||
sharedPhotoFilePath: string | null;
|
||||
fileExists: boolean;
|
||||
}
|
||||
|
||||
export interface SharedImagePlugin {
|
||||
/**
|
||||
* Get shared image data from native layer
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only on iOS: native metadata and file are left intact after retrieval (Phase 1C)
|
||||
*/
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
|
||||
@@ -20,4 +27,9 @@ export interface SharedImagePlugin {
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state (iOS)
|
||||
*/
|
||||
getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user