Compare commits

..

31 Commits

Author SHA1 Message Date
Jose Olarte III
85ac6632ed fix(ios): resolve getSharedImage() with null when no share pending
When no shared image exists, return a bare Capacitor resolve instead of
an object with null base64/fileName/shareId fields, matching the
TypeScript contract and web implementation.
2026-07-01 17:04:45 +08:00
Jose Olarte III
0c91b687f9 feat(ios): clean up native share storage after import (Phase 1F)
Add SharedImageUtility.clearProcessedShare() to permanently delete the
shared image file and remove all App Group metadata (sharedPhotoFileName,
sharedPhotoFilePath, sharedPhotoShareId, sharedPhotoReady,
sharedPhotoProcessedShareId). The helper is idempotent and logs cleanup
started/completed plus a warning on file-deletion failure. Expose it through
the SharedImage plugin with a web no-op stub.

Wire cleanup into the import flow so it runs only after both
storeSharedImageInTempDB() and markProcessed() succeed; a cleanup failure is
logged but does not fail the already-successful import. After cleanup a
subsequent getSharedImage() finds no pending share. iOS only; Android, the
Share Extension, and Phase 1E duplicate suppression are unchanged.
2026-06-30 22:17:37 +08:00
Jose Olarte III
7093f38e09 feat(ios): skip already-processed shares in getSharedImageData (Phase 1E)
Return nil from getSharedImageData() when the current sharedPhotoShareId
equals the stored sharedPhotoProcessedShareId, so a share that was already
marked processed is no longer re-imported on subsequent app activations.

The check runs before reading the image file and logs
"[ShareTarget] shareId=<id> already processed; skipping". It leaves the
image file and all metadata intact (cleanup is deferred to a later phase),
and does not change behavior for brand-new shares. iOS only; no changes to
markProcessed(), the Share Extension, Android, or the JS flow.
2026-06-30 22:05:29 +08:00
Jose Olarte III
cb748f9105 feat(ios): mark shares processed after successful import
Call SharedImage.markProcessed(shareId) from the share import workflow once
a shared image has been successfully stored in the temp DB. The call is
gated to iOS and a non-empty shareId, and a mark-processed failure is logged
as a warning without failing the already-successful import.

Add explicit logging for processing started/succeeded/failed and
markProcessed invoked. On a storage failure the share is left intact and not
marked processed, so it remains available for retry. Android is unchanged.
2026-06-30 20:36:09 +08:00
Jose Olarte III
a3e9a0104d feat(ios): expose shareId from getSharedImage()
Include the existing native sharedPhotoShareId in the SharedImage
getSharedImage() result so the web layer can identify the current share.
Add shareId to the native result dictionary and plugin response (NSNull on
the no-image branch), and add shareId: string to the TypeScript
SharedImageResult interface.

This surfaces a value that was already produced natively (previously used
only for logging) so application code can later pass it to markProcessed().
No behavior changes beyond exposing the value; Android untouched. Updates
doc/share-target-ios-audit.md accordingly.
2026-06-30 11:02:59 +08:00
Jose Olarte III
981f8b77d0 feat(ios): add markProcessed share-completion API (Phase 1D)
Add a native iOS markProcessed(shareId) method to the SharedImage plugin
that records an explicit completion signal in App Group UserDefaults
(sharedPhotoProcessedShareId) and logs "[ShareTarget] shareId=<id> marked
processed". The API is exposed to TypeScript via the plugin definitions and
a web no-op stub.

Deliberately does not delete files or clear share metadata (cleanup is
deferred to a later phase), is not yet called from application code, and
does not touch Android. Documents the share-target lifecycle and plugin API
in doc/share-target-ios-audit.md.
2026-06-30 11:00:52 +08:00
Jose Olarte III
125406a54c docs(ios): clean up AppBridgeViewController registration comments
Replace the transitional Phase 2B-1 comments (which referenced a temporary
AppDelegate registration safety net) with a concise description of the
plugin registration now that it is finalized. Comment-only change; plugin
registration behavior is unchanged.
2026-06-30 10:30:30 +08:00
Jose Olarte III
c6d5876da3 refactor(ios): remove orphaned Share Extension diagnostics API
Remove the getShareExtensionDiagnostics() API now that the share-target
investigation is complete and it has no remaining callers.

