Compare commits

..

54 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
ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
fe97dff752 Merge pull request 'Rework Thanks Button' (#234) from thanks-button-rework into master
Reviewed-on: #234
2026-06-19 07:21:37 +00:00
Jose Olarte III
903047f13b style(gift): center entity selection step heading and entity type toggle 2026-06-18 21:09:32 +08:00
Jose Olarte III
48be234af4 fix(home): offset scrolled Thank button for safe-area-inset-bottom 2026-06-17 17:57:13 +08:00
6c0907d905 remove unused function & duplicate comment 2026-06-16 16:09:31 -06:00
Jose Olarte III
8d8bcf2a7e style(home): rework Thank button and sticky scroll action bar
Replace the floating circular plus FAB with a full-width bottom bar that
matches the inline Thank button. Wrap the quick-action section in a styled
container and raise the scroll threshold to 120px.
2026-06-16 21:48:34 +08:00
a4b47904c8 Merge pull request 'Add footer to Gifted Details view' (#233) from gifted-details-footer into master
Reviewed-on: #233
2026-06-16 08:27:47 +00:00
Jose Olarte III
bb890baacf fix(gifted-details): add QuickNav footer navigation 2026-06-15 17:20:18 +08:00
dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III
ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
41 changed files with 5410 additions and 4640 deletions

View File

@@ -1 +1 @@
18.19.0
20.18.1

2
.nvmrc
View File

@@ -1 +1 @@
18.19.0
20.18.1

View File

@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
- If there are DB changes: before updating the test server, open browser(s) with
current version to test DB migrations.
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
`npm install`.
- Run a build to make sure package-lock version is updated, linting works, etc:
`npm install && npm run build:web`
- Run a build to make sure linting works, etc:
`npm run build:web`
- Commit everything (since the commit hash is used the app).
@@ -346,7 +346,7 @@ current version to test DB migrations.
- Tag with the new version,
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
`git tag 1.0.2 && git push origin 1.0.2`.
`git tag 1.3.13 && git push origin 1.3.13`.
- For test, build the app:
@@ -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 67 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.12;/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 67/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.3.12"/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

View File

@@ -6,6 +6,18 @@ 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.3] - 2026.06.19
### Removed
- Automatic "Check your starred projects" daily notification
### Changed
- Positioning for 'Thank' button and entity-type toggle link
## [1.4.2] - 2026.05.24
### Changed
- Support 16 KB page sizes
## [1.3.13] - 2026.04.05
### Added
- Ability to select project that the current one fulfills

View File

@@ -29,16 +29,16 @@ android {
compileSdk rootProject.ext.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
defaultConfig {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 67
versionName "1.3.12"
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.
@@ -72,13 +72,14 @@ android {
}
packagingOptions {
jniLibs {
// Required for 16 KB page-size support: keep native libs uncompressed and
// page-aligned inside the APK (default on AGP 8.x with minSdk 23+, set
// explicitly so it does not regress).
useLegacyPackaging = false
pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so']
}
}
// Configure for 16 KB page size compatibility
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
bundle {
language {

View File

@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
},
@@ -100,7 +100,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

View File

@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
// This should query the TimeSafari API for notification content
// using the configured apiBaseUrl, activeDid, and jwtToken
// For now, return a placeholder notification
long scheduledTime = fetchContext.scheduledTime != null
? fetchContext.scheduledTime
: System.currentTimeMillis() + 60000; // 1 minute from now
NotificationContent content = new NotificationContent(
"TimeSafari Update",
"Check your starred projects for updates!",
scheduledTime
);
List<NotificationContent> results = new ArrayList<>();
results.add(content);
Log.d(TAG, "Returning " + results.size() + " notification(s)");
return results;
// Not implemented yet: return no content rather than fabricating a
// placeholder notification (previously hardcoded "Check your starred
// projects for updates!", which showed on every app startup).
Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
return Collections.<NotificationContent>emptyList();
} catch (Exception e) {
Log.e(TAG, "Fetch failed", e);

View File

@@ -1,5 +1,5 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
}
@@ -9,7 +9,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.android.tools.build:gradle:8.7.2'
}
}
@@ -17,10 +17,10 @@ apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
}
@@ -28,8 +28,8 @@ android {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}

View File

@@ -1,6 +1,6 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]

View File

