Compare commits

..

8 Commits

Author SHA1 Message Date
Jose Olarte III
402bd2681f chore(ios): log share extension diagnostics on startup (temporary)
Call getShareExtensionDiagnostics() during the iOS shared-image startup
check and print the result to Xcode logs for share-target investigation.
2026-06-24 19:43:27 +08:00
Jose Olarte III
498a4926bf feat(ios): add Share Extension startup diagnostic marker and API
Write shareExtensionLastStart on ShareViewController.viewDidLoad and
expose getShareExtensionDiagnostics() through SharedImagePlugin with
shareId, file path, and fileExists for debugging failed share flows.
2026-06-24 19:38:25 +08:00
Jose Olarte III
f0ca49b5dc feat(ios): add diagnostic logging to Share Extension share flow
Log start/end, shareId, attachment counts, UTType, byte counts, filenames,
and every early return across viewDidLoad through completeRequest to trace
how far a share progresses when debugging failures.
2026-06-24 19:28:09 +08:00
Jose Olarte III
07463246f0 feat(ios): add share-target diagnostic logging in SharedImageUtility
Log shareId, sharedPhotoFilePath, metadataExists, and fileExists at the
start of getSharedImageData() and hasSharedImage() to debug pending
App Group shares without changing retrieval behavior.
2026-06-24 17:19:54 +08:00
Jose Olarte III
79ceebbd1d feat(ios): make shared image retrieval non-destructive (Phase 1C)
Stop deleting App Group metadata and image files in getSharedImageData()
so retrieval is read-only while preserving the existing plugin API shape.
Document removed deletion paths in the iOS share target audit.
2026-06-24 16:49:45 +08:00
Jose Olarte III
ddbd07f315 feat(ios): use UUID-based filenames for shared images (Phase 1B)
Store shared images as <shareId>.<ext> in the App Group container while
keeping the original filename in metadata, preventing on-disk collisions
without changing retrieval, deletion, or JS consumer behavior.
2026-06-23 19:37:11 +08:00
Jose Olarte III
35a6a6bfb3 feat(ios): add share ID tracking for share target (Phase 1A)
Generate a UUID per incoming share in the Share Extension, persist it
as sharedPhotoShareId in App Group metadata, and add [ShareTarget] logs
for receive/store/retrieve events without changing retrieval or deletion.
2026-06-23 19:13:44 +08:00
Jose Olarte III
08a55202f5 docs(ios): add share target implementation audit
Document the Share Extension → App Group → main app flow, including
read/write/delete points, startup detection hooks, timing behavior,
and race conditions to support share-target reliability work.
2026-06-23 17:20:53 +08:00
17 changed files with 783 additions and 538 deletions

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -1 +0,0 @@
@AGENTS.md

View File

@@ -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")
}

View File

@@ -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`.

View File

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

View 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 14 can all fire for a single share event. Only the JS paths (#24) 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` |

View File

@@ -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)";

View File

@@ -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())
}
}

View File

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

View File

@@ -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
View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "giftopia",
"version": "1.4.4",
"version": "1.4.3",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"

View File

@@ -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();

View File

@@ -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,
};
}
}

View File

@@ -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>;
}