- Drop getShareExtensionDiagnostics from the plugin definition, web stub,
  Swift plugin method + registration, and SharedImageUtility implementation
- Remove the ShareExtensionDiagnostics TypeScript interface and its now-dead
  import in the web stub
- Remove the diagnostics-only [ShareTarget] log line and the now-unused
  shareExtensionLastStartKey constant in SharedImageUtility

getSharedImage/hasSharedImage, SharedPhotoReady logic, ShareViewController,
AppDelegate, and Android are unchanged. TypeScript type-check passes.
2026-06-29 18:12:35 +08:00
Jose Olarte III
0a70914d0c docs(ios): remove completed share-target investigation audits
Remove the point-in-time, read-only audit documents produced during the
iOS share-target investigation. Each was a "no code modified" snapshot and
is no longer needed now that the investigation is complete. None were
referenced by other docs or code.

- doc/share-target-ios-audit.md
- doc/share-target-ios-launch-flow-audit.md
- doc/share-extension-configuration-audit.md
- doc/share-extension-app-group-audit.md

Permanent implementation, setup, design, and decision docs are retained.
2026-06-29 18:00:31 +08:00
Jose Olarte III
b48ab3dc42 chore(ios): remove temporary cold-start share diagnostics
Remove the temporary diagnostics added during the iOS share-target
investigation, tagged with the TEMPORARY SHARE TARGET DIAGNOSTICS marker.

- Drop the pendingShareExists field/computation from ShareExtensionDiagnostics
  across the Swift utility, TS definitions, and web stub
- Remove the cold-start diagnostics block in main.capacitor.ts (the iOS
  getShareExtensionDiagnostics call and [ShareTarget] Cold-start state dump)
- Drop pendingShareExists from the getShareExtensionDiagnostics log line

Kept getShareExtensionDiagnostics itself (unmarked plugin API + registration),
the unmarked [ShareTarget] print logging, share-target functionality, and
Android code unchanged.
2026-06-29 17:55:55 +08:00
Jose Olarte III
43328e9d4b chore(ios): remove temporary Share Target trace diagnostics
Remove the native trace infrastructure that was added solely for the iOS
share-target investigation. This reverts the temporary share-extension and
app-launch trace logging along with the APIs and debug UI built on top of it.

- Remove getShareExtensionTrace/clearShareExtensionTrace/getAppLaunchTrace/
  clearAppLaunchTrace from the native plugin, TS definitions, and web stubs
- Remove appendTrace (ShareViewController) and appendAppLaunchTrace
  (SharedImageUtility) implementations and all call sites
- Drop the share-extension-trace.log / app-launch-trace.log file handling
- Delete ShareTargetDebugView, its /share-target-debug route, and the
  TestView entry point
- Remove now-unused AppDelegate isTimesafariURL helper and launchURL local
- Strip trace dump/alert logic from main.capacitor.ts

Share-target behavior, plugin registration, image storage, SharedPhotoReady
logic, Android, and the share-extension diagnostics snapshot are unchanged.
2026-06-29 17:43:17 +08:00
Jose Olarte III
02e6e3427d refactor(ios): remove SharedImage plugin-readiness polling (Phase 2B-3)
The SharedImage plugin is now registered deterministically from
AppBridgeViewController.capacitorDidLoad() before the web layer loads, so
JS no longer needs to wait for it. Remove the readiness machinery that
existed solely to tolerate the old async AppDelegate registration:
waitForSharedImagePluginReady(), the sleep() helper, the
STARTUP_PLUGIN_MAX_ATTEMPTS/RETRY_DELAY_MS constants and retry loop, and
the three readiness-only console diagnostics (waiting / ready after N /
giving up).

The iOS startup branch now calls checkForSharedImageAndNavigate()
immediately. All other share-target behavior is unchanged: cold-start,
extension, and launch diagnostics; native trace APIs; the Share Target
Debug Panel; appStateChange/appUrlOpen handling; actual shared-image
handling; and the Android startup path. No JS readiness diagnostics
remain; no Android changes.
2026-06-26 21:14:59 +08:00
Jose Olarte III
337a8f7536 refactor(ios): remove AppDelegate SharedImage registration (Phase 2B-2)
Now that AppBridgeViewController registers SharedImagePlugin from
capacitorDidLoad(), remove the obsolete plugin-registration logic from
AppDelegate: the 5-attempt retry loop in didFinishLaunchingWithOptions,
the registerSharedImagePlugin() method, and the registration-specific
logging. SharedImagePlugin is now registered from a single, deterministic
site.