@@ -13,4 +13,11 @@ ext {
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
// Pin CameraX to 1.4.2: first stable line shipping a 16 KB page-size-aligned
// libimage_processing_util_jni.so. The barcode-scanning plugin still defaults to 1.1.0.
androidxCameraCamera2Version = '1.4.2'
androidxCameraCoreVersion = '1.4.2'
androidxCameraLifecycleVersion = '1.4.2'
androidxCameraViewVersion = '1.4.2'
}

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.

3
ios/.gitignore vendored
View File

@@ -17,6 +17,7 @@ App/App/config.xml
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset

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 */,
);
@@ -452,7 +445,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -508,7 +501,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -524,18 +517,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67;
CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.12;
MARKETING_VERSION = 1.4.3;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -553,18 +546,18 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 67;
CURRENT_PROJECT_VERSION = 69;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.12;
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 = 67;
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.3.12;
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 = 67;
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.3.12;
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

@@ -15,50 +15,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Initialize SQLite
//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.
@@ -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,26 +61,110 @@ 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
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)
return ["base64": base64String, "fileName": fileName, "shareId": resolvedShareId]
}
/**
* 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
@@ -76,7 +185,7 @@ public class SharedImageUtility {
let fileURL = containerURL.appendingPathComponent(filePath)
return FileManager.default.fileExists(atPath: fileURL.path)
}
/**
* Check if shared photo ready flag is set
* This flag is set by the Share Extension when image is ready

View File

@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
platform :ios, '15.5'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -30,9 +30,4 @@ end
post_install do |installer|
assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
end
end
end

View File

@@ -1,94 +1,84 @@
PODS:
- Capacitor (6.2.1):
- Capacitor (7.6.4):
- CapacitorCordova
- CapacitorApp (6.0.2):
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorCamera (6.1.2):
- CapacitorCamera (7.0.5):
- Capacitor
- CapacitorClipboard (6.0.2):
- CapacitorClipboard (7.0.4):
- Capacitor
- CapacitorCommunitySqlite (6.0.2):
- CapacitorCommunitySqlite (7.0.3):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3):
- CapacitorCordova (7.6.4)
- CapacitorFilesystem (7.1.8):
- Capacitor
- CapacitorMlkitBarcodeScanning (6.2.0):
- IONFilesystemLib (~> 1.1.1)
- CapacitorMlkitBarcodeScanning (7.5.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorShare (6.0.3):
- GoogleMLKit/BarcodeScanning (= 7.0.0)
- CapacitorShare (7.0.4):
- Capacitor
- CapacitorStatusBar (6.0.2):
- CapacitorStatusBar (7.0.6):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- CapawesomeCapacitorFilePicker (7.2.0):
- Capacitor
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (5.0.0):
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 4.0.0)
- GoogleMLKit/MLKitCore (5.0.0):
- MLKitCommon (~> 10.0.0)
- GoogleToolboxForMac/DebugUtils (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- GoogleToolboxForMac/Defines (2.3.2)
- GoogleToolboxForMac/Logger (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
- GoogleUtilities/Environment (7.13.3):
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (4.0.0):
- MLKitCommon (~> 10.0)
- MLKitVision (~> 6.0)
- MLKitCommon (10.0.0):
- GoogleDataTransport (~> 9.0)
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- GoogleUtilities/UserDefaults (~> 7.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLKitVision (6.0.0):
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 10.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- IONFilesystemLib (1.1.2)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SQLCipher (4.9.0):
- SQLCipher/standard (= 4.9.0)
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.10.0)
- SQLCipher/standard (4.10.0):
- SQLCipher/common
- TimesafariDailyNotificationPlugin (2.0.0):
- TimesafariDailyNotificationPlugin (4.0.1):
- Capacitor
- ZIPFoundation (0.9.19)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -110,8 +100,8 @@ SPEC REPOS:
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- IONFilesystemLib
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@@ -148,33 +138,33 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@timesafari/daily-notification-plugin"
SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
COCOAPODS: 1.16.2

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

8694
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"name": "giftopia",
"version": "1.3.13",
"version": "1.4.3",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
@@ -13,6 +14,7 @@
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js",
"check:dependencies": "./scripts/check-dependencies.sh",
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
"test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh",
@@ -28,7 +30,7 @@
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -138,19 +140,19 @@
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/clipboard": "^6.0.2",
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capacitor/status-bar": "^6.0.2",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@capacitor-community/sqlite": "^7.0.3",
"@capacitor-mlkit/barcode-scanning": "^7.5.0",
"@capacitor/android": "^7.6.4",
"@capacitor/app": "^7.1.0",
"@capacitor/camera": "^7.0.5",
"@capacitor/cli": "^7.6.4",
"@capacitor/clipboard": "^7.0.4",
"@capacitor/core": "^7.6.4",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/ios": "^7.6.4",
"@capacitor/share": "^7.0.4",
"@capacitor/status-bar": "^7.0.6",
"@capawesome/capacitor-file-picker": "^7.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0",

View File

@@ -222,8 +222,9 @@ build_ios_app() {
if [ "$BUILD_TYPE" = "debug" ]; then
build_config="Debug"
# Any Simulator — avoids hardcoding a device name (e.g. iPhone 15 Pro) that may not exist in newer Xcode runtimes
destination="generic/platform=iOS Simulator"
# Use device SDK — prebuilt MLKit frameworks (MLImage, MLKitBarcodeScanning) ship
# iOS device slices only and cannot link against the iOS Simulator SDK.
destination="generic/platform=iOS"
else
build_config="Release"
destination="platform=iOS,id=auto"
@@ -233,10 +234,16 @@ build_ios_app() {
cd ios/App
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
unset LIBRARY_PATH
unset DYLD_LIBRARY_PATH
unset DYLD_FALLBACK_LIBRARY_PATH
# Build the app:
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
# build in Xcode for full diagnostics. Real errors still fail the build.
local build_exit=0
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme "$scheme" \
@@ -247,10 +254,14 @@ build_ios_app() {
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
GCC_WARN_INHIBIT_ALL_WARNINGS=YES || build_exit=$?
cd ../..
if [ $build_exit -ne 0 ]; then
return $build_exit
fi
log_success "iOS app built successfully"
}
@@ -413,6 +424,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
log_success "Assets generation completed successfully!"
exit 0
@@ -562,6 +574,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 8: Build iOS app

View File

@@ -337,6 +337,27 @@ parse_args() {
fi
}
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
# folders and Contents.json are missing; the tool opens Contents.json before writing
# PNGs, so we create minimal asset-catalog stubs when absent.
ensure_ios_capacitor_asset_directories() {
local base="ios/App/App/Assets.xcassets"
if [ ! -d "$base" ]; then
log_warn "Missing $base — cannot prepare iOS asset directories"
return 0
fi
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
fi
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
fi
log_debug "Ensured iOS capacitor-assets output directories exist"
}
# Export functions for use in child scripts
export -f log_info log_success log_warn log_error log_debug log_step
export -f measure_time print_header print_footer
@@ -344,4 +365,5 @@ export -f check_command check_directory check_file
export -f safe_execute check_venv get_git_hash
export -f clean_build_artifacts validate_env_vars
export -f setup_build_env setup_app_directories load_env_file print_env_vars
export -f print_usage parse_args
export -f print_usage parse_args
export -f ensure_ios_capacitor_asset_directories

View File

@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-1">
<label class="block font-semibold text-lg capitalize text-center">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<div class="text-center mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
class="text-xs text-blue-600 hover:underline uppercase"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}

View File

@@ -129,7 +129,7 @@
@click="disabled ? notifyLocked() : addGroup()"
>
<font-awesome icon="plus" class="text-sm" />
New Group
New Do-Not-Pair Group
</button>
</div>
</template>

View File

@@ -6,8 +6,8 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
APP_NAME_NO_SPACES = "TimeSafari",
APP_NAME = "Giftopia",
APP_NAME_NO_SPACES = APP_NAME,
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",

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

View File

@@ -123,10 +123,10 @@ export class CapacitorQRScanner implements QRScannerService {
// Add listener for barcode scans
const handle = await BarcodeScanner.addListener(
"barcodeScanned",
"barcodesScanned",
(result) => {
if (this.scanListener && result.barcode?.rawValue) {
this.scanListener.onScan(result.barcode.rawValue);
if (this.scanListener && result.barcodes?.[0]?.rawValue) {
this.scanListener.onScan(result.barcodes[0].rawValue);
}
},
);

View File

@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
return true;
}
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call
*/
/**
* Request notification permissions from the OS
* Shows native permission dialog on first call

View File

@@ -1402,54 +1402,6 @@ export default class AccountViewView extends Vue {
}, 150);
}
/**
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
* if reminder is already on, reschedules so the plugin uses the new interval.
*/
async toggleReminderFastRollover(): Promise<void> {
const next = !this.reminderFastRolloverForTesting;
await this.$saveSettings({
reminderFastRolloverForTesting: next,
});
this.reminderFastRolloverForTesting = next;
if (this.notifyingReminder) {
try {
const service = NotificationService.getInstance();
if (Capacitor.getPlatform() !== "android") {
await service.cancelDailyNotification();
}
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
const title = "Daily Reminder";
const body =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
...(next ? { rolloverIntervalMinutes: 10 } : {}),
});
this.notify.success(
next
? "Reminder will repeat every 10 minutes (testing)."
: "Reminder will repeat daily (24h).",
TIMEOUTS.STANDARD,
);
} catch (err) {
logger.error(
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
err,
);
this.notify.error(
"Failed to update reminder interval. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/
@@ -2116,6 +2068,10 @@ export default class AccountViewView extends Vue {
hasLocation: result.includeLocation,
});
if (response.data.userMessage) {
this.notify.info(response.data.userMessage);
}
return result;
} else {
logger.debug("[AccountViewView] No profile data found in response:", {
@@ -2123,6 +2079,10 @@ export default class AccountViewView extends Vue {
hasData: !!response.data,
hasDataData: !!(response.data && response.data.data),
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
}
return null;
@@ -2229,6 +2189,10 @@ export default class AccountViewView extends Vue {
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam
@@ -2345,6 +2309,10 @@ export default class AccountViewView extends Vue {
status: response.status,
});
if (response.data?.userMessage) {
this.notify.info(response.data.userMessage);
}
return true;
} catch (error: unknown) {
// Handle specific HTTP status codes cleanly to suppress console spam

View File

@@ -1,4 +1,6 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<TopMessage />

View File

@@ -87,7 +87,7 @@
</li>
</ul>
<p class="mt-4">
<div class="mt-4">
<!--
This section is for Twilio's A2P Campaign requirements.
They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold).
@@ -125,7 +125,7 @@
along with the explicit signed permission to use it for searches.
</li>
</ul>
</p>
</div>
</div>
<!-- eslint-enable -->
</section>

View File

@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<div
class="bg-slate-100 border border-slate-300 rounded-md px-4 py-2.5 mb-4"
>
<div class="flex gap-2 justify-between items-center ps-8">
<!-- Thank button - always visible and unchanged -->
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
</button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition
enter-active-class="transition-all duration-1000 ease-out"
leave-active-class="transition-all duration-1000 ease-in"
enter-from-class="scale-0"
enter-to-class="scale-100"
leave-from-class="scale-100"
leave-to-class="scale-0"
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-500 ease-in"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<button
<div
v-if="isScrolled"
type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
:style="getButtonPosition()"
@click="openPersonDialog()"
class="bg-gradient-to-t from-white to-transparent fixed inset-x-0 bottom-[calc(4.75rem+max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))] px-4 pb-3 w-full z-[49]"
>
<font-awesome icon="plus" />
</button>
<button
type="button"
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg drop-shadow-[0_0_10px_rgba(255,255,255,1)]"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
<span>Thank</span>
</button>
</div>
</transition>
<button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
class="block text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()"
>
<font-awesome
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
const scrollTop = appElement
? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100;
const shouldBeScrolled = scrollTop > 120;
if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled;
}

View File

@@ -366,7 +366,8 @@
Do Not Pair Together
</h4>
<p class="text-xs text-gray-500 mb-2">
People in the same group will not be matched with each other.
People in the same do-not-pair group will not be matched with each
other.
</p>
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
Erase matches to change restrictions.

View File

@@ -130,11 +130,13 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
// Refresh home view and check gift
await page.goto('./');
// Wait for async data (e.g. "new offers" banner) to finish loading so the layout
// is stable before clicking — otherwise a layout shift can redirect the click.
await page.waitForLoadState('networkidle');
// Firefox complains on load the initial feed here when we use the test server.
// It may be similar to the CORS problem below.
const item = await page.locator('li:first-child').filter({ hasText: finalTitle });
await item.locator('[data-testid="circle-info-link"]').click();
const item = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
await expect(item).toBeVisible();
await item.click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();