All non-registration responsibilities are unchanged: notification
handling, URL and universal-link proxying, SharedPhotoReady activation
logic, every lifecycle callback, the temporary app launch tracing, and
all temporary share-target diagnostics remain intact. No JS or Android
changes.
2026-06-26 19:35:43 +08:00
Jose Olarte III
4978e93711 feat(ios): register SharedImage via CAPBridgeViewController subclass (Phase 2B-1)
Introduce AppBridgeViewController, a CAPBridgeViewController subclass that
registers the app-local SharedImagePlugin from capacitorDidLoad(), where
the Capacitor bridge is guaranteed to exist. This is the first step toward
replacing AppDelegate's timed retry registration with a deterministic,
bridge-ready registration point.

Point the existing root bridge controller in Main.storyboard at
AppBridgeViewController (same VC id/scene; only the custom class changes)
and add the new file to the App target in project.pbxproj.

The existing AppDelegate registration and its retry loop are intentionally
left in place as a temporary safety net, so the plugin is now registered
from both sites during this phase. No diagnostics, retry logic, JS, or
Android code changed; plugin name ("SharedImage") and implementation are
unchanged.
2026-06-26 19:26:00 +08:00
Jose Olarte III
9941264022 chore(ios): add temporary Share Target debug panel (temporary)
Add a dedicated, temporary debug view for the iOS Share Target
investigation so native traces can be inspected without attaching Xcode.
Kept separate from the Notification/Notiwind test panel so it is easy to
remove once the investigation is complete.

The panel (src/views/ShareTargetDebugView.vue, route
/share-target-debug) has three buttons:
- Dump Native Traces: calls SharedImage.getShareExtensionTrace() and
  getAppLaunchTrace(), logs both in full (untruncated) between
  EXTENSION TRACE START/END and APP LAUNCH TRACE START/END markers, and
  shows them in read-only scrollable fields.
- Clear Share Extension Trace: clears the native log and the field.
- Clear App Launch Trace: clears the native log and the field.

Register the route alongside the existing /test debug route and link to
the panel from TestView. No share-target behavior changed; Android
untouched. All additions marked TEMPORARY SHARE TARGET DIAGNOSTICS.
2026-06-26 18:10:08 +08:00
Jose Olarte III
256018d30d chore(ios): add native app launch lifecycle trace diagnostics (temporary)
Add temporary, append-only tracing of the native iOS application launch
lifecycle to diagnose failed cold-start shares when Xcode is not
attached during launch. Writes app-launch-trace.log in the same App
Group container as share-extension-trace.log, reusing the same style
(ISO8601 timestamps, append-only, all logging failures swallowed).

AppDelegate now traces every lifecycle callback
(didFinishLaunchingWithOptions, application(open:),
applicationDidBecomeActive, applicationWill/DidEnterForeground/Background,
applicationWillResignActive, applicationWillTerminate, and
checkForSharedImageOnActivation), recording the callback name, whether a
URL was supplied, the URL value, whether it matches timesafari://,
whether launch options carried a URL, and whether shared-image
activation ran.

Expose read-only getAppLaunchTrace()/clearAppLaunchTrace() plugin APIs
(mirroring the share-extension trace APIs) with matching TypeScript
definitions and web stubs. main.capacitor.ts logs the full launch trace
between APP LAUNCH TRACE START/END markers inside the existing
diagnostics block.

No share-target behavior changed; Android untouched. All additions are
marked TEMPORARY SHARE TARGET DIAGNOSTICS.
2026-06-26 17:06:50 +08:00
Jose Olarte III
c1a5bae5c8 feat(ios): wait for SharedImage plugin before initial startup check (Phase 2A)
Eliminate the iOS startup race between asynchronous SharedImage plugin
registration and the first shared-image check. Previously the initial
check fired on a fixed 1000ms timer that only assumed the native plugin
(registered by AppDelegate with retries from T+500ms) was ready, so a
slow registration could make the first getSharedImage() call throw and
miss a cold-start share.

Replace the fixed delay with waitForSharedImagePluginReady(), which
probes the plugin via a read-only hasSharedImage() call and retries
within a bounded budget (10 attempts, 300ms apart) until the plugin
actually responds. The initial check runs only once readiness is
confirmed, with a best-effort fallback if the budget is exhausted.

Scope is limited to the initial startup check on iOS. appStateChange,
appUrlOpen/handleDeepLink, router navigation, share processing, Android
behavior, and all native Swift code are unchanged. Temporary
share-target diagnostics are preserved and extended with startup
readiness logging.

Document the change as a Phase 2A section in
doc/share-target-ios-audit.md.
2026-06-26 16:48:00 +08:00
Jose Olarte III
c9061e669e docs(ios): add share-target launch and deep-link flow audit
Add doc/share-target-ios-launch-flow-audit.md documenting the iOS
Share Extension launch path from openMainApp("timesafari://") through
the native AppDelegate, Capacitor bridge, and JS bootstrap to
/shared-photo navigation.

Covers cold- vs warm-start execution order, the single appUrlOpen
listener and its call graph, all shared-image detection mechanisms
(startup timer, appStateChange, applicationDidBecomeActive,
sharedPhotoReady flag, SharedPhotoReady notification), native launch-URL
handling, and a timing analysis of the race conditions.

Key findings: cold-start shares rely on the T+1000ms startup timer
reading the App Group payload (not appUrlOpen, which registers at
T+2000ms after the launch URL is delivered); warm-start shares are
driven by appUrlOpen/appStateChange; the launch URL event and the
SharedPhotoReady notification can be lost before JS is ready, but the
durable App Group payload is not. Audit only; no code changes.
2026-06-26 15:43:42 +08:00
Jose Olarte III
7b1fec779b chore(ios): log full Share Extension trace on startup (temporary)
Surface the complete Share Extension execution trace during app startup
so the cold-start share path can be inspected even when Xcode truncates
console output.

In checkForSharedImageAndNavigate(), after the existing diagnostics
call, retrieve SharedImage.getShareExtensionTrace() and:
- log the full untruncated trace between TRACE FULL START/END markers
- show the full trace in a user-visible alert (iOS only)
- log TRACE LENGTH and the first 500 characters

Share-target behavior is unchanged and the trace work is gated to iOS,
so Android is unaffected. All additions are marked with
"TEMPORARY SHARE TARGET DIAGNOSTICS".
2026-06-25 19:57:42 +08:00
Jose Olarte III
d1106d9aec chore(ios): add Share Extension execution trace diagnostics (temporary)
Add a lightweight, temporary trace logger to diagnose where the iOS
Share Extension stops executing during a failed cold-start share.

- ShareViewController: add appendTrace() helper that writes ISO8601-
  prefixed lines to share-extension-trace.log in the App Group
  container, ignoring failures (diagnostics only)
- Add trace entries across the share flow: viewDidLoad,
  processAndOpenApp, processSharedImage, image attachment/load,
  storeImageData, setSharedPhotoReadyFlag, openMainApp, completeRequest
- SharedImageUtility: add getShareExtensionTrace() (read-only) and
  clearShareExtensionTrace() (deletes the trace file)
- SharedImagePlugin: expose getShareExtensionTrace() and
  clearShareExtensionTrace() to JS
- definitions.ts / SharedImagePlugin.web.ts: add ShareExtensionTrace
  type, method signatures, and web stubs

Share behavior is unchanged and Android is untouched. All additions are
marked with "TEMPORARY SHARE TARGET DIAGNOSTICS".
2026-06-25 19:10:57 +08:00
Jose Olarte III
6f7be2e3b2 chore(ios): add pendingShareExists cold-start share diagnostics (temporary)
Extend ShareExtensionDiagnostics with pendingShareExists across
SharedImageUtility, definitions, and the web stub, and log cold-start
state (pending share + current route) in the iOS startup share check.
2026-06-25 18:37:04 +08:00
Jose Olarte III
4fc30562fb docs(ios): add App Group configuration audit for share extension
Document App/extension entitlements, bundle IDs, teams, and signing,
flagging a code-vs-entitlement App Group ID mismatch (code uses
group.app.timesafari.share while entitlements grant
group.app.trentlarson.timesafari.share) that breaks shared storage.
2026-06-25 17:48:53 +08:00
Jose Olarte III
6afe40bc23 docs(ios): add Share Extension configuration audit
Document that TimeSafariShareExtension is a code-based extension with
ShareViewController as NSExtensionPrincipalClass, confirm viewDidLoad
executes on launch, and note no blocking Info.plist/storyboard mismatches.
2026-06-25 17:28:20 +08:00
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
21 changed files with 629 additions and 637 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,92 @@
# iOS Share Target Audit
**Date:** 2026-06-30
**Platform:** iOS only (Android unchanged)
**Status:** Phase 1D complete — explicit share completion API added (not yet called)
## Purpose
This document tracks the native iOS share-target lifecycle and the
Capacitor API surface exposed to the web/TypeScript layer. It is updated as
each phase lands.
## Lifecycle Overview
1. The Share Extension (`ShareViewController.swift`) receives an image, writes
it to the App Group container, records metadata in App Group
`UserDefaults`, assigns a `shareId` (UUID), sets the `sharedPhotoReady`
flag, and opens the main app via `timesafari://`.
2. The main app reads the shared image through the `SharedImage` Capacitor
plugin.
3. The application layer processes the image, then (in a future phase) signals
explicit completion via `markProcessed(shareId)`.
## App Group Storage Keys
Stored in `UserDefaults(suiteName: "group.app.trentlarson.timesafari.share")`:
| Key | Set by | Meaning |
| --- | --- | --- |
| `sharedPhotoFileName` | Share Extension | Original display filename |
| `sharedPhotoFilePath` | Share Extension | On-disk filename in App Group container |
| `sharedPhotoShareId` | Share Extension | Current pending share identifier (UUID) |
| `sharedPhotoReady` | Share Extension | Flag indicating a share is ready |
| `sharedPhotoProcessedShareId` | Main app (`markProcessed`) | Last shareId explicitly marked processed (Phase 1D) |
## Capacitor API (`SharedImage` plugin)
The plugin is registered as `SharedImage` and is implemented in:
- Native iOS: `ios/App/App/SharedImagePlugin.swift` (delegates to
`ios/App/App/SharedImageUtility.swift`)
- Web stub: `src/plugins/SharedImagePlugin.web.ts`
- TypeScript definitions: `src/plugins/definitions.ts`
### `getSharedImage(): Promise<SharedImageResult | null>`
Returns the shared image as `{ base64, fileName, shareId }`, or `null` if none
exists. `shareId` is the native `sharedPhotoShareId` (UUID assigned by the
Share Extension); pass it to `markProcessed()` to signal explicit completion.
Read-only: native metadata and the file are left intact after retrieval
(Phase 1C).
> Note: On iOS the "no shared image" case resolves an object with `base64`,
> `fileName`, and `shareId` set to `null`; on web `getSharedImage()` resolves
> `null` directly.
### `hasSharedImage(): Promise<{ hasImage: boolean }>`
Returns whether a shared image file currently exists, without reading it.
### `markProcessed(options: { shareId: string }): Promise<{ success: boolean }>` — Phase 1D
Explicit completion signal from the application layer for a given share.
- **Input:** `shareId` — the identifier reported by `getSharedImage()`.
Must be a non-empty string; otherwise the call rejects.
- **Behavior (iOS):** Records `shareId` in App Group `UserDefaults` under
`sharedPhotoProcessedShareId` and resolves with `{ success: true }`.
- **Does NOT delete files:** The stored image file and share metadata are
intentionally left intact. File cleanup is deferred to a later phase.
- **Web:** No-op; resolves with `{ success: false }`.
- **Logging:** Emits `[ShareTarget] shareId=<id> marked processed` on success.
> Note (Phase 1D): `markProcessed` is implemented and exposed to TypeScript
> but is **not yet called** from application code. Wiring it into the
> processing flow is a later phase.
## Native Logging Reference
Relevant `[ShareTarget]` log lines emitted on iOS:
- `[ShareTarget] shareId=<id> retrieved`
- `[ShareTarget] shareId=<id> left intact after retrieval`
- `[ShareTarget] shareId=<id> marked processed` (Phase 1D)
## Out of Scope
- Android: no changes. The Android plugin
(`android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java`)
does not implement `markProcessed`.
- File deletion / cleanup of processed shares.
- Calling `markProcessed` from application code.

View File

@@ -18,6 +18,7 @@
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
C8C56E182EE0700A00737D0E /* AppBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBridgeViewController.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -74,18 +76,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -138,6 +129,7 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */,
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
@@ -357,6 +349,7 @@
buildActionMask = 2147483647;
files = (
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
C8C56E182EE0700A00737D0E /* AppBridgeViewController.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
);
@@ -524,7 +517,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 +528,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 +546,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 +557,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 +575,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 +589,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 +613,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 +627,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

@@ -0,0 +1,24 @@
//
// AppBridgeViewController.swift
// App
//
// Capacitor bridge view controller subclass.
//
// Registers the app-local SharedImagePlugin once the Capacitor bridge
// has been created. This provides deterministic plugin registration
// before the web layer starts executing.
//
import UIKit
import Capacitor
class AppBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
let pluginInstance = SharedImagePlugin()
bridge?.registerPluginInstance(pluginInstance)
print("[AppBridgeViewController] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
}
}

View File

@@ -16,50 +16,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
//let sqlite = SQLite()
//sqlite.initialize()
// Register SharedImage plugin manually after bridge is ready
// Try multiple times with increasing delays to ensure bridge is initialized
var attempts = 0
let maxAttempts = 5
func tryRegister() {
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
}
} else {
print("[AppDelegate] ⚠️ Failed to register plugin after \(maxAttempts) attempts")
}
}
// Start registration attempts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
tryRegister()
}
// SharedImagePlugin is registered from AppBridgeViewController.capacitorDidLoad()
// (Phase 2B-2). The previous AppDelegate retry-based registration was removed.
// Override point for customization after application launch.
return true
}
@discardableResult
private func registerSharedImagePlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
// Create plugin instance
// The @objc(SharedImage) annotation makes it available as "SharedImage" to Objective-C
// which matches the JavaScript registration name
let pluginInstance = SharedImagePlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -116,7 +79,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
*/
private func checkForSharedImageOnActivation() {
// Check if shared photo is ready
if SharedImageUtility.isSharedPhotoReady() {
let isReady = SharedImageUtility.isSharedPhotoReady()
if isReady {
// Clear the flag
SharedImageUtility.clearSharedPhotoReadyFlag()

View File

@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="AppBridgeViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>

View File

@@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
@@ -18,6 +25,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@@ -26,6 +44,13 @@
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Time Safari allows you to upload photos.</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -47,30 +72,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
</dict>
</plist>

View File

@@ -24,7 +24,9 @@ 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(markProcessed(_:)), returnType: .promise),
CAPPluginMethod(#selector(clearProcessedShare(_:)), returnType: .promise)
]
}
@@ -32,23 +34,21 @@ 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
* Returns base64 string, fileName, and shareId, or null if no image exists
* 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 {
// No shared image exists - return null (not an error)
call.resolve([
"base64": NSNull(),
"fileName": NSNull()
])
call.resolve()
return
}
// Return the shared image data
call.resolve([
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
"fileName": sharedData["fileName"] ?? "",
"shareId": sharedData["shareId"] ?? ""
])
}
@@ -62,5 +62,34 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
"hasImage": hasImage
])
}
/**
* Mark a share as processed by the application layer (Phase 1D)
* Records an explicit completion signal for the given shareId.
* Does NOT delete the stored image file - file cleanup is deferred to a later phase.
*/
@objc public func markProcessed(_ call: CAPPluginCall) {
guard let shareId = call.getString("shareId"), !shareId.isEmpty else {
call.reject("markProcessed requires a non-empty 'shareId'")
return
}
let success = SharedImageUtility.markProcessed(shareId: shareId)
call.resolve([
"success": success
])
}
/**
* Permanently remove the currently processed share from App Group storage (Phase 1F)
* Deletes the image file and all share metadata. Idempotent.
* Intended to be called only after successful processing and markProcessed().
*/
@objc public func clearProcessedShare(_ call: CAPPluginCall) {
let success = SharedImageUtility.clearProcessedShare()
call.resolve([
"success": success
])
}
}

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 sharedPhotoReadyKey = "sharedPhotoReady"
private static let sharedPhotoProcessedShareIdKey = "sharedPhotoProcessedShareId"
/// 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
* @returns Dictionary with "base64", "fileName", and "shareId" 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,6 +61,20 @@ public class SharedImageUtility {
}
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
// Phase 1E: skip a share that has already been marked processed.
// If the current pending shareId matches the processed marker, treat it
// as if no pending share exists. Leave the file and metadata intact -
// cleanup is deferred to a later phase.
let processedShareId = userDefaults.string(forKey: sharedPhotoProcessedShareIdKey)
if let shareId = shareId,
let processedShareId = processedShareId,
shareId == processedShareId {
print("[ShareTarget] shareId=\(shareId) already processed; skipping")
return nil
}
let fileURL = containerURL.appendingPathComponent(filePath)
// Read image data from file
@@ -46,19 +82,89 @@ public class SharedImageUtility {
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)
return ["base64": base64String, "fileName": fileName, "shareId": resolvedShareId]
}
// Remove the file
try? FileManager.default.removeItem(at: fileURL)
/**
* Mark a share as processed by the web/application layer (Phase 1D).
*
* Records the processed shareId in the App Group UserDefaults so that
* later phases can decide whether retrieval/cleanup should be skipped.
* This is an explicit completion signal: it does NOT delete the stored
* image file or clear the share metadata. File cleanup is deferred to a
* later phase.
*
* @param shareId The share identifier reported by getSharedImage()
* @returns true if the processed marker was recorded, false otherwise
*/
@discardableResult
static func markProcessed(shareId: String) -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("[ShareTarget] shareId=\(shareId) markProcessed failed: UserDefaults unavailable for app group")
return false
}
userDefaults.set(shareId, forKey: sharedPhotoProcessedShareIdKey)
userDefaults.synchronize()
return ["base64": base64String, "fileName": fileName]
print("[ShareTarget] shareId=\(shareId) marked processed")
return true
}
/**
* Permanently remove the currently processed share from App Group storage (Phase 1F).
*
* Deletes the shared image file (if present) and removes all share metadata
* keys. Idempotent: missing files or keys are treated as success. Intended
* to be called only after the share has been successfully handed off to the
* app and marked processed.
*
* @returns true if cleanup completed without error, false if file deletion failed
*/
@discardableResult
static func clearProcessedShare() -> Bool {
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
let shareId = userDefaults?.string(forKey: sharedPhotoShareIdKey)
?? userDefaults?.string(forKey: sharedPhotoProcessedShareIdKey)
?? "unknown"
print("[ShareTarget] shareId=\(shareId) cleanup started")
var success = true
// Delete the shared image file if it exists
if let filePath = userDefaults?.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL {
let fileURL = containerURL.appendingPathComponent(filePath)
if FileManager.default.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.removeItem(at: fileURL)
} catch {
success = false
print("[ShareTarget] shareId=\(shareId) cleanup warning: failed to delete file error=\(error.localizedDescription)")
}
}
}
// Remove all share metadata keys (removeObject is a no-op when absent)
if let userDefaults = userDefaults {
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
userDefaults.removeObject(forKey: sharedPhotoShareIdKey)
userDefaults.removeObject(forKey: sharedPhotoReadyKey)
userDefaults.removeObject(forKey: sharedPhotoProcessedShareIdKey)
userDefaults.synchronize()
}
print("[ShareTarget] shareId=\(shareId) cleanup completed")
return success
}
/**
@@ -67,7 +173,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

View File

@@ -13,6 +13,8 @@ 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
@@ -21,6 +23,14 @@ class ShareViewController: UIViewController {
}
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)
@@ -28,18 +38,32 @@ class ShareViewController: UIViewController {
// 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
}
@@ -48,93 +72,134 @@ class ShareViewController: UIViewController {
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)
}
@@ -146,48 +211,81 @@ 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)
let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
let fileURL = containerURL.appendingPathComponent(storedFileName)
// Remove old file if it exists
try? FileManager.default.removeItem(at: fileURL)
// 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
}
@@ -195,6 +293,7 @@ class ShareViewController: UIViewController {
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
@@ -202,6 +301,7 @@ class ShareViewController: UIViewController {
// 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

@@ -169,12 +169,61 @@ async function checkAndStoreNativeSharedImage(): Promise<{
// Check if we have valid image data (base64 must be non-null and non-empty)
if (result && result.base64 && result.base64.trim().length > 0) {
const fileName = result.fileName || "shared-image.jpg";
const shareId = result.shareId;
const shareIdLog = shareId || "unknown";
// Store in temp database using extracted method
// Hand off to the app: store in temp database using extracted method
logger.info(
"[Main] Native shared image found (via plugin), storing in temp DB",
`[Main] Shared image processing started shareId=${shareIdLog} fileName=${fileName}`,
);
await storeSharedImageInTempDB(result.base64, fileName);
try {
await storeSharedImageInTempDB(result.base64, fileName);
} catch (storeError) {
// Processing failed - leave the shared item intact (do NOT mark processed)
logger.error(
`[Main] Shared image processing failed shareId=${shareIdLog}:`,
storeError,
);
isProcessingSharedImage = false;
return { success: false };
}
logger.info(
`[Main] Shared image processing succeeded shareId=${shareIdLog} fileName=${fileName}`,
);
// Signal explicit completion only after successful processing.
// iOS-only: the markProcessed API is not implemented on Android.
if (shareId && Capacitor.getPlatform() === "ios") {
let marked = false;
try {
logger.info(`[Main] markProcessed invoked shareId=${shareId}`);
await SharedImage.markProcessed({ shareId });
marked = true;
} catch (markError) {
// A failure to mark processed must not fail the (already successful)
// share processing; the share simply remains available for retry.
logger.warn(
`[Main] markProcessed failed shareId=${shareId}:`,
markError,
);
}
// Phase 1F: native cleanup ONLY after both processing and markProcessed
// succeed. A cleanup failure is logged but does not fail the import,
// since the image has already been handed off to the app.
if (marked) {
try {
await SharedImage.clearProcessedShare();
} catch (cleanupError) {
logger.warn(
`[Main] cleanup failed shareId=${shareId}:`,
cleanupError,
);
}
}
}
isProcessingSharedImage = false;
return { success: true, fileName };
@@ -405,22 +454,22 @@ logger.info(`[Main] ✅ App mounted successfully`);
// Check for shared image on initial load (in case app was launched from share sheet)
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
// after a delay to ensure the native code has finished processing
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
// Use multiple checks with increasing delays to handle timing issues
// Android share intent processing happens in onCreate, which may complete after JS loads
const checkDelays =
Capacitor.getPlatform() === "android"
? [500, 1500, 3000] // Android needs more time for share intent processing
: [1000]; // iOS is faster
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android") {
// Android behavior unchanged: multiple checks with increasing delays because
// share intent processing happens in onCreate, which may complete after JS loads.
const checkDelays = [500, 1500, 3000]; // Android needs more time for share intent processing
checkDelays.forEach((delay) => {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, delay);
});
} else if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
// Phase 2B-3: the SharedImage plugin is now registered deterministically from
// AppBridgeViewController.capacitorDidLoad() before the web layer loads, so it
// is guaranteed to exist here. Perform the initial shared-image check
// immediately without waiting/polling for plugin readiness.
void checkForSharedImageAndNavigate();
}
// Listen for app state changes to detect when app becomes active

View File

@@ -18,4 +18,16 @@ export class SharedImagePluginWeb
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
async markProcessed(_options: {
shareId: string;
}): Promise<{ success: boolean }> {
// Web platform doesn't support native sharing - no-op
return { success: false };
}
async clearProcessedShare(): Promise<{ success: boolean }> {
// Web platform doesn't support native sharing - no-op
return { success: false };
}
}

View File

@@ -5,13 +5,18 @@
export interface SharedImageResult {
base64: string;
fileName: string;
/**
* Identifier of the current share (iOS `sharedPhotoShareId`).
* Pass this to `markProcessed()` to signal explicit completion.
*/
shareId: string;
}
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 +25,29 @@ export interface SharedImagePlugin {
* Useful for quick checks before calling getSharedImage()
*/
hasSharedImage(): Promise<{ hasImage: boolean }>;
/**
* Mark a share as explicitly processed by the application layer (Phase 1D, iOS only).
*
* Records a completion signal for the given shareId on the native side.
* This does NOT delete the stored image file or clear share metadata;
* file cleanup is deferred to a later phase.
*
* On web this is a no-op that resolves with success=false.
*
* @param options.shareId The share identifier reported by getSharedImage()
*/
markProcessed(options: { shareId: string }): Promise<{ success: boolean }>;
/**
* Permanently remove the currently processed share from native storage
* (Phase 1F, iOS only).
*
* Deletes the shared image file and all share metadata. Idempotent: missing
* files/keys resolve successfully. Should only be called after the share has
* been successfully processed AND marked processed.
*
* On web this is a no-op that resolves with success=false.
*/
clearProcessedShare(): Promise<{ success: boolean }>;
}