Compare commits

..
Author SHA1 Message Date
Jose Olarte III c84cce54af Document the retired WAKEUP_PING refresh path as inactive and drop unused refresh auth helpers.
Guides and the debug panel still described /notifications/refresh and api_* scheduling as current after Phase 5; keep Phase 4 cleanup and FCM send-wakeup diagnostics.
2026-09-24 22:35:48 +08:00
Jose Olarte III 4f339eba32 Raise leftover iOS deployment targets from 14.0 to 15.5 so Xcode 27 can build.
Align the project, Share Extension, and CocoaPods `post_install` with the existing 15.5 minimum. Also refresh the CocoaPods embed-frameworks phase and package-lock peer metadata.
2026-09-24 17:17:08 +08:00
Jose Olarte III eb2240b901 Retire WAKEUP_PING refresh consumption and clear leftover api_* schedules on upgrade.
Stop the app from scheduling via the legacy refresh pipeline, and add a one-time Preferences-gated startup cleanup that calls clearApiNotifications() so upgraded installs shed persisted api_* residue without touching daily reminder, dual, AlertSearch, or FCM.
2026-09-22 20:59:23 +08:00
Jose Olarte III 724131d9a8 Retire app consumption of WAKEUP_PING → /notifications/refresh → api_* scheduling.
Phase 2 of legacy notification retirement: drop the production push refresh chain and dead debug/composable paths while keeping FCM registration, AlertSearch, daily reminder, and dual notifications intact.
2026-09-22 20:39:37 +08:00
trentlarson e77d08e3a8 Merge branch 'fix/shared-photo-deeplink' 2026-09-21 18:57:00 -06:00
trentlarson ef476bbb57 fix problem with project ID for "given to" project links, fix deep-link redirect for web, update browserslist DB 2026-09-21 07:55:08 -06:00
trentlarson 2bbf230312 adjust the URL for sharing a photo with the app (since it was going to the root "/" home route for sharing and that caused conflicts when we actually want to deep-link to the home page) 2026-09-20 21:35:52 -06:00
trentlarson 45503f568a fix more types and make more code consistent (mobile test seems to hang) 2026-09-20 16:10:36 -06:00
trentlarson e350feb132 add some other deep links for useful pages, and fix a UI test 2026-09-20 15:17:58 -06:00
trentlarson 7e3f2aa004 add deep-links for "" (base), new-activity, and QR-scan pages 2026-09-20 14:26:05 -06:00
Jose Olarte III de951543c7 Merge branch 'notify-api_endpoint-query' 2026-09-17 14:46:01 +08:00
Jose Olarte III 7604a92f60 Hide the in-app notification channel on Account unless running a development build. 2026-09-16 17:26:04 +08:00
trentlarson 4bf2fc1009 fix potential build problems (Claude recommendations) 2026-09-14 13:06:19 -06:00
trentlarson 97f4f59eff make the background-search JWTs in the pool all unique, removing problematic jti 2026-09-14 12:33:52 -06:00
trentlarson 6baf2a98ac reconcile & fix changes in FCM & SMS notifications 2026-09-14 10:23:06 -06:00
trentlarson 44695a51cc Merge branch 'notify-api_endpoint-query_merge' into notify-api_endpoint-query 2026-09-14 08:09:49 -06:00
trentlarson 044344026c Merge commit '0c400b87' into notify-api_endpoint-query 2026-09-13 18:47:53 -06:00
trentlarson fe9a2f0cbb Merge branch 'notify-api-sms' into notify-api_endpoint-query 2026-09-13 18:45:36 -06:00
trentlarson 240c3c5a76 Consolidate notify-api JWT minting; add wire types (WIP before SMS merge) 2026-09-13 17:08:23 -06:00
Jose Olarte III 4fcc9a40d0 Align AlertSearch authorization uploads with the wakeup-service contract: UTC-day delegated JWTs and required notifyHourUtc/notifyMinuteUtc. 2026-09-10 22:07:00 +08:00
Jose Olarte III 2afe748292 Send the ngrok skip-browser-warning header only when the Notification Debug Panel backend override is set.
WebView fetch to free ngrok was blocked by the interstitial (no CORS ACAO). Keep production notify-api requests unchanged.
2026-09-03 21:12:05 +08:00
Jose Olarte III 34a8c51d2f Add a manual debug-panel action to mint and PUT the 100-day AlertSearch authorization batch.
Reuse the existing delegated JWT minting and notification API auth so a signed-in ethr identity can upload the batch to the configured notify backend without changing production defaults or sending it automatically.
2026-09-02 15:34:03 +08:00
trentlarson 59993cb96a fix husky git hooks (because it was complaining about those lines) 2026-08-30 17:59:01 -06:00
trentlarson 940e851d3c adjust the UX for SMS text opt-in, to show terms immediately 2026-08-30 17:55:23 -06:00
trentlarson 0c400b8797 fix spacing in a doc diagram 2026-08-30 11:07:36 -06:00
Jose Olarte III d5bcdbae3f Mint 100 per-local-day delegated notification JWTs for notify-api without touching the native background pool.
Each token is signed with the existing Endorser path and bounded by the user’s timezone midnight, so the next phase can submit the batch without changing prefetch JWTs or notification scheduling.
2026-08-26 16:05:51 +08:00
Jose Olarte III 46a2e0aaf5 Add typed alertSearch API contract and response models without implementing the daily search flow.
This prepares the app for endorser and partner alertSearch by capturing the known buckets, cursor ULID semantics, and JWT kinds, without changing notification scheduling or the background JWT pool.
2026-08-26 15:25:19 +08:00
trentlarson b02a1b29c5 add T&C/privacy link for notification help 2026-08-22 13:01:48 -06:00
trentlarson 3f291b0d63 add a more comprehensive demo UI workflow for SMS signup 2026-08-13 09:45:16 -06:00
jose 6b65ad8554 Merge pull request 'New Giftopia App Icons and Splashes' (#236) from giftopia-app-icon into master
Reviewed-on: #236
2026-07-29 12:08:44 +00:00
Jose Olarte III 03658340f8 chore(assets): update icon and splash colors for Giftopia branding 2026-07-28 18:45:56 +08:00
Jose Olarte III d5c357b291 Document dedicated Notification API URL configuration.
Describe VITE_DEFAULT_NOTIFY_API_SERVER / DEFAULT_NOTIFY_API_SERVER,
the debug-override resolution order, and replace outdated APP_SERVER
assumptions in build and notification testing docs.
2026-07-22 20:56:20 +08:00
Jose Olarte III 86611fe50d Update notification debug panel default URL hint.
Replace the outdated APP_SERVER placeholder with DEFAULT_NOTIFY_API_SERVER
so the UI matches centralized notify-api base URL resolution.
2026-07-22 18:02:34 +08:00
Jose Olarte III 4152012838 Point notification API base URL at DEFAULT_NOTIFY_API_SERVER.
Keep debug localStorage overrides first; fall back to the dedicated
notify-api default instead of APP_SERVER.
2026-07-22 17:45:10 +08:00
Jose Olarte III 25110e3eea Align DEFAULT_NOTIFY_API_SERVER with other backend service defaults.
Drop getDefaultNotifyApiServer and the Endorser-based fallback so notify
config uses the same env-or-prod pattern as image, partner, and push.
2026-07-22 17:39:48 +08:00
Jose Olarte III 0ecd4c6dd7 Add dedicated Notification API URL config for prod and test.
Introduce PROD/TEST notify-api constants, DEFAULT_NOTIFY_API_SERVER, and
VITE_DEFAULT_NOTIFY_API_SERVER in env files so notification traffic can
target notify-api hosts independently of APP_SERVER.
2026-07-22 17:35:37 +08:00
Jose Olarte III 821d3b7d05 fix(assets): point Android adaptive icons at hyphenated filenames 2026-07-20 18:14:31 +08:00
trentlarson ee8fb80cfe start the SMS notifications, beginning with the UI 2026-07-19 20:23:34 -06:00
trentlarson cdf5c721a5 update some iOS settings per Xcode recommendation 2026-07-18 15:40:34 -06:00
trentlarson 974002fa90 tweak scripts & docs for iOS build on new device 2026-07-18 15:39:24 -06:00
Jose Olarte III 8e6c83021f fix(assets): rename splash_dark.png to splash-dark.png
Match the filename @capacitor/assets expects so iOS uses the custom
dark splash instead of the logo-on-black fallback.
2026-07-17 20:04:08 +08:00
Jose Olarte III cfe90fd04e chore(ios): remove unused SplashDark.imageset leftover
Drop the obsolete SplashDark catalog; dark launch screens live as
appearance variants inside Splash.imageset.
2026-07-17 17:52:44 +08:00
Jose Olarte III fb9da10fd2 fix(ios): validate resources/ and warn if assets/ shadows it
Point iOS asset checks at resources/ and document that a leftover assets/
directory makes @capacitor/assets ignore the canonical sources.
2026-07-17 17:48:40 +08:00
Jose Olarte III 6d221ee1ca Install optional iOS Dark/Tinted app icons after capacitor-assets generate.
Post-process AppIcon.appiconset so appearance variants are reapplied on every iOS build without changing the existing asset generation flow.
2026-07-16 18:34:22 +08:00
Jose Olarte III 823db447ca style(assets): refresh Giftopia icons and splashes with adaptive layers 2026-07-15 20:53:54 +08:00
Jose Olarte III f23fe65078 docs(notifications): document debug panel auth settings and bypassAuth
Add doc/notification-debug-panel.md as the canonical panel reference.
Update ngrok guides, README, and analysis doc to describe independent
Backend URL, Test Mode, and Skip JWT Authentication settings, including
recommended configs for hosted test servers vs local ngrok.
2026-07-07 19:21:18 +08:00
Jose Olarte III 82380b3d35 fix(notifications): decouple JWT bypass from backend URL override
Add an explicit notificationDebug.bypassAuth setting (default off) so
custom backend URLs and test mode no longer skip authentication. Expose
the toggle in the Notification Debug Panel for local ngrok workflows;
hosted test servers receive JWT-authenticated requests by default.
2026-07-07 19:00:42 +08:00
trentlarson 87ffa025e8 bump version to 1.4.4 build 70 2026-06-22 12:14:29 -06:00
trentlarson 15c9088736 change the android build such that Play Store isn't required (also removed android/google-services.json) 2026-06-21 18:01:04 -06:00
trentlarson ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
trentlarson 463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
jose 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
trentlarson 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
jose 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
Jose Olarte III 6a7f341990 docs(android): document Send Real WAKEUP_PING debug panel flow
Add §6 coverage for the full backend→FCM→refresh pipeline, expected
Logcat lines, and troubleshooting that distinguishes backend success
from end-to-end delivery; cross-link checklist, workflow, and §14.
2026-06-12 18:23:19 +08:00
Jose Olarte III 693bfacc1e feat(notifications): include refresh source in completion and failure logs
Thread options.source through logRefreshSuccess and logRefreshFailure so
WAKEUP_PING and debug-panel refreshes are grep-friendly end-to-end in
Logcat and the Event Log without changing refresh behavior.
2026-06-12 17:12:21 +08:00
Jose Olarte III 3d6ac2ab53 feat(dev): test full WAKEUP_PING pipeline from debug panel
Add Send Real WAKEUP_PING via /debug/send-wakeup and rename the local
refresh shortcut to Simulate WAKEUP_PING (Local).
2026-06-11 17:01:28 +08:00
Jose Olarte III cd32895281 refactor(notifications): use clearApiNotifications and scheduleApiNotifications
Update the refresh replacement flow for the renamed plugin APIs and remove
the obsolete clearPredictiveNotifications type augmentation.
2026-06-09 19:39:50 +08:00
Jose Olarte III a1f94300ad refactor(notifications): rename predictive terminology to API notifications
Update app logs, comments, and debug inspector labels to use API
notification wording while keeping clearPredictiveNotifications plugin
calls unchanged. Align the iOS pending-notification inspector with the
plugin's api_ identifier prefix.
2026-06-09 17:49:08 +08:00
Jose Olarte III 58b61471b5 fix(notifications): use clearPredictiveNotifications on refresh
Avoid cancelAllNotifications during schedule replacement so Daily
Reminder schedules are not cleared.
2026-06-08 19:42:48 +08:00
Jose Olarte III 55ef36be06 fix(notifications): send deviceId on refresh to match backend contract
The /notifications/refresh endpoint now requires deviceId or fcmToken.
Reuse the stable device ID from registration so refresh no longer returns 400.
2026-06-05 19:14:07 +08:00
Jose Olarte III 00abd5277f fix(dev): refresh Backend Status URL after save in debug panel
activeBackendUrl was a computed with no reactive deps, so it stayed stale
until the panel remounted. Use a ref and update it in syncBackendState().
2026-06-03 17:53:54 +08:00
Jose Olarte III 227ae85bb7 build(android): wire Capacitor Preferences and Firebase for push testing
Register @capacitor/preferences in the Android Capacitor project so
notification deviceId storage matches iOS. Replace the placeholder
google-services.json with the production Firebase client config for FCM.
Refresh package-lock after native sync / install.
2026-06-03 17:53:31 +08:00
trentlarson dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
trentlarson 9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
Jose Olarte III e0a3f7094f docs(notifications): add Android local ngrok testing guide
Add local-android-testing-ngrok.md for FCM wakeup, debug panel, Firebase
setup, platform/battery notes, troubleshooting, verification checklist, and
end-to-end QA flow. Add local-android-testing-analysis.md as planning
notes mapping reuse from the iOS ngrok guide.
2026-06-02 21:33:23 +08:00
Jose Olarte III 2dd76878ba build(ios): enable push and remote background notification capabilities
Add AppDebug.entitlements with development aps-environment for Debug builds, point Debug signing at it, and add remote-notification to UIBackgroundModes.
2026-05-27 17:26:33 +08:00
Jose Olarte III 4fb8f048cd build(ios): add GoogleService-Info.plist to Xcode resources
Also clarify the ngrok iOS guide steps for dragging the plist into the correct App folder/target.
2026-05-27 17:01:35 +08:00
trentlarson cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
Jose Olarte III c97defef11 build(ios): add CapacitorPreferences pod for notification deviceId
Wire @capacitor/preferences into the iOS Capacitor Podfile so stable
deviceId persistence works on native builds. Refresh package-lock.json.
2026-05-25 17:06:07 +08:00
Jose Olarte III 2c0992ba8b docs(notifications): put Xcode workspace before Firebase in ngrok guide
Reorder first-time setup so Capacitor/Xcode workspace generation (section 4)
precedes Firebase and APNs steps that require Xcode. Update cross-links and
skip targets; no change to Firebase/APNs technical instructions.
2026-05-25 16:59:17 +08:00
trentlarson a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
trentlarson 802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
trentlarson efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
trentlarson 39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
trentlarson 93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
trentlarson ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
trentlarson ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
Jose Olarte III 964cdb4509 docs(notifications): add from-scratch Firebase and APNs setup to ngrok guide
Document first-time FCM/APNs configuration (project, plist, .p8 key,
Admin credentials, Xcode capabilities) before the iOS build step, and
renumber later sections so the checklist references the new flow.
2026-05-24 17:33:01 +08:00
Jose Olarte III 656de5eba3 docs(notifications): clarify ngrok guide so backend starts once
Consolidate first-run backend setup in section 1 and reframe section 2
as verification only, so local iPhone testing does not look like two
separate startup steps.
2026-05-24 10:21:10 +08:00
Jose Olarte III 0d7586865c feat(notifications): allow auth bypass for local debug and ngrok testing
Add shouldBypassNotificationAuth() when test mode or a backend URL override
is set so register/refresh can proceed without DID/Bearer headers. Production
paths still require auth when bypass is off; log bypass vs authenticated
request modes for easier WAKEUP_PING and panel smoke testing.
2026-05-20 19:34:46 +08:00
Jose Olarte III 5bc030125a feat(notifications): defer FCM registration until auth is ready
Queue token registration when Bearer auth is unavailable at startup,
with bounded exponential backoff retries. Flush the pending token when
identity is set, the app resumes, or the native fetcher configures.
Skip refresh API calls when auth is missing and log lifecycle events
for registration wait and refresh skip.
2026-05-20 15:54:36 +08:00
Jose Olarte III 8cd8727a84 feat(notifications): authenticate register and refresh API calls
Use getHeaders(activeDid) for POST /notifications/register and
/notifications/refresh so requests include Authorization: Bearer tokens
like the rest of the app. Add notificationApiAuth helper for shared header
resolution, auth logging, and graceful handling when identity or token
is missing or the server returns 401/403.
2026-05-20 15:45:46 +08:00
Jose Olarte III 8864a2049b docs(notifications): add local iOS ngrok testing guide for wakeup service
Document Mac backend + ngrok + physical iPhone setup, debug panel overrides,
Firebase/APNs checklist, curl examples, and troubleshooting for WAKEUP_PING flows.
2026-05-18 21:22:53 +08:00
Jose Olarte III 63f5c4ecc7 feat(notifications): add structured observability for push wake and refresh flows
Introduce NotificationDebugEvents and [Notifications] console/panel logging for push
handlers, token registration, refresh timing, schedule replacement, and WAKEUP_PING.
2026-05-18 18:46:16 +08:00
Jose Olarte III a4453c0b1b feat(dev): extend Notification Debug Panel for backend testing
Add backend URL, test mode, token re-register, refresh diagnostics, FCM token display,
and a capped event log; expose refreshNotificationsWithDiagnostics and reregisterFcmTokenNow.
2026-05-18 16:28:21 +08:00
Jose Olarte III 794b48f0d7 feat(notifications): add localStorage debug config for notification API base URL
Introduce NotificationDebugConfig so register/refresh use getNotificationApiBaseUrl()
(APP_SERVER by default, optional LAN/ngrok override) and configurable testMode without rebuilds.
2026-05-18 15:06:52 +08:00
Jose Olarte III 4c97c578bb fix(notifications): fall back when crypto.randomUUID is missing
If randomUUID is unavailable (older WebViews), generate a one-time ID
with Date.now + random segment, log a single DeviceId warning, and
persist it as before so registration still works.
2026-05-13 20:57:14 +08:00
Jose Olarte III 6a9f34a516 feat(notifications): persist stable deviceId for FCM registration
Add getOrCreateDeviceId() backed by Capacitor Preferences so one UUID
survives app restarts and token refreshes. Include deviceId in POST
/notifications/register alongside fcmToken, platform, and testMode.
Add @capacitor/preferences and lightweight DeviceId logs (no token/ID values).
2026-05-13 18:41:10 +08:00
Jose Olarte III 5a40075ab1 fix(dev): pending inspector stable times and refreshPending without nested busy
Expose wall-clock fire targets from the iOS NotificationInspector
(scheduled_time userInfo and predictive_<epochMs> ids) so the debug
panel is not misleading when nextTriggerDate resamples for interval
triggers. Extend TS types and show the scheduled target in the UI,
with a note when iOS nextTriggerDate diverges.

Make refreshPending a plain fetch so mock refresh, wakeup ping, flood
test, and clear notifications can refresh the pending list while an
outer withBusy guard is already active.
2026-05-11 13:50:52 +08:00
Jose Olarte III 48637ae9a8 docs(readme): document Notification Debug Panel for dev builds 2026-05-11 11:16:43 +08:00
trentlarson 7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
trentlarson 9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III a55dce6f3d fix(dev): align notification debug with non-production Capacitor builds
Add includeDevToolkitRoutes (vite dev or MODE !== production) and use it
from the router, AccountViewView, and NotificationDebugView so the debug
screen matches dev-notifications registration after vite build.

Update the gated banner copy to refer to production Vite builds.
2026-05-08 20:02:34 +08:00
Jose Olarte III d7d5e401b8 fix: dev notification debug on Capacitor and iOS compile
Register the dev-notifications route whenever the bundle is non-production
(DEV or Vite MODE !== production), matching the account screen so RouterLink
to Notification Debug does not throw after vite build.

Align AccountViewView isDev with that rule and document the coupling.

Add NotificationInspectorPlugin.swift to the App target compile sources so
AppDelegate can register the plugin.
2026-05-08 17:54:00 +08:00
Jose Olarte III 19427c2817 fix(account): avoid import.meta in AccountViewView template
Vue’s template compiler treats bindings as non-module JS, so
`import.meta.env.DEV` in `v-if` broke the Capacitor/Vite build.
Expose a readonly `isDev` from the script instead.
2026-05-08 16:34:17 +08:00
Jose Olarte III d4ac0acd01 chore: bump @timesafari/daily-notification-plugin to 3.0.2 2026-05-08 16:31:52 +08:00
Jose Olarte III 1ef3f32b9e fix(dev): clarify Android pending inspector and harden debug entry guard
- Report UNIMPLEMENTED from Android NotificationInspector instead of empty pending
- Surface iOS-only inspector message in NotificationDebugPanel without noisy errors
- Gate Account debug link with import.meta.env.DEV and document intent
- Add architecture comments on NotificationDebugService, inspector plugin, and native exports
2026-05-07 20:40:09 +08:00
Jose Olarte III fd0b8ce6d0 feat(dev): add notification debug panel and native pending inspector
Add a dev-only Notification Debug Panel at /dev/notifications for testing
predictive refresh and WAKEUP_PING without a backend.

- Gate route and Advanced Settings entry on import.meta.env.DEV
- NotificationDebugService drives mock refresh, flood test, clear, and
  wake simulation via existing handleCapacitorPushNotificationReceived and
  applyNotificationRefreshPayload (shared with refreshNotifications)
- Add NotificationInspector Capacitor plugin: iOS lists pending
  UNNotificationRequest identifiers and next trigger; Android stub returns
  empty pending for safe registration
2026-05-07 18:52:59 +08:00
Jose Olarte III 320e55912b fix(notifications): apply backend timestamps via scheduleNotifications API
Stop converting backend timestamps to HH:mm/recurring schedules and remove
createSchedule/updateSchedule reconciliation. After a successful refresh payload,
clear existing notifications and schedule exact timestamps via the plugin
scheduleNotifications API (with back-compat clear fallback) to prevent drift.
2026-05-06 17:56:55 +08:00
Jose Olarte III 6bbade2a29 feat(notifications): refresh on mount and resume with debounce
Trigger refreshNotifications on composable mount and document resume, using a
debounced/in-flight guarded wrapper to avoid rapid duplicate refresh calls.
Expose the debounced refresh function from useNotifications.
2026-05-06 17:11:10 +08:00
Jose Olarte III 1cd329c720 fix(notifications): clear scheduled notifications before refresh apply
Cancel all native notifications before applying the backend-provided schedule so
refreshNotifications always performs a full replacement and never leaves stale
entries behind.
2026-05-06 16:45:56 +08:00
Jose Olarte III 7c8ef284c2 feat(notifications): apply backend refresh schedule to native plugin
Update refreshNotifications to POST /notifications/refresh and map returned
nextNotifications timestamps to clockTime schedules, upserting them via the
DailyNotification schedule APIs (with deterministic IDs) after refreshing native
fetcher credentials.
2026-05-06 16:17:50 +08:00
Jose Olarte III 35a1b92559 feat(notifications): refresh native fetcher on WAKEUP_PING silent push
Add refreshNotifications (configureNativeFetcherIfReady) and
handleCapacitorPushNotificationReceived for data.type WAKEUP_PING; invoke from
Capacitor pushNotificationReceived without UI.
2026-05-06 16:04:01 +08:00
Jose Olarte III c523c14d96 feat(notifications): register FCM tokens with backend
Add registerToken POST to /notifications/register (platform, testMode).
Call it from Capacitor registration and Firebase getToken with deduped
registerRetrievedToken; expose registerToken via barrel and useNotifications
as registerFcmToken.
2026-05-06 15:40:00 +08:00
Jose Olarte III 162158066f feat(notifications): initialize Firebase Messaging and Capacitor push on native
Add firebaseMessagingClient to ensure the Firebase app is created from VITE_FIREBASE_*,
wire PushNotifications (listeners, requestPermissions, register) before token work,
and call getMessaging/getToken/onMessage when firebase/messaging is supported. Hook
startup from main.capacitor and set PushNotifications presentationOptions in
capacitor.config. Depend on firebase and @capacitor/push-notifications.
2026-05-06 15:30:46 +08:00
Jose Olarte III 1643bab18b Merge branch 'notify-api_android' into notify-api 2026-04-23 16:08:05 +08:00
Jose Olarte III ce078862e7 chore: sync package-lock and Podfile.lock (TimesafariDailyNotificationPlugin 3.0.1) 2026-04-20 17:44:00 +08: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
Jose Olarte III 954500cf9d fix(ios): static SQLCipher pods, strip system SQLite, refresh deps
- Podfile: use static frameworks; post_install/post_integrate hooks to
  avoid mixing Apple libsqlite3/SQLite headers with SQLCipher (including
  stripping aggregate Pods-App xcconfig flags for Swift explicit modules).
- Xcode: enable CLANG_ENABLE_MODULES; replace CocoaPods “Embed Pods
  Frameworks” phase with “Copy Pods Resources”; minor project file hygiene.
- Pods: SQLCipher 4.10.0, ZIPFoundation patch bump; Podfile.lock updated.
- package.json: allow patch updates for @capacitor-community/sqlite (^6.0.2);
  regenerate package-lock.json.
- Info.plist: reorder keys only (same URL scheme, background modes, BG tasks,
  notification alert style).
2026-04-09 21:46:32 +08:00
trentlarson e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
trentlarson ea662f4430 bump to v 1.3.13 (for a web release) 2026-04-05 19:58:36 -06:00
trentlarson 81647e1f3c make terms & conditions into a separate page 2026-04-05 19:21:43 -06:00
Jose Olarte III 73d595046a docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-04-02 19:03:58 +08:00
Jose Olarte III cf9d207895 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-04-02 19:03:58 +08:00
Jose Olarte III 7d87a746f9 feat(ios): register Swift TimeSafariNativeFetcher for New Activity notifications
Add TimeSafariNativeFetcher (plansLastUpdatedBetween parity with Android) and
call DailyNotificationPlugin.registerNativeFetcher from AppDelegate before JS
configureNativeFetcher; broaden DailyNotificationDelivered scheduled_time types
in willPresent. Wire the new file into the App target; normalize PBX object IDs
to 24-char hex.

Document plugin ≥3 handoff (consuming-app-handoff-ios-native-fetcher-chained-dual),
refresh iOS/Android parity and notification-from-api-call file tables.
2026-04-02 19:02:48 +08:00
Jose Olarte III 90e6603d52 docs: add plugin-repo handoff section to iOS/Android New Activity parity guide
Add §6 with reference file table, Endorser contract summary aligned to
TimeSafariNativeFetcher, likely plugin touchpoints, and suggested implementation
order; renumber acceptance checklist to §7.
2026-04-02 17:51:51 +08:00
Jose Olarte III 8290943b53 docs: add New Activity iOS/Android parity guide and refine follow-ups
Add doc/new-activity-notifications-ios-android-parity.md covering dual-schedule
and Endorser API parity, plugin vs app work, Android dual-path notes, prefetch
vs notify ordering on iOS (§3.3), and clarified Phase B JWT pool status on
both platforms. Link the guide from doc/notification-from-api-call.md under the
iOS checklist.
2026-04-01 20:49:02 +08:00
trentlarson bf1ee78025 allow a custom error message to stay on the screen indefinitely 2026-03-29 19:11:49 -06:00
Jose Olarte III 66b7d0f46e docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-03-26 19:41:03 +08:00
Jose Olarte III 63dcf44125 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-03-26 19:40:07 +08:00
trentlarson cf1ecdfb4c add registration for new contacts that are unregistered 2026-03-22 20:20:33 -06:00
trentlarson e9ad61b780 don't delete a gift image on an edit unless they hit 'save' 2026-03-22 20:07:59 -06:00
trentlarson ad8df3eb93 fix problem where canceling an edit deletes an image 2026-03-22 20:06:58 -06:00
trentlarson 05d346edce add project selection for one that this 'fulfills' 2026-03-22 17:58:46 -06:00
trentlarson e259e60fa7 bump version and add "-beta" 2026-03-22 17:39:46 -06:00
trentlarson 821de3f006 do not toggle off the 'advanced' section in account view with the 'general' toggle is disabled 2026-03-22 09:53:56 -06:00
trentlarson 43f83031d4 rename app from "Gifties" to "Giftopia" 2026-03-21 16:27:21 -06:00
trentlarson 688a48a332 bump to version 1.3.12 build 67 2026-03-21 16:22:14 -06:00
trentlarson 8938c242ee change more files to name the app "Gifties" 2026-03-20 19:33:04 -06:00
trentlarson 358af42afd rename from "Gift Economies" to "Gifties" 2026-03-19 21:18:11 -06:00
trentlarson 59c00241b8 add the nearest-neighbor feature to the claim screen 2026-03-19 20:24:09 -06:00
trentlarson 33ec90e571 move the 'discover' page 'starred' word to be on the same level 2026-03-18 19:44:25 -06:00
204 changed files with 17817 additions and 5712 deletions
+2
View File
@@ -18,4 +18,6 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
# Using shared test notify API (no local notify server by default).
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true
+1
View File
@@ -11,3 +11,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
+1
View File
@@ -15,4 +15,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true
-2
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
#
# Husky Commit Message Hook
# Validates commit message format using commitlint
#
. "$(dirname -- "$0")/_/husky.sh"
# Run commitlint but don't fail the commit (|| true)
# This provides helpful feedback without blocking commits
-2
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
#
# Husky Pre-commit Hook
# Runs lint-fix and Build Architecture Guard on staged files
#
. "$(dirname -- "$0")/_/husky.sh"
echo "🔍 Running pre-commit hooks..."
-2
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env bash
#
# Husky Pre-push Hook
# Runs Build Architecture Guard to check commits being pushed
#
. "$(dirname -- "$0")/_/husky.sh"
echo "🔍 Running Build Architecture Guard (pre-push)..."
+1 -1
View File
@@ -1 +1 @@
18.19.0
20.18.1
+1 -1
View File
@@ -1 +1 @@
18.19.0
20.18.1
+20
View File
@@ -0,0 +1,20 @@
# Agent Instructions for crowd-funder-for-time-pwa
## Android Build — Google Play Services / FOSS Compatibility
**Firebase is opt-in. Do NOT enable it accidentally.**
`android/google-services.json` is gitignored and may be present on disk for push notification development, but Firebase is only activated when you explicitly pass `-PfirebaseEnabled` to Gradle:
- **FOSS / APK / Aurora / Zapstore / F-Droid builds**: just `./gradlew assembleRelease` — Firebase stays off even if `google-services.json` is on disk.
- **Firebase / FCM / Play Store builds**: `./gradlew bundleRelease -PfirebaseEnabled` — explicitly opt in.
This guard is in `android/app/build.gradle`. Do NOT change this conditional to activate Firebase unconditionally based on file presence alone — that was the bug that broke FOSS distribution in June 2026.
`google-services.json` is intentionally excluded from git (`android/.gitignore`). Never commit it.
Full details, incident history, and F-Droid notes: `doc/development/android-firebase-gms.md`
## Android Build — MLKit Barcode Scanner
`@capacitor-mlkit/barcode-scanning` depends on `com.google.android.gms:play-services-code-scanner`, which merges `com.google.android.gms.version` into the APK manifest. This is a known long-term issue for strict FOSS/F-Droid builds. For now, the dependency is accepted; barcode scanning simply will not work on GMS-less devices (it fails gracefully at scan time, not at startup). Do not add additional GMS/Firebase dependencies without explicitly acknowledging this trade-off.
+39 -23
View File
@@ -164,6 +164,7 @@ cp .env.example .env.development
# - VITE_DEFAULT_ENDORSER_API_SERVER
# - VITE_DEFAULT_PARTNER_API_SERVER
# - VITE_DEFAULT_IMAGE_API_SERVER
# - VITE_DEFAULT_NOTIFY_API_SERVER
```
#### Platform-Specific Development
@@ -333,11 +334,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 +347,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:
@@ -1126,7 +1127,7 @@ If you need to build manually or want to understand the individual steps:
##### 0. First time (or if dependencies change)
- `pkgx +rubygems.org zsh`
- `pkgx +rubygems.org +pod zsh`
- ... and you may have to fix these, especially with pkgx:
@@ -1140,7 +1141,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 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 70 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.4;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1153,6 +1154,8 @@ Here's prod. Also available: test, dev
npm run build:ios:prod
```
- The first time, it may complain about a bundler install for "missing gems", and you'll want to run the "install" command it gives you.
3.1. Use Xcode to build and run on simulator or device.
- Select Product -> Destination with some Simulator version. Then click the run arrow.
@@ -1360,7 +1363,7 @@ npm run assets:validate
**Source Assets (Required):**
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
- `resources/splash-dark.png` - Dark mode splash source
**Android Resources (Generated):**
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
@@ -1419,8 +1422,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 66/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.1"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 70/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.4"/g' android/app/build.gradle
```
##### 2. Build
@@ -1458,17 +1461,18 @@ cd -
- Setup by adding the app/gradle.properties.secrets file (see properties at top
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
- In app/build.gradle, bump the versionCode and maybe the versionName
- Then `bundleRelease`:
```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true
./gradlew bundleRelease -Dlint.baselines.continue=true -PfirebaseEnabled
cd -
```
... and find your `aab` file at app/build/outputs/bundle/release
* Note that F-Droid builds should omit `-PfirebaseEnabled`.
At play.google.com/console:
- Go to Production or the Closed Testing and either Create Track or Manage Track.
@@ -1603,6 +1607,11 @@ Use the commands above to check and fix code quality issues.
4. **Native Build**: Platform-specific compilation
5. **Package Creation**: APK/IPA generation
`resources/` is the canonical source for app icons and splash screens.
Do not keep a legacy top-level `assets/` directory unless it is intentionally
used: `@capacitor/assets` prioritizes `assets/` over `resources/`, which can
prevent the canonical assets from being discovered.
## Architecture Environment Configuration
### Environment Files
@@ -1648,6 +1657,7 @@ The build system supports multiple environment file patterns for different scena
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
# Platform Configuration
VITE_PLATFORM=web|electron|capacitor
@@ -1667,6 +1677,7 @@ VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZY
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=http://localhost:8080
```
@@ -1677,6 +1688,7 @@ VITE_APP_SERVER=http://localhost:8080
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=https://test.timesafari.app
```
@@ -1687,6 +1699,7 @@ VITE_APP_SERVER=https://test.timesafari.app
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
VITE_APP_SERVER=https://timesafari.app
```
@@ -1714,20 +1727,10 @@ VITE_APP_SERVER=https://timesafari.app
fi
```
2. **Platform-Specific Overrides**
2. **Environment File Loading**
```bash
# scripts/build-android.sh
if [ "$BUILD_MODE" = "development" ]; then
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
fi
```
3. **Environment File Loading**
```bash
# scripts/build-web.sh
# scripts/build-web.sh, build-android.sh, build-ios.sh, build-electron.sh
local env_file=".env.$BUILD_MODE" # .env.development, .env.test, .env.production
if [ -f "$env_file" ]; then
load_env_file "$env_file"
@@ -1739,6 +1742,18 @@ VITE_APP_SERVER=https://timesafari.app
fi
```
3. **Platform-Specific Overrides**
These run last so the platform address wins over the `.env` files.
```bash
# scripts/build-android.sh
if [ "$BUILD_MODE" = "development" ]; then
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
fi
```
4. **Application Usage**
```typescript
@@ -1944,6 +1959,7 @@ The build system supports multiple environment file patterns:
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
# Platform Configuration
VITE_PLATFORM=web|electron|capacitor
+42 -1
View File
@@ -6,9 +6,50 @@ 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.3.8] - 2026
## [?] - 2026
### Added
- Full flow for setting up SMS notifications: phone registration and code
verification, delegated alertSearch JWT batches with a send hour, and
revocation to stop the texts
- Notification debug panel action to mint and upload a push-channel alertSearch
authorization, built on the same batch minter as the SMS channel
### Fixed
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
mode instead of always reporting a production build
- Android and iOS development builds keep their emulator and `--api-ip` API
addresses, which the `.env` files had been overwriting
## [1.4.4] - 2026.06.21
### Changed
- More checks for Firebase so that it won't break, eg in Aurora store.
## [1.4.3] - 2026.06.19
### Removed
- Automatic "Check your starred projects" daily notification
### 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
- Separate Terms & Conditions page (required for SMS campaigns)
### Fixed
- Edits to a 'give' would delete the image
## [1.3.12] - 2026.03.21
### Added
- Device wake-up for notifications
### Changed
- Rename to "Gifties"
## [1.3.7]
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+53 -2
View File
@@ -15,13 +15,43 @@ Quick start:
```bash
npm install
```
### Web
```bash
npm run build:web:dev
```
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
### Android
```bash
npm run build:android:test:run
```
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
### iOS
```bash
npm run build:ios:studio
```
Assumes Xcode and Xcode Command Line Tools are installed.
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
## Tests
```
npm run test:web
# ... or do every check:
npm run test:all
```
## 🛡️ Build Architecture Guard
This project uses **Husky Git hooks** to protect the build system
@@ -89,6 +119,27 @@ VITE_LOG_LEVEL=debug npm run build:web:dev
See [Logging Configuration Guide](doc/logging-configuration.md) for complete details.
## Notification Debug Panel (dev builds)
In non-production bundles (for example `vite dev` or a Vite build whose mode is not `production`), the **Notification Debug Panel** at `/dev/notifications` helps you test FCM token registration, AlertSearch authorization upload, FCM wakeup delivery (`/debug/send-wakeup`), and local schedule inspection on native builds. The app no longer consumes `WAKEUP_PING` to call `/notifications/refresh` or schedule `api_*` notifications.
**Access:** **Account** → enable **Show All General Advanced Functions** → **Notification Debug Panel**.
Key configuration (independent settings):
- **Notification Backend URL** — which notification server receives API calls
- **Test Mode** — `testMode` sent in JSON request bodies (default on)
- **Skip JWT Authentication (Local Development Only)** — omit JWT headers for local unauthenticated backends (default off)
See [doc/notification-debug-panel.md](doc/notification-debug-panel.md) for controls, recommended settings (hosted test server vs local ngrok), and troubleshooting.
Platform-specific end-to-end guides:
- [doc/local-android-testing-ngrok.md](doc/local-android-testing-ngrok.md)
- [doc/local-ios-testing-ngrok.md](doc/local-ios-testing-ngrok.md)
## Database Clearing (development)
### Quick Usage
```bash
# Run the database clearing script
@@ -178,7 +229,7 @@ icon and splash screen generation across all platforms.
### Asset Sources
- **Single source of truth**: `resources/` directory (Capacitor default)
- **Source files**: `icon.png`, `splash.png`, `splash_dark.png`
- **Source files**: `icon.png`, `splash.png`, `splash-dark.png`
- **Format**: PNG or SVG files for optimal quality
### Asset Generation
+19 -9
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 66
versionName "1.4.1"
versionCode 70
versionName "1.4.4"
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 {
@@ -129,11 +130,20 @@ dependencies {
apply from: 'capacitor.build.gradle'
// Firebase / Google Play Services are opt-in. Pass -PfirebaseEnabled to any Gradle command
// to activate Firebase (FCM push notifications). Without this flag the build works on
// F-Droid, Aurora, Zapstore, and plain APK sideloading even when google-services.json
// is present on disk (it is gitignored; see AGENTS.md for the full story).
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
logger.info("Firebase enabled: google-services plugin applied")
} else if (servicesJSON.exists() && !project.hasProperty('firebaseEnabled')) {
logger.info("google-services.json present but firebaseEnabled not set — skipping Firebase plugin (pass -PfirebaseEnabled to enable)")
} else {
logger.info("google-services.json not found — Firebase plugin not applied")
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
logger.info("google-services plugin not applied: ${e.message}")
}
+4 -2
View File
@@ -2,8 +2,8 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
@@ -15,6 +15,8 @@ dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')
+46 -7
View File
@@ -1,13 +1,13 @@
{
"project_info": {
"project_number": "123456789000",
"project_id": "timesafari-app",
"storage_bucket": "timesafari-app.appspot.com"
"project_number": "1094643115061",
"project_id": "pc-api-7249509642322112640-286",
"storage_bucket": "pc-api-7249509642322112640-286.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef",
"mobilesdk_app_id": "1:1094643115061:android:f11bd26f6bd2fcdc887d7c",
"android_client_info": {
"package_name": "app.timesafari.app"
}
@@ -15,7 +15,45 @@
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345"
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1094643115061:android:354e70007466b006887d7c",
"android_client_info": {
"package_name": "ch.endorser.mobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1094643115061:android:40b63cb5851f34ac887d7c",
"android_client_info": {
"package_name": "com.veramo_react_native"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyCFLYeLfGQqh7ErvzXgy74H0Gx3yQAMEw8"
}
],
"services": {
@@ -24,5 +62,6 @@
}
}
}
]
}
],
"configuration_version": "1"
}
@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -16,6 +16,13 @@
]
}
},
"PushNotifications": {
"presentationOptions": [
"badge",
"sound",
"alert"
]
},
"SplashScreen": {
"launchShowDuration": 3000,
"launchAutoHide": true,
@@ -34,12 +41,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 +107,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
@@ -23,6 +23,14 @@
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
},
{
"pkg": "@capacitor/push-notifications",
"classpath": "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
@@ -16,6 +16,7 @@ import android.webkit.WebViewClient;
import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
import app.timesafari.sharedimage.SharedImagePlugin;
import app.timesafari.notifications.NotificationInspectorPlugin;
//import com.getcapacitor.community.sqlite.SQLite;
import android.content.SharedPreferences;
@@ -29,6 +30,7 @@ public class MainActivity extends BridgeActivity {
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
private static final Uri SHARED_PHOTO_DEEP_LINK = Uri.parse("timesafari://shared-photo");
@Override
public void onCreate(Bundle savedInstanceState) {
@@ -66,6 +68,9 @@ public class MainActivity extends BridgeActivity {
// Register SharedImage plugin
registerPlugin(SharedImagePlugin.class);
// Register NotificationInspector plugin (dev tooling; safe no-op on Android)
registerPlugin(NotificationInspectorPlugin.class);
// Register DailyNotification plugin
// Plugin is written in Kotlin but compiles to Java-compatible bytecode
@@ -117,7 +122,9 @@ public class MainActivity extends BridgeActivity {
}
if (imageUri != null) {
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
processSharedImage(imageUri, fileName);
if (processSharedImage(imageUri, fileName)) {
notifySharedPhotoDeepLink();
}
handled = true;
}
}
@@ -134,7 +141,9 @@ public class MainActivity extends BridgeActivity {
imageUris = uris;
}
if (imageUris != null && !imageUris.isEmpty()) {
processSharedImage(imageUris.get(0), null);
if (processSharedImage(imageUris.get(0), null)) {
notifySharedPhotoDeepLink();
}
handled = true;
}
}
@@ -153,10 +162,12 @@ public class MainActivity extends BridgeActivity {
}
/**
* Process a shared image: read it, convert to base64, and write to temp file
* Process a shared image: read it, convert to base64, and write it to SharedPreferences
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
*
* @return true when the image is available to the SharedImage plugin
*/
private void processSharedImage(Uri imageUri, String fileName) {
private boolean processSharedImage(Uri imageUri, String fileName) {
// Extract filename from URI or use default (do this before opening streams)
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
@@ -179,7 +190,7 @@ public class MainActivity extends BridgeActivity {
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
return false;
}
// Read image bytes
@@ -195,21 +206,36 @@ public class MainActivity extends BridgeActivity {
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Store in SharedPreferences for plugin to read
storeSharedImageInPreferences(base64String, actualFileName);
if (!storeSharedImageInPreferences(base64String, actualFileName)) {
return false;
}
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
return true;
} catch (IOException e) {
Log.e(TAG, "Error processing shared image", e);
return false;
} catch (Exception e) {
Log.e(TAG, "Unexpected error processing shared image", e);
return false;
}
}
/**
* Deliver an App-plugin URL event after the shared image is ready. The App plugin retains
* URL-open events until JavaScript registers its listener, covering both cold and warm starts.
*/
private void notifySharedPhotoDeepLink() {
Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, SHARED_PHOTO_DEEP_LINK);
getBridge().onNewIntent(deepLinkIntent);
Log.d(TAG, "Delivered shared-photo deep link to Capacitor");
}
/**
* Store shared image data in SharedPreferences for plugin to read
* Plugin will read and clear the data when called
*/
private void storeSharedImageInPreferences(String base64, String fileName) {
private boolean storeSharedImageInPreferences(String base64, String fileName) {
try {
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
@@ -219,9 +245,11 @@ public class MainActivity extends BridgeActivity {
editor.apply();
Log.d(TAG, "Stored shared image data in SharedPreferences");
return true;
} catch (Exception e) {
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
return false;
}
}
}
}
@@ -98,7 +98,17 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
: ""));
}
/** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
/**
* Picks the pool entry whose validity window covers today, falling back to the
* primary jwtToken when no pool is configured.
*
* <p>Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
* (mintBackgroundJwtTokenPool) files the token for a given day at index
* {@code epochDay % size}. That is why the index below is the raw epoch day rather
* than a count from when the pool arrived: this side keeps no mint date, and the
* same arithmetic on both ends is what lines the slot up with the day it covers.
* A token read from the wrong slot is outside its window and Endorser rejects it.
*/
private String selectBearerTokenForRequest() {
List<String> pool = jwtTokenPool;
if (pool == null || pool.isEmpty()) {
@@ -0,0 +1,16 @@
package app.timesafari.notifications;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "NotificationInspector")
public class NotificationInspectorPlugin extends Plugin {
@PluginMethod
public void getPendingNotifications(PluginCall call) {
call.unimplemented(
"Pending notification inspection is currently implemented on iOS only");
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">TimeSafari</string>
<string name="title_activity_main">TimeSafari</string>
<string name="app_name">Giftopia</string>
<string name="title_activity_main">Giftopia</string>
<string name="package_name">timesafari.app</string>
<string name="custom_url_scheme">timesafari.app</string>
</resources>
@@ -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
}
}
@@ -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 = [:]
+6
View File
@@ -20,6 +20,12 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
+7
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'
}
+4 -4
View File
@@ -2,9 +2,9 @@
"icon": {
"android": {
"adaptive": {
"background": "#121212",
"foreground": "resources/icon.png",
"monochrome": "resources/icon.png"
"background": "resources/android/icon/icon-background.png",
"foreground": "resources/android/icon/icon-foreground.png",
"monochrome": "resources/android/icon/icon-monochrome.png"
},
"target": "android/app/src/main/res"
},
@@ -22,7 +22,7 @@
"scale": "cover",
"target": "android/app/src/main/res"
},
"darkSource": "resources/splash_dark.png",
"darkSource": "resources/splash-dark.png",
"ios": {
"target": "ios/App/App/Assets.xcassets",
"useStoryBoard": true
+4 -4
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
}
@@ -73,7 +73,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
+7 -4
View File
@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
appName: 'Giftopia',
webDir: 'dist',
server: {
cleartext: true
@@ -18,6 +18,9 @@ const config: CapacitorConfig = {
]
}
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert']
},
SplashScreen: {
launchShowDuration: 3000,
launchAutoHide: true,
@@ -36,12 +39,12 @@ const config: CapacitorConfig = {
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 +103,7 @@ const config: CapacitorConfig = {
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
productName: 'Giftopia',
directories: {
output: 'dist-electron-packages'
},
+1 -1
View File
@@ -22,7 +22,7 @@
"scale": "cover",
"target": "android/app/src/main/res"
},
"darkSource": "resources/splash_dark.png",
"darkSource": "resources/splash-dark.png",
"ios": {
"target": "ios/App/App/Assets.xcassets",
"useStoryBoard": true
+2 -4
View File
@@ -61,16 +61,14 @@ The app depends on:
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
```
If the fixes were only made in a **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
If the fixes were only made in a **local clone** and never pushed to **gitea** `master`, then:
- `npm install` / `npm update` in the app would not pull the fixes.
- The app’s `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
**Do this:**
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
- **Push** the fixed plugin to the official gitea repo (`trent_larson/daily-notification-plugin`), then in this app run `npm update @timesafari/daily-notification-plugin` (or set `package.json` to the branch/tag/commit you need), `npm install`, `npx cap sync android`, clean build and reinstall. The app should always depend on the published git remote, not a local `file:` path.
### 3. Fallback text from native fetcher (Bug 2 only)
+40
View File
@@ -125,6 +125,35 @@ view: "details"
All deep links follow the format: `timesafari://<route>/<param>?<query>`
### App Routes
These take no parameters.
- `timesafari://` — no route at all opens the app at the home feed.
- `timesafari://account`
- `timesafari://discover`
- Query params, all optional:
- `searchText`: prefills the search box
- `searchPeople`: any value switches to the people tab
- `hideOnboarding`: "true" suppresses the onboarding prompt
- `timesafari://invite-one`
- `timesafari://new-activity`
- `timesafari://onboard-meeting-list`
- `timesafari://projects`
- `timesafari://recent-offers-to-user`
- `timesafari://recent-offers-to-user-projects`
- `timesafari://search-area`
- `timesafari://share-my-contact-info`
- `timesafari://statistics`
### Help Routes
- `timesafari://help`
- `timesafari://help-notifications`
- `timesafari://help-notification-types`
- `timesafari://help-onboarding`
- `timesafari://help-terms`
### Claim Routes
- `timesafari://claim/:id`
@@ -143,6 +172,17 @@ All deep links follow the format: `timesafari://<route>/<param>?<query>`
- `timesafari://contact-import/:jwt`
- Query params:
- `contacts`: JSON array of contacts
- `timesafari://contact-qr` and `timesafari://contact-qr-scan-full`
Both open the page that displays your contact QR code, scans someone else's,
and prompts you to set your name if your identity does not carry one. The
destination view is chosen by platform, not by which of the two paths was
used: a full-screen camera view where a native scanner is available, an
ordinary page where the in-page web reader is used.
Prefer `contact-qr` when publishing a link. The same path is appended to the
web address by `/deep-link/<path>`, and `/contact-qr` is the web route that
serves this page in a browser.
### Project Routes
+3 -3
View File
@@ -47,7 +47,7 @@ npm run build:android:studio
#### Source Assets (Required)
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
- `resources/splash-dark.png` - Dark mode splash source
#### Android Resources (Generated)
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
@@ -201,13 +201,13 @@ mkdir -p android/app/src/main/res/mipmap-{mdpi,hdpi,xhdpi,xxhdpi,xxxhdpi}
# Copy source assets to assets directory
cp resources/icon.png assets/
cp resources/splash.png assets/
cp resources/splash_dark.png assets/
cp resources/splash-dark.png assets/
# Generate assets manually
npx @capacitor/assets generate
# Clean up
rm assets/icon.png assets/splash.png assets/splash_dark.png
rm assets/icon.png assets/splash.png assets/splash-dark.png
```
## Future Enhancements
+66
View File
@@ -0,0 +1,66 @@
# Android — Firebase, Google Play Services, and FOSS Distribution
## Overview
The app is designed to work on Android devices with and without Google Play Services (GMS). Firebase/FCM push notifications are an opt-in feature at build time; all other functionality works on GMS-less devices (F-Droid, LineageOS without OpenGApps, etc.).
## How the opt-in guard works
`android/app/build.gradle` applies the `com.google.gms.google-services` Gradle plugin only when **both** conditions are true:
1. `android/google-services.json` is present on disk
2. The Gradle property `firebaseEnabled` is explicitly passed
```groovy
if (servicesJSON.exists() && servicesJSON.text && project.hasProperty('firebaseEnabled')) {
apply plugin: 'com.google.gms.google-services'
}
```
This means the file can live on disk for development purposes without accidentally activating Firebase.
## Build commands
| Target | Command | Firebase |
|---|---|---|
| APK / sideload / Zapstore | `./gradlew assembleRelease` | off |
| Aurora / Play Store without FCM | `./gradlew bundleRelease` | off |
| Play Store with FCM push notifications | `./gradlew bundleRelease -PfirebaseEnabled` | on |
| F-Droid | `./gradlew assembleRelease` | off (required) |
## Behavior on non-GMS devices
When built with `-PfirebaseEnabled`, Firebase SDKs check for GMS availability at startup and degrade gracefully if it is absent:
- Firebase initializes but detects no GMS
- FCM skips token registration silently (no token, no notifications)
- The app continues to work normally
This means a single Play Store AAB (`bundleRelease -PfirebaseEnabled`) covers both GMS and non-GMS users. GMS users get push notifications; non-GMS users get a fully functional app without them.
**Aurora Store** pulls the exact APK from Play Store servers, so Aurora users get whichever variant was uploaded. The Play Store AAB built with `-PfirebaseEnabled` is correct for Aurora.
**F-Droid** is stricter: their build policy rejects any APK with GMS dependencies at the binary level, even with graceful degradation. F-Droid submission would require a separate `assembleRelease` build (no flag) and a dedicated F-Droid listing.
## The `google-services.json` file
- Gitignored (`android/.gitignore` line 80) — never commit it
- Contains Firebase project credentials (project number, app ID, API key)
- Safe to leave on disk; has no effect unless `-PfirebaseEnabled` is passed
- Obtain from the Firebase console: Project Settings → Your apps → Android app → Download `google-services.json`
## Known GMS dependency: MLKit barcode scanner
`@capacitor-mlkit/barcode-scanning` unconditionally depends on `com.google.android.gms:play-services-code-scanner` (present since the plugin was first added at v6.0.0). This merges `com.google.android.gms.version` and `GoogleApiActivity` into the APK manifest regardless of the `-PfirebaseEnabled` flag.
Practical impact:
- **GMS devices**: barcode scanning works normally
- **Non-GMS devices**: barcode scanning fails at scan time (not at startup); the app launches and runs normally otherwise
This is an accepted trade-off. Removing it would require either forking the plugin or introducing a `foss` product flavor that excludes the MLKit plugin entirely — work to undertake if/when F-Droid submission is planned.
## Incident: June 2026
Jose Olarte III's `notify-api` branch placed a production `google-services.json` in `android/` to test Firebase Cloud Messaging. The branch was never merged to `master`, but because the file is gitignored it persisted on disk after switching branches. At the time, the Gradle conditional activated Firebase based on file presence alone (no opt-in flag), so all subsequent local builds embedded Firebase and required Google Play Services. This silently broke APK/Aurora/Zapstore distribution.
**Fix applied:** deleted `google-services.json` from disk, changed the Gradle conditional to require `-PfirebaseEnabled`, and documented the rule in `AGENTS.md`.
+17 -6
View File
@@ -128,18 +128,29 @@ Your Android device and computer **must be on the same Wi-Fi network** for the d
### Step 3: Configure API Endpoints
Create or edit `.env.development` with your computer's IP:
Pass your computer's IP to the build with `--api-ip`. The build script points
the claim and partner APIs at that address:
```bash
# .env.development - for physical device testing
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_APP_SERVER=http://192.168.1.100:8080
npm run build:android:dev -- --api-ip 192.168.1.100
```
**Important**: Replace `192.168.1.100` with your actual IP address.
Without `--api-ip`, a development build uses `10.0.2.2:3000`, which reaches the
host machine from an emulator but not from a physical device.
The build script applies `--api-ip` after loading `.env.development`, so the
flag wins for the claim and partner APIs. Other addresses come from that file,
which development web builds share:
```bash
# .env.development
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_APP_SERVER=http://192.168.1.100:8080
```
### Step 4: Start Your Local Server
If testing against local API servers, ensure they're accessible from the network:
+1 -1
View File
@@ -89,7 +89,7 @@ system to a standardized, single-source asset configuration approach using
resources/ # Image sources ONLY
icon.png
splash.png
splash_dark.png
splash-dark.png
config/assets/ # Versioned config & schema
capacitor-assets.config.json
+155
View File
@@ -0,0 +1,155 @@
# Background New Activity JWT pool
How the app credentials native background prefetch for New Activity.
## 1. What the pool is for
Background prefetch runs in WorkManager on Android and a background task on iOS,
with no JavaScript executing. It calls Endorser directly and needs a Bearer JWT
that was minted while the app was awake, possibly days earlier.
The app mints a pool of `BACKGROUND_JWT_POOL_SIZE` JWTs and hands them to the
plugin through `configureNativeFetcher`. Each covers one UTC day, and the native
fetcher picks the one matching the day it runs.
Source: `src/libs/crypto/backgroundJwtPool.ts`,
`src/services/notifications/nativeFetcherConfig.ts`,
`src/constants/backgroundJwt.ts`.
## 2. Token shape
Each token in the pool carries:
| Claim | Value |
|-------|-------|
| `iss` | the minting DID |
| `iat` | mint time |
| `nbf` | its day's opening midnight, minus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
| `exp` | its day's closing midnight, plus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
The slack widens the window at both ends for clock skew between the device and
Endorser. Widening is safe; narrowing can leave a prefetch inside the day with
no usable token.
Two properties follow from the day windows, and both are load-bearing:
- **Each token grants one day.** A token read from a log line or a captured
header buys one day of Endorser access rather than the whole grant.
- **The tokens are distinct.** ES256K signing is deterministic, so JWTs built
from identical payloads are byte-identical. Differing windows are what keep
the pool from collapsing into one string repeated `POOL_SIZE` times, which
would defeat any duplicate-token rule the server applies.
## 3. Slot ordering
Both native fetchers select with `pool[epochDay % pool.size()]` and hold no
record of when the pool was minted. The minter therefore files the token
covering a given UTC day at index `epochDay % BACKGROUND_JWT_POOL_SIZE`.
Any `POOL_SIZE` consecutive days hit every index exactly once, so the array is
dense whatever day minting starts on.
This is a contract across three languages. Changing the index arithmetic on one
side without the others produces tokens presented outside their windows, which
Endorser rejects with no local error. `src/test/backgroundJwtPool.test.ts`
asserts the invariant by replaying the native selector against the minted pool.
Implementations: `TimeSafariNativeFetcher.selectBearerTokenForRequest` in
`android/app/src/main/java/app/timesafari/` and `ios/App/App/`.
## 4. Identities that can mint
Seed-phrase (`did:ethr`) identities only.
Passkey (`did:peer`) identities raise
`BackgroundJwtUnsupportedIdentityError`. Each of their signatures is a WebAuthn
assertion, so minting a pool would raise one biometric prompt per token, and
`createJwtNavigator` overrides the day window with a one-minute `exp` — the
tokens would expire long before the prefetch they were minted for.
`configureNativeFetcherIfReady` catches the error and leaves prefetch
unconfigured.
The delegated alertSearch batch rejects the same identities, with the
notify-api answering `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
## 5. Lifecycle
| Event | Action |
|-------|--------|
| App foreground, startup, notification-time change | `configureNativeFetcherIfReady` mints a pool and configures the fetcher |
| Active identity changes (`$setActiveDid`) | `clearNativeFetcherPool` drops the pool the fetcher holds |
The identity is decrypted once per mint and reused for every signature.
Decrypting per token costs seconds on a phone, and minting runs on every
foreground.
## 6. What the pool bounds
The grant is `BACKGROUND_JWT_POOL_SIZE` days wide and each token inside it is
one day wide.
`clearNativeFetcherPool` is custody, not revocation. Endorser exposes no
revocation mechanism, so a token that left the device before the clear stays
valid until its window closes. What the clear bounds is the ordinary case — an
account switch, a sign-out, a shared or lost handset — where no copy was taken
and the device's own store is the only remaining exposure.
Anti-replay in the strict sense is unavailable on this path. It would require
either a server-side one-time-use store, which Endorser does not offer, or
per-request signing, which would put the private key in native code. Day-scoped
windows narrow the exposure instead of eliminating it.
## 7. Constants
All in `src/constants/backgroundJwt.ts`.
| Constant | Meaning |
|----------|---------|
| `BACKGROUND_JWT_POOL_SIZE` | Consecutive UTC days the pool covers, one token each. The whole forward grant a user authorizes per mint. |
| `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` | Padding on each end of a day window, for clock skew. |
| `BACKGROUND_JWT_SECONDS_PER_DAY` | The day frame each slot is cut from. |
| `BACKGROUND_JWT_EXPIRY_DAYS` / `_SECONDS` | Lifetime for the single-token background path. |
Past the last covered day the pool carries no credential and prefetch stops
until the app opens again.
## 8. A different credential
The notify-api's delegated alertSearch batch
(`src/services/notifications/alertAuthorizationBatch.ts`) is a separate
credential with a separate inventory. It authorizes the notification service to
run a user's daily alertSearch server-side; this pool authorizes the user's own
device to prefetch. They share the day-window shape and nothing else. See
`notification-wakeup-service/README.md`.
## 9. Rejected
- **A unique `jti` per slot, with one shared long `exp`.** A `jti` is an
identifier, not a replay defense: it does nothing unless the server keeps a
seen-set, and Endorser's behavior here was never confirmed. Day windows make
the tokens distinct for the same cost while also bounding each one.
- **One long-lived token instead of a pool.** Fails if Endorser rejects
duplicate JWT strings across days. That policy question is open (§10), so the
design does not depend on the answer.
- **Sizing the pool as `expiryDays + buffer`.** The rationale was headroom for
duplicate-token rules. With one token per day, the pool size is the grant
length in days and needs no separate buffer term.
- **Per-request signing in native code (DPoP-style).** The only true anti-replay
option, rejected to keep one signing implementation in TypeScript rather than
forking crypto into Java and Swift.
- **Routing all New Activity through the notification service and deleting this
path.** Rejected: it would force every user onto server-side delegation, and
the service is single-replica, so the direct device-to-Endorser path has no
equivalent.
## 10. Open questions
- **Endorser duplicate-JWT policy.** Whether Endorser rejects a Bearer JWT
string it has already seen is unconfirmed. The pool is correct either way; the
answer would determine whether a pool is required at all.
- **Maximum `exp` Endorser accepts.** Day-scoped windows are well inside any
plausible limit, so this gates only the single-token path.
- **Plugin behavior on an empty pool.** `clearNativeFetcherPool` passes empty
credentials to `configureNativeFetcher`. A plugin build that rejects them
leaves the previous pool in place; the failure is logged rather than reported
as a successful clear.
@@ -0,0 +1,80 @@
# Consuming app handoff: iOS native fetcher + chained dual (mirror)
**Canonical source:** `daily-notification-plugin` repo, `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` (same content as below for offline use).
---
## Implemented in this app
- **`ios/App/App/TimeSafariNativeFetcher.swift`** — Swift `NativeNotificationContentFetcher` mirroring `TimeSafariNativeFetcher.java` (`POST …/plansLastUpdatedBetween`, starred IDs from `daily_notification_timesafari.starredPlanIds`, JWT pool selection, pagination key `daily_notification_timesafari.last_acked_jwt_id`, aggregated copy).
- **`AppDelegate.swift`** — `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any JS `configureNativeFetcher`; foreground handler reads `scheduled_time` as `Int64`, `NSNumber`, or `Int` for `DailyNotificationDelivered`.
## Dependency
- **`@timesafari/daily-notification-plugin`** must be **≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Declare it in `package.json` from the official remote (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`, branch or tag as needed), then `npm install` so `package-lock.json` resolves the published tree.
## Bump / sync (after plugin version is resolved)
1. `npm install`
2. `npx cap sync ios && npx cap sync android`
3. `cd ios/App && pod install`
4. Clean build in Xcode / Android Studio
## QA focus
- iOS: Fetcher registered before `configureNativeFetcher`; `updateStarredPlans` not `UNIMPLEMENTED`.
- Both: New Activity fires **after** prefetch for that cycle where the plugin implements chaining.
- Android: Existing `MainActivity.setNativeFetcher` unchanged; regression-test `cancelDualSchedule` vs Daily Reminder.
---
## Original handoff text (from plugin)
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
- **iOS** `NativeNotificationContentFetcher`–style registration (`DailyNotificationPlugin.registerNativeFetcher`)
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; the plugin doc adds **app-side** steps not spelled out there.
### 1. iOS — register native fetcher before `configureNativeFetcher`
The plugin **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
```swift
import TimesafariDailyNotificationPlugin
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
```
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
- Conforms to `NativeNotificationContentFetcher`
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java`
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS).
### 2. iOS — `UNUserNotificationCenterDelegate` / rollover
Chained dual notifications set:
- `notification_id` = `org.timesafari.dailynotification.dual`
- `scheduled_time` = `NSNumber` (fire time in ms)
Ensure **`DailyNotificationDelivered`** forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`**.
### 3. Android — no API change for `setNativeFetcher`
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` keep that flow.
**Behavior change:** the dual **notify** alarm is scheduled when **dual prefetch work finishes**, not at the initial `scheduleDualNotification` only.
### 4. Assumptions
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
- Module import: `TimesafariDailyNotificationPlugin` (Pod `TimesafariDailyNotificationPlugin`).
+1 -2
View File
@@ -6,8 +6,7 @@
2. **Notifications show when the app is in the foreground** (not only background/closed).
3. **Plugin loads at app launch** so recovery runs after reboot without the user opening notification UI.
**Reference:** Test app at
`/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin/test-apps/daily-notification-test`
**Reference:** In the **daily-notification-plugin** repository, the test app lives at `test-apps/daily-notification-test` (same repo as `https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin`).
---
+308
View File
@@ -0,0 +1,308 @@
# Deep Link Debugging Guide for TimeSafari
This guide helps you debug and fix deep link issues in the TimeSafari Capacitor application.
## Quick Start
1. **Check logs**: Use browser dev tools or device console to see detailed logging
2. **Test manually**: Use the testing script or browser console commands
3. **Verify configuration**: Ensure all platform configurations are correct
## Common Issues and Solutions
### Issue 1: App opens but doesn't navigate to the correct page
**Symptoms:**
- Deep link opens the app
- App stays on home screen or current page
- No error messages visible
**Debugging Steps:**
1. **Check console logs** in browser dev tools or device console:
```bash
# Android
adb logcat | grep -E "(TimeSafari|DeepLink|appUrlOpen)"
# iOS Simulator
xcrun simctl spawn booted log stream --predicate 'process == "TimeSafari"'
```
2. **Verify listener registration**:
Look for these log messages:
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
```
3. **Check for event reception**:
Look for:
```
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://your/url/here
```
4. **Verify URL parsing**:
Check if URL components are parsed correctly:
```
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"123"},"query":{}}
```
### Issue 2: Listener not receiving events
**Symptoms:**
- No deep link logs appear
- App opens but no event processing
**Solutions:**
1. **Rebuild and reinstall** the app completely:
```bash
npm run build:capacitor
npx cap sync
npx cap run android # or ios
```
2. **Check capacitor.config.json**:
```json
{
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
}
}
}
```
3. **Verify native configuration**:
- **Android**: Check `android/app/src/main/AndroidManifest.xml`
- **iOS**: Check `ios/App/App/Info.plist`
### Issue 3: URL scheme not recognized by OS
**Symptoms:**
- "No app found to handle this link" error
- OS doesn't open your app
**Solutions:**
1. **Android**: Verify intent filter in AndroidManifest.xml:
```xml
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
```
2. **iOS**: Verify URL types in Info.plist:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
```
## Testing Tools
### 1. Automated Testing Script
Use the provided testing script:
```bash
# Test all URLs on Android
./scripts/test-deep-links.sh android
# Test specific URL on iOS
./scripts/test-deep-links.sh ios "timesafari://claim/test123"
# Monitor logs
./scripts/test-deep-links.sh android-logs
```
### 2. Manual Testing Commands
**Android Emulator:**
```bash
adb shell am start -W -a android.intent.action.VIEW -d "timesafari://claim/test123" app.timesafari
```
**iOS Simulator:**
```bash
xcrun simctl openurl booted "timesafari://claim/test123"
```
### 3. Browser Console Testing
For web/PWA testing, use the browser console:
```javascript
// Test deep link processing directly
window.testSingleDeepLink("timesafari://claim/test123");
// Run all test URLs
window.testDeepLinks();
```
## Debugging Steps Checklist
### Pre-Testing Setup
- [ ] App is installed on device/emulator
- [ ] App has been launched at least once
- [ ] Device/emulator is properly connected
- [ ] Debugging tools are accessible
### During Testing
- [ ] Check console for initialization logs
- [ ] Verify listener registration
- [ ] Test with simple URL first (e.g., `timesafari://claim/test`)
- [ ] Monitor URL parsing logs
- [ ] Check router navigation logs
### Post-Testing Analysis
- [ ] Review complete log sequence
- [ ] Identify where process fails
- [ ] Check error messages for clues
- [ ] Test with different URL formats
## Common Log Patterns
### Successful Deep Link Flow
```
[DeepLink] Registering appUrlOpen listener...
[DeepLink] Listener registered successfully
[DeepLink] ========== DEEP LINK EVENT RECEIVED ==========
[DeepLink] URL: timesafari://claim/test123
[DeepLinkHandler] Starting handleDeepLink with URL: timesafari://claim/test123
[DeepLinkHandler.parseDeepLink] Parse result: {"path":"claim","params":{"id":"test123"},"query":{}}
[DeepLinkHandler.validateAndRoute] Route validation passed. Route name: claim
[DeepLinkHandler.validateAndRoute] Router navigation completed successfully
[DeepLink] Deep link handled successfully
```
### Failed URL Parsing
```
[DeepLinkHandler.parseDeepLink] Route not found: invalid-route
[DeepLinkHandler.parseDeepLink] Available routes: ["claim","project","contact-import",...]
[DeepLinkHandler.validateAndRoute] Redirecting to deep-link-error page
```
### Router Navigation Issues
```
[DeepLinkHandler.validateAndRoute] Error routing to route name claim
[DeepLinkHandler.validateAndRoute] Navigation params: {"name":"claim","params":{"id":"test123"}}
```
## Platform-Specific Issues
### Android
**Issue**: Deep links work in development but not in production build
- **Solution**: Ensure `android:exported="true"` in MainActivity
**Issue**: App doesn't respond to links when running in background
- **Solution**: Check `android:launchMode="singleTask"` in AndroidManifest.xml
### iOS
**Issue**: Deep links don't work in iOS simulator
- **Solution**: Use `xcrun simctl openurl` instead of opening URLs in Safari
**Issue**: App launches but doesn't process URL
- **Solution**: Check for Associated Domains if using universal links
## Advanced Debugging
### Enable Capacitor Native Logging
Add to `capacitor.config.json`:
```json
{
"ios": {
"loggingBehavior": "debug"
},
"android": {
"loggingBehavior": "debug"
}
}
```
### Add Custom Debug Points
Insert additional logging in your deep link handler:
```typescript
// Add at strategic points in DeepLinkHandler
console.log('[DEBUG] Custom checkpoint:', { data: yourData });
```
### Network Debugging
If deep links involve network requests:
```bash
# Monitor network traffic (Android)
adb shell dumpsys connectivity
# Monitor network traffic (iOS)
# Use Xcode Network Debugger
```
## Recovery Strategies
### If deep links stop working completely:
1. **Clean rebuild**:
```bash
rm -rf node_modules
npm install
npm run build:capacitor
npx cap sync
```
2. **Reset device/emulator**:
- Clear app data
- Uninstall and reinstall
- Restart emulator
3. **Verify basic functionality**:
- Test simple navigation within app
- Test URL schemes with minimal URLs
- Gradually increase complexity
## Support Resources
- [Capacitor Deep Links Documentation](https://capacitorjs.com/docs/guides/deep-links)
- [Android Intent Filter Guide](https://developer.android.com/guide/components/intents-filters)
- [iOS URL Scheme Guide](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app)
## Contact and Feedback
If you encounter issues not covered in this guide:
1. Check the project's issue tracker
2. Review recent commits for deep link changes
3. Test with minimal reproduction case
4. Document exact steps and environment details
+401
View File
@@ -0,0 +1,401 @@
# Android Local Notification Testing — Planning Analysis
**Created:** 2026-06-02
**Source document:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
**Status:** Planning snapshot from 2026-06-02. Several iOS-guide assumptions (app **Refresh Notifications**, Simulate WAKEUP, `applyNotificationRefreshPayload`, WAKEUP_PING → `/notifications/refresh` → `api_*`) are **retired**. For current Android procedure see [local-android-testing-ngrok.md](./local-android-testing-ngrok.md). Do not treat the reuse tables below as the live app path.
**Purpose:** Plan a future **Android** counterpart guide by mapping what can be reused from the iOS ngrok workflow and what must be written for Android-specific push, permissions, and OS behavior.
---
## Executive summary
The iOS guide’s **backend + ngrok + in-app debug panel** path is platform-agnostic. Most of sections **1–3**, **6**, **9** (with log tooling swapped), **10** (with `platform: "android"`), **12**, and parts of **11** can be copied or lightly edited.
Everything involving **APNs, Xcode, Apple Developer, iOS capabilities, and iOS background/silent-push caveats** must be replaced. Android adds **direct FCM delivery** (no APNs hop), **`google-services.json`**, **runtime notification permissions (API 33+)**, **Doze / battery optimization / OEM restrictions**, and different **force-stop / background** semantics.
Existing related docs to cross-link (not duplicate):
- [android-physical-device-guide.md](./android-physical-device-guide.md) — USB, `adb`, build/run commands
- [notification-system-overview.md](./notification-system-overview.md)
- [notification-from-api-call.md](./notification-from-api-call.md)
- [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md)
---
## iOS guide structure (reference map)
| § | iOS doc heading | Reuse for Android |
|---|-----------------|-------------------|
| Intro | Architecture overview | **Adapt** — swap APNs leg for FCM→device |
| — | Prerequisites | **Partial** — drop Xcode/APNs; add Android SDK/device |
| 1 | Install and configure ngrok | **Reuse unchanged** |
| 2 | Start the backend locally | **Reuse unchanged** |
| 3 | Obtain and use ngrok HTTPS URL | **Reuse** — wording: “device” not “iPhone” |
| 4 | Generate and open iOS workspace | **Rewrite** — Android Studio / Capacitor sync |
| 5 | Firebase + APNs setup | **Rewrite** — Firebase Android only; no APNs |
| 6 | Notification Debug Panel override | **Reuse unchanged** |
| 7 | Firebase and Xcode checklist | **Rewrite** — Android manifest / Gradle checklist |
| 8 | iOS-specific testing notes | **Rewrite** — Android delivery caveats |
| 9 | Recommended debug workflow | **Reuse** — replace Xcode console with logcat |
| 10 | Sample curl commands | **Reuse** — change `platform` to `android` |
| 11 | Troubleshooting | **Partial** — keep ngrok/API rows; replace push rows |
| 12 | Key source files | **Reuse unchanged** |
| 13 | Related docs | **Extend** — link Android build/device guides |
---
## Sections reusable unchanged (or near-unchanged)
These blocks can be carried into `doc/local-android-testing-ngrok.md` (proposed name) with at most global find-replace (“iPhone” → “Android device”, “Mac” tunnel audience unchanged).
### notification-wakeup-service startup (iOS §1 Terminal A, §2)
- Clone **notification-wakeup-service**, `npm install`, `.env` from `.env.example`
- `export PORT=3000` (or port from that repo’s README)
- `npm run dev`
- Local verify: `curl -sS http://localhost:3000/health`
- Firebase **Admin** service account for the backend (`GOOGLE_APPLICATION_CREDENTIALS`) — same project can serve iOS and Android apps
### ngrok setup (iOS §1)
- `brew install ngrok/ngrok/ngrok` (or download)
- `ngrok http 3000` in a second terminal
- Use **HTTPS** forwarding URL; free tier URL rotation note
- ngrok inspect UI at `http://127.0.0.1:4040`
### ngrok account creation (iOS §1 “Account and auth token”)
- Sign up at dashboard.ngrok.com
- `ngrok config add-authtoken YOUR_AUTHTOKEN_HERE`
### Obtaining HTTPS URL (iOS §3)
- Copy `https://….ngrok-free.app` from Forwarding line
- No trailing slash in debug panel
- Mac-side tunnel test: `export NGROK_URL=…` and `curl "$NGROK_URL/health"`
### Backend override configuration (iOS §6)
- Non-production build required for Notification Debug Panel
- Account → **Show All General Advanced Functions** → `/dev/notifications`
- **Notification Backend URL**, **Save Backend URL**
- `localStorage`: `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, `notificationDebug.bypassAuth`
- Optional programmatic override via `@/services/notifications` (`setBackendBaseUrl`, `setTestMode`, `setBypassAuth`, `getNotificationApiBaseUrl`)
### Debug panel usage (iOS §6 table, §8 “Two Simulate WAKEUP_PING buttons”)
| Control | Android relevance |
|---------|-------------------|
| Notification Backend URL | Same |
| Test Mode | Same (`testMode` in JSON body) |
| Skip JWT Authentication | Same — explicit opt-in for unauthenticated local backends (default off) |
| Register Token Now | Same (`POST /notifications/register`) |
| Refresh Notifications | Same |
| Simulate WAKEUP_PING (backend) | Same — isolates ngrok + refresh without FCM |
| Wakeup Ping Simulator | Same — exercises `handleCapacitorPushNotificationReceived` path |
| Event Log `[Notifications]` | Same |
| Pending Notification Inspector | Same concept; confirm Android plugin inspector behavior in **daily-notification-plugin** |
### testMode usage (iOS §6, §10)
- Default-on when unset in storage (`NotificationDebugConfig.ts`)
- Sent on register and refresh payloads
- Backend/debug endpoints accept `testMode: true` for dev traffic
### Refresh endpoint testing (iOS §9 steps 5, §11 “Refresh endpoint unreachable”)
- Panel **Refresh Notifications** → expect Event Log + ngrok `POST /notifications/refresh`
- **Simulate WAKEUP_PING** (backend button) for API-only path
- Troubleshooting table for network error, 404, wrong port, stale URL
### curl examples (iOS §10)
Reuse structure; **only payload deltas** for Android doc:
```bash
export BASE="https://abc123.ngrok-free.app"
```
- `$BASE/health` — unchanged
- `$BASE/notifications/register` — set `"platform": "android"`
- `$BASE/notifications/refresh` — set `"platform": "android"`
- `$BASE/debug/send-wakeup` — unchanged shape; confirm deviceId/token contract in **notification-wakeup-service** README
App still uses `Capacitor.getPlatform()` for `platform` in `NotificationService.ts` (`ios` | `android`).
### Shared architecture concepts (intro + silent wake sequence)
Reusable narrative (edit diagram only):
1. FCM **data** message with `data.type = "WAKEUP_PING"`
2. Capacitor `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`
3. `POST {backend}/notifications/refresh` with `testMode`
4. `nextNotifications` → `applyNotificationRefreshPayload()` → **daily-notification-plugin** clear + schedule
Repos table (notification-wakeup-service, crowd-funder-for-time-pwa, daily-notification-plugin) — unchanged.
### Key source files (iOS §12)
Same files apply on Android Capacitor builds:
- `NotificationDebugConfig.ts`, `NotificationDebugEvents.ts`, `notificationLog.ts`
- `NotificationService.ts`, `NativeNotificationService.ts`
- `firebaseMessagingClient.ts`, `NotificationDebugPanel.vue`, `main.capacitor.ts`
### Recommended debug workflow (iOS §9) — reuse with tooling swap
Steps 1–5, 8–9 unchanged. Replace step 7:
- **iOS:** Xcode console → `[Notifications] pushNotificationReceived type=WAKEUP_PING`
- **Android:** `adb logcat` filtered on app tag / `[Notifications]` (document exact filter in Android guide)
---
## iOS-specific sections — must rewrite for Android
### Architecture diagram (intro)
**iOS today:** Mac → ngrok → app; FCM → **APNs** → iPhone.
**Android doc:** FCM → **device directly** (no APNs). Update ASCII diagram and caption (“silent push” on Android is still FCM data; delivery rules differ).
### Prerequisites (intro list)
| iOS prerequisite | Android replacement |
|------------------|---------------------|
| Mac with **Xcode** | **Android Studio**, JDK 17+, `ANDROID_HOME`, `adb` — see [android-physical-device-guide.md](./android-physical-device-guide.md) |
| Physical **iPhone** | Physical **Android** device (emulator possible for some steps but **not** representative for Doze/OEM/battery) |
| Firebase with **APNs** for bundle ID | Firebase with **Android app** (`app.timesafari` package name) |
| Non-production build | Same — e.g. `build:android:dev` / `build:android:test` |
Remove: “simulator is not sufficient for reliable silent push / **APNs**”.
Add: emulator vs physical device guidance for FCM and background limits.
### §4 — Generate and open the iOS workspace
**Replace entirely** with Android equivalent:
- `npm install`
- `npm run build:android:dev` or `build:android:test` (non-production for debug panel)
- `npx cap sync android` if needed
- Open `android/` in Android Studio
- Run on physical device (USB debugging)
- `VITE_FIREBASE_*` in Capacitor web build
- `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` — same entry point
Do **not** reference `.xcworkspace`, signing in Xcode, or `build:ios:*` except as cross-link to iOS doc.
### §5 — Firebase + APNs setup (first-time setup)
**Keep (Android-relevant portions only):**
- Firebase account / Spark plan sufficient for FCM
- Create Firebase project
- **Register Android app** in Firebase (package name `app.timesafari` from `capacitor.config.ts`)
- Download **`google-services.json`** → `android/app/` (project may gitignore this file — document secure handling)
- Firebase Admin service account for **notification-wakeup-service** — same as iOS §5 tail
**Remove entirely:**
- Register **iOS** app in Firebase (or move to “shared project” sidebar: one Firebase project, two apps)
- **GoogleService-Info.plist** / Xcode drag-and-drop
- **Create APNs Authentication Key** (.p8)
- **Upload APNs key to Firebase**
- **Enable iOS capabilities** (Push Notifications, Background Modes → Remote notifications)
**Add in Android guide (see next major section):**
- Gradle plugin / `google-services` classpath if not already in repo
- `POST_NOTIFICATIONS` permission (API 33+)
- Default notification channel / Capacitor Push Notifications Android setup
- SHA-1/SHA-256 only if using Firebase features that require it (note whether wakeup testing needs Play App Signing keys)
### §5 verify checklist — iOS-only bullets
Replace:
- “Xcode without Firebase/plist errors” → Android Studio build; `google-services.json` present
- “iOS push permission prompt” → Android 13+ notification permission + older grant model
- “content-available style payload” → Android **high-priority data message** / FCM options as implemented by **notification-wakeup-service** (document actual payload; no APNs `content-available`)
### §7 — Firebase and Xcode checklist (iOS)
**Replace** with Android checklist, e.g.:
| Item | Action |
|------|--------|
| **Application ID** | `app.timesafari` in `capacitor.config.ts`, `android/app/build.gradle`, Firebase Android app |
| **google-services.json** | In `android/app/`; not committed if gitignored — local copy per developer |
| **Gradle** | Google services plugin applied (verify repo’s current `build.gradle`) |
| **Permissions** | `POST_NOTIFICATIONS` (API 33+); manifest entries for FCM |
| **FCM token** | Debug panel **Register Token Now** + ngrok `POST /notifications/register` |
| **No APNs** | N/A on Android |
### §8 — iOS-specific testing notes
**Replace** with Android-specific sections (draft topics below). Do not port:
- APNs silent delivery / Simulator unreliability (iOS framing)
- **Force-quit** via app switcher (iOS-specific policy)
- **Low Power Mode** (iOS) — Android has different battery saver APIs
- **Focus / Do Not Disturb** (iOS naming)
Port with Android wording:
- Two **Simulate WAKEUP_PING** buttons table — unchanged behavior
### §11 — Troubleshooting (partial)
**Reuse as-is:**
- Refresh endpoint unreachable (ngrok, URL, 404, CORS note)
- Stale ngrok URL
- Plugin / JWT errors after refresh
**Rewrite:**
| iOS troubleshooting | Android replacement |
|----------------------|---------------------|
| Push permission + `VITE_FIREBASE_*` + **Xcode** log | Permission (runtime POST_NOTIFICATIONS), logcat, Firebase Android config |
| Silent push not waking — **backgrounded not force-quit**, **APNs key**, wait 30–120s | FCM high-priority data, **force-stop** (`STOP` from settings), **Doze**, battery optimization, OEM autostart, token mismatch |
| Physical device + provisioning profile | USB debugging, correct build variant, Play vs debug signing if relevant |
### §13 — Related docs
Keep iOS-centric links as “see also”; add:
- [android-physical-device-guide.md](./android-physical-device-guide.md)
- `BUILDING.md` — Android build commands (`build:android:*`)
- **daily-notification-plugin** Android docs (exact alarm, pending inspector on Android)
---
## Android-Specific Topics Required
These sections do not exist in the iOS guide (or exist only by analogy) and must be written for the Android notification testing doc.
### Firebase project setup
- Use the **same** Firebase project as iOS when testing the same backend, or document a dedicated `timesafari-dev` project.
- Add an **Android** app with package name **`app.timesafari`**.
- Enable **Cloud Messaging** (default on new projects).
- Download **`google-services.json`** and install under `android/app/`.
- Note: `android/.gitignore` may exclude `google-services.json` — developers copy locally; never commit secrets.
### google-services.json
- Placement: `android/app/google-services.json`
- Sync after add: `npx cap sync android`, rebuild in Android Studio
- Verify build merges Firebase config (no “missing google-services” Gradle errors)
- Relationship to `VITE_FIREBASE_*` for the web layer / Capacitor JS Firebase initialization
### Android notification permissions
- **Android 13+ (API 33):** `POST_NOTIFICATIONS` runtime permission — required for notification **display**; document interaction with **data-only** FCM wake (may still deliver to app code when permission denied — verify against current app behavior and document accurately).
- **Android 12 and below:** install-time grant model; fewer runtime prompts.
- App Settings → Notifications — manual enable path for testers.
- Link [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md) for product-level permission UX.
### FCM token handling
- Token obtained via Capacitor Push Notifications + `firebaseMessagingClient.ts` (same JS path as iOS).
- **Register Token Now** in debug panel → `POST /notifications/register` with `platform: "android"`.
- Token rotation: when to re-register; duplicate skip behavior in panel.
- Ensure **notification-wakeup-service** stores/sends to the token shown in the panel for `/debug/send-wakeup`.
- Optional: `adb` cannot easily read FCM token — panel is source of truth (same as iOS).
### Android background delivery behavior
- FCM **data** messages handled in foreground/background per Capacitor plugin and `NativeNotificationService.ts`.
- No APNs intermediary — document expected latency vs iOS.
- **High-priority** FCM for wakeup testing (align with backend message options).
- App in **background** vs **foreground** vs **killed** — different from iOS “swipe away” story:
- **Force stop** (Settings → Force stop): delivery often blocked until user launches app again (stricter than iOS “backgrounded”).
- **Recent apps swipe**: behavior varies by OEM/Android version — document “test with Home button background, not force stop.”
- `pushNotificationReceived` / listener registration at startup (`main.capacitor.ts`).
### Doze Mode
- Device idle → deferred network and job execution.
- Testing: use `adb shell dumpsys deviceidle` (document safe dev-only commands) or unplugged idle wait.
- Explain why `/debug/send-wakeup` may succeed on server but device wakes late.
- Whitelisting app for tests (developer settings) — use cautiously; note production users won’t do this.
### Battery optimization
- Settings → Apps → TimeSafari → Battery → **Unrestricted** vs **Optimized**.
- Manufacturer “battery saver” modes that restrict background network.
- Recommend **Unrestricted** (or equivalent) for local wakeup validation; warn that production users may remain optimized.
### OEM restrictions (Samsung, Xiaomi, Oppo, etc.)
- **Autostart** / **Background activity** / **Battery** menus on Samsung, Xiaomi (MIUI), Oppo/ColorOS, Huawei, OnePlus, etc.
- Symptom: FCM works on Pixel but not on OEM device until autostart enabled.
- Provide a short “if wake fails on OEM, check…” checklist without exhaustive per-OEM screenshots (link community docs if needed).
- Physical device testing should include at least one **stock-ish** device (Pixel) and one **OEM** device when possible.
---
## Proposed outline for `doc/local-android-testing-ngrok.md`
Suggested section order mirroring iOS doc for easy maintenance:
1. Title, audience, goal (Android physical device + ngrok + wakeup service)
2. Architecture overview (FCM direct to Android)
3. Prerequisites (Android Studio, device, Firebase Android app, non-prod build)
4. ngrok install, account, tunnel (**reuse iOS §1**)
5. Start notification-wakeup-service (**reuse iOS §2**)
6. ngrok HTTPS URL (**reuse iOS §3**)
7. Build and open Android project (**new**, replaces iOS §4)
8. Firebase setup for Android (**new**, replaces iOS §5 — no APNs)
9. Notification Debug Panel (**reuse iOS §6**)
10. Android configuration checklist (**new**, replaces iOS §7)
11. Android-specific testing notes (**new**, replaces iOS §8)
12. Recommended debug workflow (**reuse iOS §9** + logcat)
13. Sample curl commands (**reuse iOS §10** + `platform: "android"`)
14. Troubleshooting (**merge reusable + Android push rows**)
15. Key source files (**reuse iOS §12**)
16. Related docs (**iOS doc + Android device guide + BUILDING**)
---
## Wording and terminology substitutions
When adapting reused sections:
| iOS doc term | Android doc term |
|--------------|------------------|
| iPhone | Android phone / device |
| Xcode console | logcat / Android Studio Logcat |
| `build:ios:dev` / `test` | `build:android:dev` / `test` |
| `GoogleService-Info.plist` | `google-services.json` |
| APNs / silent push | FCM data message / high-priority data |
| Bundle ID | Application ID / package name (`app.timesafari`) |
| Physical iPhone required for APNs | Physical device strongly recommended for Doze/OEM/FCM realism |
| `platform: "ios"` in curl | `platform: "android"` |
---
## Gaps to resolve while writing the Android guide
Research during authoring (code + **notification-wakeup-service** + **daily-notification-plugin**):
1. Exact FCM Android message priority and payload fields for `WAKEUP_PING` (parity with iOS data message).
2. Whether `POST_NOTIFICATIONS` denial blocks data message delivery to JS listeners on API 33+.
3. Gradle/Firebase plugin versions already in `android/` — document exact files to touch.
4. Android **Pending Notification Inspector** parity with iOS panel section.
5. Whether emulator with Google Play image is acceptable for minimal FCM smoke tests vs mandatory physical device for wakeup SLA testing.
---
## Document maintenance
| Document | Role |
|----------|------|
| [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) | Canonical iOS + ngrok workflow (unchanged by this analysis) |
| **This file** | Reuse vs rewrite matrix and Android topic backlog |
| *Future* `local-android-testing-ngrok.md` | Operator guide for Android testers |
When backend or debug panel behavior changes, update **both** platform guides’ shared sections in lockstep (or extract shared “ngrok + debug panel” snippet later — out of scope unless requested).
+970
View File
@@ -0,0 +1,970 @@
# Local Android Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise FCM token registration and FCM wakeup **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical Android device.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend. Local schedules come from Static Daily Reminder, New Activity / dual, and the native fetcher.
**See also:** [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) for the iOS workflow (APNs, Xcode). [android-physical-device-guide.md](./android-physical-device-guide.md) for USB debugging and build/run commands.
---
## Architecture overview
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical Android phone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
│ notification- │ tunnel │ (public HTTPS URL) │
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
│ │ time-pwa (Capacitor │
│ │ Android on device) │
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──FCM────────► │ Android device │
│ Messaging │ direct │ app.timesafari.app │
└─────────────────────┘ └──────────────────────┘
```
Unlike iOS, Android does **not** use APNs. FCM delivers directly to the app via Google Play services on the device.
### Repos and responsibilities
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` for the backend |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Android wakeup flow (current)
1. **notification-wakeup-service** `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. FCM delivers to the device (best-effort; see [Android Platform Notes](#8-android-platform-notes) and [Battery Optimization Caveats](#9-battery-optimization-caveats)).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()` in `NativeNotificationService.ts`.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
---
## Prerequisites
- Mac (or Linux) with Node.js 18+, **notification-wakeup-service** cloned and runnable
- **Android Studio**, JDK 17+, `ANDROID_HOME`, and `adb` — see [android-physical-device-guide.md](./android-physical-device-guide.md)
- Physical Android device with USB debugging (recommended for realistic FCM, Doze, and OEM behavior)
- ngrok account (free tier is enough for dev)
- Firebase project with an **Android** app registered for the Gradle **application ID**
- Non-production app build (Notification Debug Panel is dev-only)
- `google-services.json` in `android/app/` (not committed; see [`.gitignore`](../android/.gitignore))
---
## 1. Install and configure ngrok (macOS)
### Install
```bash
# Homebrew
brew install ngrok/ngrok/ngrok
```
Or download from [https://ngrok.com/download](https://ngrok.com/download).
### Create ngrok account and configure auth token
1. Sign up at [https://dashboard.ngrok.com/signup](https://dashboard.ngrok.com/signup).
2. Copy your authtoken from **Your Authtoken** in the dashboard.
3. Configure the CLI:
```bash
ngrok config add-authtoken YOUR_AUTHTOKEN_HERE
```
### Start a tunnel to the wakeup service
Assume the service listens on port **3000** (confirm in **notification-wakeup-service** `README` or `.env`).
```bash
# Terminal A — backend
cd /path/to/notification-wakeup-service
npm install
# one-time setup if needed
cp .env.example .env
# configure Firebase service account etc. as required
export PORT=3000
npm run dev
```
```bash
# Terminal B — ngrok
ngrok http 3000
```
ngrok prints a forwarding URL, for example:
```text
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
```
Use the **HTTPS** URL (not `http://127.0.0.1:3000`). The phone cannot reach your Mac’s localhost without the tunnel.
> **Note:** Free ngrok URLs change every time you restart ngrok unless you use a reserved domain (paid). Update the app debug override whenever the URL changes.
---
## 2. Start the backend locally
On first setup, copy `.env.example` to `.env` and set Firebase service account, `PORT`, and other variables per **notification-wakeup-service** docs.
If the backend is not already running from section 1:
```bash
cd /path/to/notification-wakeup-service
npm run dev
```
Verify locally before ngrok:
```bash
curl -sS http://localhost:3000/health
```
Expected: HTTP 200 and a JSON body indicating the service is up (exact shape depends on that repo).
### Configure Firebase Admin for the backend
**notification-wakeup-service** uses the Firebase Admin SDK to send FCM messages from your Mac.
1. Firebase Console → **Project settings** → **Service accounts**.
2. Click **Generate new private key** and download the JSON file.
3. Store the JSON outside the repo (do not commit it).
4. Point the backend at it:
```bash
export GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/service-account.json"
```
Set the same variable (or the equivalent documented in **notification-wakeup-service**) in the shell where you run `npm run dev`, or add it to that repo’s `.env`.
---
## 3. Obtain and use the ngrok HTTPS URL
1. Run `ngrok http <PORT>`.
2. Copy the `https://….ngrok-free.app` host from the **Forwarding** line.
3. Do **not** add a trailing slash when saving in the app (the debug config trims it).
4. Optional: open `http://127.0.0.1:4040` (ngrok web UI) to inspect requests and responses while testing.
Test through the tunnel from your Mac:
```bash
export NGROK_URL="https://abc123.ngrok-free.app"
curl -sS "$NGROK_URL/health"
```
---
## 4. Build and deploy to a physical Android device
From **crowd-funder-for-time-pwa**, build a non-production Capacitor bundle and sync Android. Complete [section 5](#5-firebase-setup-for-android-first-time) before expecting FCM to work.
```bash
npm install
npm run build:android:dev # or build:android:test — non-production for debug panel
```
Or use the combined run targets from [android-physical-device-guide.md](./android-physical-device-guide.md):
```bash
npm run build:android:debug:run
# or
npm run build:android:test:run
```
Open `android/` in Android Studio if you need to inspect native logs or signing. Ensure `VITE_FIREBASE_*` variables are set for the build you use (see `.env` / [BUILDING.md](../BUILDING.md)).
Native push registration runs at startup via `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` once Firebase and `google-services.json` are in place.
---
## 5. Firebase setup for Android (first-time setup)
Complete this section once before your first physical-device push test. If Firebase is already configured for this Android app, skip to [section 6](#6-configure-the-notification-debug-panel-backend-override).
### Create or access a Firebase account
1. Sign in at [https://console.firebase.google.com/](https://console.firebase.google.com/).
2. No paid Firebase plan is required. The free **Spark** plan supports **Firebase Cloud Messaging (FCM)** and local ngrok development.
### Create a Firebase project
1. In the Firebase Console, click **Add project** (or **Create a project**).
2. Enter a project name (for example, `timesafari-dev`) and continue through the wizard.
3. **Google Analytics** is optional for this workflow.
4. When the project is created, open it. **Cloud Messaging** is available on all projects — you do not need a separate “enable FCM” step beyond registering the Android app.
### Add the Android app in Firebase
1. In the project overview, click the **Android** icon (**Add app** → Android).
2. Enter the **Android package name**. It must **exactly** match the Gradle **applicationId**:
**`app.timesafari.app`**
(see `applicationId` in `android/app/build.gradle`. This differs from Capacitor `appId` in `capacitor.config.ts`, which is `app.timesafari`.)
3. App nickname and SHA-1/SHA-256 are optional for basic FCM wakeup testing; you may add debug keystore fingerprints later if Firebase Console prompts for them.
4. Download **`google-services.json`** when prompted.
### Place google-services.json
1. Copy the file to:
```text
crowd-funder-for-time-pwa/android/app/google-services.json
```
2. The repo gitignores this file — each developer keeps a local copy; never commit it.
3. Rebuild so Gradle applies the Google Services plugin (`android/app/build.gradle` applies `com.google.gms.google-services` when the file exists). If the file is missing, the build logs: *google-services.json not found, google-services plugin not applied. Push Notifications won't work*.
4. Sync Capacitor if you changed native config:
```bash
npx cap sync android
```
### Enable Firebase Cloud Messaging
FCM is enabled by default for Firebase projects. Confirm in **Project settings** → **Cloud Messaging**:
- **Cloud Messaging API** is available (legacy or HTTP v1 per your backend setup).
- The Android app (`app.timesafari.app`) appears under your apps.
The **notification-wakeup-service** sends wakeup messages through Firebase Admin using the same project’s service account ([section 2](#2-start-the-backend-locally)).
### Web / Capacitor JS Firebase config
In addition to `google-services.json`, the Capacitor web build needs `VITE_FIREBASE_*` env vars (API key, project ID, messaging sender ID, app ID, and optionally `VITE_FIREBASE_VAPID_KEY`). These must refer to the **same Firebase project** as `google-services.json`.
### Verify Firebase configuration
Before ngrok end-to-end testing, confirm:
- [ ] App builds and installs without Gradle errors about `google-services.json`.
- [ ] Push permission is **granted** (see [section 7](#7-android-notification-permissions)).
- [ ] **Notification Debug Panel** shows an FCM token (after permission and registration).
- [ ] **Register Token Now** succeeds and ngrok shows `POST /notifications/register`.
- [ ] Backend health and `GOOGLE_APPLICATION_CREDENTIALS` are set so `/debug/send-wakeup` can run.
---
## 6. Configure the Notification Debug Panel backend override
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
### Open the panel
1. Use a **non-production** build (e.g. `build:android:dev` or `build:android:test`).
2. **Account** → enable **Show All General Advanced Functions**.
3. Open **Notification Debug Panel** (route `/dev/notifications`).
### Backend Testing section
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token and `platform: "android"` |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs (`/notifications/alert-authorization`) |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM delivery diagnostic only (no app-side refresh/`api_*` scheduling) |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
### Authentication vs backend URL
These settings are **independent**:
- **Backend URL** — which server receives `/notifications/*` and `/debug/*` calls.
- **Test Mode** — `testMode` field in JSON request bodies only.
- **Skip JWT Authentication** — whether `Authorization: Bearer …` is sent (via `getNotificationApiHeaders()`).
For a **hosted shared test server** (for example `https://test-notify-api.timesafari.app`): set the backend URL, keep **Test Mode** on if the server requires it, and leave **Skip JWT Authentication** **off**. Ensure an active DID and endorser session exist in the app.
For **local ngrok / localhost**: set the backend URL to your tunnel or `http://127.0.0.1:PORT`. Enable **Skip JWT Authentication** only if your local **notification-wakeup-service** intentionally accepts unauthenticated requests.
### testMode
When **Test Mode** is on (default if never saved), register and send-wakeup requests include `"testMode": true`. The backend can route dev traffic separately from production. Turn it off in the panel only if you intentionally want production-mode API behavior against your tunnel.
### WAKEUP_PING debug controls
**Send Real WAKEUP_PING** is the remaining panel control for wakeup. Mock refresh, Simulate WAKEUP_PING, Wakeup Ping Simulator, and Flood Test were removed with the retired refresh pipeline.
Use **Send Real WAKEUP_PING** to confirm backend → FCM → Capacitor listener delivery. Do not expect `/notifications/refresh` or `api_*` rescheduling.
### Send Real WAKEUP_PING
**Send Real WAKEUP_PING** is the in-app equivalent of calling **`POST /debug/send-wakeup`** from the Mac (see [Verification Checklist step 7](#7-manual-wakeup-endpoint-sends-fcm-successfully)). It is intended for **local testing and diagnostics** on non-production builds only.
**What it does:**
1. Reads the **Current FCM Token** shown in the panel (same token used by **Register Token Now**). If no token is available, the action fails with a panel status message.
2. `POST`s to `{backend override}/debug/send-wakeup` with `deviceId`, `fcmToken`, `platform: "android"`, and `testMode` from the panel config (`NotificationDebugService.sendRealWakeupPing()`).
3. On HTTP success, the **notification-wakeup-service** enqueues a real FCM **data** message with `data.type = "WAKEUP_PING"` to that device.
4. When FCM delivers the message to the app, Capacitor fires `pushNotificationReceived` → `handleCapacitorPushNotificationReceived()`, which logs `push handler ignored type=WAKEUP_PING`. There is no `refreshNotificationsWithDiagnostics` and no `applyNotificationRefreshPayload`.
That exercises **backend → FCM → Capacitor push listener** on Android without manual `curl` on the Mac. It does **not** reschedule local notifications.
**Prerequisites:** ngrok backend override saved, **Test Mode** as needed, notification permission granted, **Register Token Now** succeeded, app **backgrounded** (Home — not force-stop) before expecting FCM delivery ([§8](#8-android-platform-notes)).
#### Expected Logcat output
Filter logcat (prefix is always `[Notifications]`):
```bash
adb logcat | grep -E '\[Notifications\].*(Real WAKEUP_PING|push handler ignored)'
```
On a **successful FCM delivery** run (HTTP success from the panel, then FCM delivery within ~30–120s), expect these key lines in order:
```
[Notifications] Real WAKEUP_PING requested
[Notifications] Real WAKEUP_PING success
[Notifications] push handler ignored type=WAKEUP_PING
```
Auth bypass messages when **Skip JWT Authentication** is on are normal. ngrok should **not** show a new `POST /notifications/refresh` from the app after the push.
#### Send Real WAKEUP_PING vs delivery success
The panel status **“Real WAKEUP_PING sent via backend.”** and the Event Log line **`Real WAKEUP_PING success`** only confirm that the backend **accepted the request and attempted FCM delivery**. They do **not** prove the device received the push.
**Successful delivery** is confirmed when **`push handler ignored type=WAKEUP_PING`** appears in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not that line, treat it as an FCM delivery problem ([FCM message not received](#fcm-message-not-received)), not a backend enqueue failure.
### Programmatic override (optional)
From Chrome DevTools attached to the WebView (`chrome://inspect` → your app):
```javascript
import {
setBackendBaseUrl,
setTestMode,
setBypassAuth,
getNotificationApiBaseUrl,
} from "@/services/notifications";
setBackendBaseUrl("https://abc123.ngrok-free.app");
setTestMode(true);
setBypassAuth(true); // local dev only — omit for hosted servers that require JWT
getNotificationApiBaseUrl(); // → ngrok URL
```
---
## 7. Android notification permissions
### Android 13+ (API 33+)
`AndroidManifest.xml` declares:
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
On **Android 13 and above**, this is a **runtime** permission. The user must grant it before the system allows notification **display**. The OS shows a system dialog the first time the app requests it.
### How this app requests permission
At startup, `initializeNativePushAndFirebaseMessaging()` in `firebaseMessagingClient.ts` calls:
```typescript
const perm = await PushNotifications.requestPermissions();
```
If `perm.receive !== "granted"`, native push registration does not proceed and you will not get a reliable FCM token in the debug panel.
Daily notification flows can also request permission via **daily-notification-plugin** (`NativeNotificationService.requestPermissions()` → plugin `POST_NOTIFICATIONS`). See [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md).
### Android 12 and below
`POST_NOTIFICATIONS` does not exist as a runtime prompt on older APIs. Notifications are generally allowed at install time; fewer permission dialogs appear during testing.
### Manual check for testers
**Settings** → **Apps** → **TimeSafari** → **Notifications** → ensure notifications are allowed.
### Permission vs FCM data wake
**WAKEUP_PING** uses FCM **data** messages handled in `pushNotificationReceived`. For local testing:
- Grant notification permission so **Register Token Now** and token display in the debug panel work.
- If permission is denied, fix permission before debugging FCM wakeup; do not assume data delivery will run the full refresh pipeline.
---
## 8. Android Platform Notes
### FCM vs iOS silent push
Android generally delivers **background FCM data messages** more reliably than iOS **silent APNs** (`content-available`), especially when the app is backgrounded (not force-stopped) and Google Play services is healthy. There is no separate APNs hop; FCM talks to the device directly.
Delivery is still **not guaranteed**. Treat wakeup as best-effort in tests and in production expectations.
### Force-stop vs backgrounding
| State | Typical FCM behavior |
|-------|----------------------|
| **Foreground** | Data messages handled promptly; `pushNotificationReceived` fires. |
| **Background** (Home) | Data messages usually delivered; handler may run with delay under load. |
| **Force stop** (Settings → Force stop) | Delivery often **blocked** until the user launches the app again. Stricter than iOS “swipe away” in many cases. |
| **Recents swipe** | Varies by OEM/Android version; less predictable than Home. Prefer **Home** for tests. |
Always validate wakeup with the app **backgrounded**, not force-stopped.
### Notification permissions (Android 13+)
On **API 33+**, `POST_NOTIFICATIONS` is a runtime permission ([section 7](#7-android-notification-permissions)). This app requests push permission via `PushNotifications.requestPermissions()` before `register()`. Denied permission blocks token registration in the debug panel and undermines end-to-end testing—fix permission before debugging FCM.
### Network connectivity
FCM needs network to Google’s endpoints. Registration needs the ngrok HTTPS URL for `POST /notifications/register`. Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never logged ignored type” symptoms even when FCM eventually arrives.
### Physical device vs emulator
Doze, App Standby, and OEM battery menus are weak or absent on many emulators. Use a **physical device** for wakeup SLA testing; emulators are fine for panel-only register smoke tests.
### Logcat
```bash
adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]'
```
Expect `push handler ignored type=WAKEUP_PING` after a successful wake. For a panel-driven test, use **Send Real WAKEUP_PING** ([§6](#send-real-wakeup_ping)) instead of manual `curl`.
---
## 9. Battery Optimization Caveats
Android power management can **delay or batch** FCM delivery and background work even when the server returns success from `/debug/send-wakeup`.
### Doze Mode
When the device is **unplugged**, screen off, and idle, Android enters **Doze**. Network access and jobs are deferred to **maintenance windows**. Wakeup messages may arrive minutes late.
**Testing tip:** Keep the device on charger, or wake the screen briefly, or wait longer (2–5+ minutes) before failing a Doze test.
### App Standby
Apps used infrequently move to **standby** buckets with reduced background network. Frequent dev installs reset some of this; long-idle test devices do not.
**Testing tip:** Open the app before sending wakeup; register token again if the device was idle for days.
### Adaptive Battery
**Adaptive Battery** / per-app battery savers learn usage patterns and restrict background activity for “unused” apps.
**Testing tip:** **Settings** → **Apps** → **TimeSafari** → **Battery** → **Unrestricted** (or **Don’t optimize**) during local validation.
### Manufacturer-specific restrictions
OEMs add layers on top of AOSP. Aggressive battery management can delay **WAKEUP_PING** delivery.
| Vendor | Where to look (names vary by OS version) |
|--------|------------------------------------------|
| **Samsung** | Device care → Battery → Background usage limits; app → Battery → Unrestricted |
| **Xiaomi** | Security app → Autostart; Battery saver → No restrictions |
| **Oppo** / **Realme** | Battery → App battery management → Allow background activity |
| **Vivo** | iManager / Battery → High background power consumption |
| **Huawei** | App launch → Manage manually → enable Auto-launch, Secondary launch, Run in background |
If wakeup works on a **Pixel** but fails on an OEM phone, assume battery policy first—not a broken FCM payload.
### How this affects wakeup testing
1. Server accepts `/debug/send-wakeup` → FCM enqueue succeeds.
2. Device may **hold** the message until Doze maintenance or OEM policy allows delivery.
3. `pushNotificationReceived` runs → `push handler ignored type=WAKEUP_PING`.
4. Any step can lag under battery savers; use **Send Real WAKEUP_PING** for the FCM path ([§6](#send-real-wakeup_ping)).
---
## 10. Recommended local testing workflow
1. Start **notification-wakeup-service** on the Mac (`npm run dev`, `GOOGLE_APPLICATION_CREDENTIALS` set).
2. Start **ngrok** and copy the HTTPS URL.
3. Install a **non-production** Android build with `google-services.json` in place.
4. Grant notification permission when prompted (or enable in Settings).
5. Open **Notification Debug Panel** → paste ngrok URL → **Save Backend URL**; confirm **Test Mode** is on; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
6. Tap **Register Token Now** → confirm ngrok `POST /notifications/register` and `[Notifications] Token registration success` in Event Log / logcat.
7. Background the app (Home), then tap **Send Real WAKEUP_PING** (or from the Mac, call **`/debug/send-wakeup`** — see [curl examples](#13-sample-curl-commands)) with the registered `deviceId` / token as required by **notification-wakeup-service**.
8. Watch logcat for `push handler ignored type=WAKEUP_PING` ([expected output](#expected-logcat-output)).
9. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic (register and send-wakeup; not app-initiated refresh).
10. Use **Pending Notification Inspector** for Daily Reminder / New Activity / dual schedules — not for retired `api_*` refresh.
For a formal pass/fail sequence, use the [Verification Checklist](#11-verification-checklist) below.
---
## 11. Verification Checklist
Use this checklist during development or QA sign-off. Each step lists **actions**, then **expected outcome**. Prerequisites: non-production Android build, `google-services.json` installed, physical device (recommended), backend + ngrok running.
| # | Check | Pass criteria |
|---|--------|----------------|
| 1 | Backend via ngrok | §1 below |
| 2 | Device → backend override | §2 |
| 3 | FCM token registered | §3 |
| 4 | Device record in wakeup service | §4 |
| 5 | Optional backend `/notifications/refresh` curl | §5 (backend only; app does not call this) |
| 6 | Locals scheduled | §6 (Daily Reminder / dual / fetcher — not refresh) |
| 7 | Manual wakeup sends FCM | §7 |
| 8 | WAKEUP_PING delivered | §8 |
| 9 | _(retired)_ Replace after refresh | skipped |
| 10 | _(retired)_ Test mode frequent refreshes | skipped |
### 1. Backend reachable through ngrok
**Actions:**
```bash
export BASE="https://YOUR-NGROK-HOST.ngrok-free.app"
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
```
**Expected outcome:** HTTP **200**; JSON body indicates the service is healthy (exact fields per **notification-wakeup-service**). Mac `curl http://localhost:3000/health` must also pass before blaming ngrok.
---
### 2. Device can reach backend override URL
**Actions:**
1. Open **Notification Debug Panel** (`/dev/notifications`).
2. Paste ngrok HTTPS URL (no trailing slash) → **Save Backend URL**.
3. Confirm **Backend Status → URL** matches the saved ngrok host.
4. Enable **Test Mode** if using dev backend behavior.
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/debug/send-wakeup`.
---
### 3. FCM token successfully registered
**Actions:**
1. Grant notification permission when prompted (Android 13+).
2. Tap **Register Token Now**.
3. Watch Event Log and logcat (`[Notifications]`, `[FirebaseMessaging]`).
**Expected outcome:**
- **Current FCM Token** shows a non-empty token (Copy works).
- Event Log: `[Notifications] Token registration success` (not failure / auth unavailable).
- ngrok inspect: `POST /notifications/register` with **200**; body includes `platform: "android"`, `testMode: true` (if Test Mode on), `deviceId`, and `fcmToken`.
---
### 4. Device record appears in notification-wakeup-service
**Actions:**
1. In ngrok inspect (`http://127.0.0.1:4040`), open the **register** request → copy `deviceId` and `fcmToken` from the JSON body.
2. In **notification-wakeup-service**, confirm persistence per that repo (server logs, database, admin/list endpoint, or debug CLI).
**Expected outcome:** The `deviceId` and `fcmToken` from step 3 are stored and retrievable by the service. `/debug/send-wakeup` can target that `deviceId` (or token, per service contract). If register returned 200 but no record exists, fix the wakeup service store/config before FCM tests.
> **Note:** `deviceId` is created in-app (`Preferences` key `stable_device_id` in `deviceId.ts`). It is not shown in the debug panel; use ngrok request body or logcat `[DeviceId]` lines.
---
### 5. Refresh endpoint (backend only; app does not call this)
**Actions:**
Mac-side curl against **notification-wakeup-service** if you need to confirm the backend still answers. The app has no **Refresh Notifications** button and does not schedule from this payload.
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
-H "Content-Type: application/json" \
-d '{"platform":"android","testMode":true}'
```
**Expected outcome:** HTTP **200** from the **service** if that route is still deployed. Do **not** expect Event Log `Refresh completed` or `api_*` schedules in the app.
---
### 6. Local notifications are scheduled (not from refresh)
**Actions:**
1. Configure Static Daily Reminder and/or New Activity (dual) in the app, or rely on the native fetcher.
2. Open **Pending Notification Inspector** → **Refresh** (list button).
**Expected outcome:** Inspector lists pending Daily Reminder / dual / fetcher items. Do not expect `api_*` identifiers after Phase 4 startup cleanup. There is no `Schedule replacement applied` from WAKEUP_PING.
---
### 7. Manual wakeup endpoint sends FCM successfully
**Actions:**
1. Use `deviceId` from step 4.
2. Either:
- **On device:** background the app (Home), open **Notification Debug Panel**, tap **Send Real WAKEUP_PING**; or
- **From the Mac:**
```bash
curl -sS -X POST "$BASE/debug/send-wakeup" \
-H "Content-Type: application/json" \
-d '{"deviceId":"YOUR_DEVICE_ID","testMode":true}'
```
3. Check **notification-wakeup-service** logs for FCM send success (no Admin SDK / token errors).
**Expected outcome:** HTTP **200** (or documented success code) from `/debug/send-wakeup`; Event Log / logcat: `Real WAKEUP_PING success` when using the panel button; server logs indicate FCM message enqueued/sent with `data.type = "WAKEUP_PING"`. This step alone does not prove device delivery (see step 8 and [Send Real WAKEUP_PING vs delivery success](#send-real-wakeup_ping-vs-delivery-success)).
---
### 8. WAKEUP_PING is delivered (no refresh)
**Actions:**
1. **Background** the app (Home — not force-stop).
2. Run step 7 again (panel **Send Real WAKEUP_PING** or Mac `curl`), or wait for a server-driven wakeup.
3. Filter logcat:
```bash
adb logcat | grep -E 'WAKEUP_PING|push handler ignored'
```
**Expected outcome (within ~30–120s, longer under Doze/OEM):**
1. `Real WAKEUP_PING success` (panel path)
2. `push handler ignored type=WAKEUP_PING`
3. ngrok: **no** app-initiated `POST /notifications/refresh`
Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output).
---
### 9. Existing notifications are replaced after refresh
**Retired.** The app no longer replaces local schedules from `/notifications/refresh`. Pending list changes come from Daily Reminder / dual / native fetcher, or from Phase 4 `clearApiNotifications()`.
---
### 10. Test mode produces frequent notification refreshes
**Retired.** `testMode` still goes on register and send-wakeup bodies. It does not drive app-side `api_*` refresh cadences.
---
## 12. End-to-End Test
Single scripted run from cold start to scheduled local notification. Time: ~15–30 minutes (plus optional wait for a near `testMode` fire).
### Phase A — Mac backend and tunnel
1. Terminal A:
```bash
cd /path/to/notification-wakeup-service
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json"
export PORT=3000
npm run dev
```
2. Verify: `curl -sS http://localhost:3000/health` → **200**.
3. Terminal B: `ngrok http 3000` → copy `https://….ngrok-free.app` → `export BASE=…`.
4. Verify: `curl -sS "$BASE/health"` → **200**.
### Phase B — Android app
5. Build and install dev/test APK (`npm run build:android:dev` or `build:android:debug:run`); `google-services.json` in `android/app/`.
6. Launch app; accept **notification permission**.
7. **Account** → **Show All General Advanced Functions** → **Notification Debug Panel**.
8. Paste `BASE` → **Save Backend URL**; enable **Test Mode**.
### Phase C — Register
9. **Register Token Now** → Event Log success; ngrok `POST /notifications/register` **200**; copy `deviceId` from ngrok request body.
10. Confirm device in **notification-wakeup-service** (logs/DB per that repo).
### Phase D — FCM wakeup delivery
11. Background app (Home).
12. Tap **Send Real WAKEUP_PING** in the panel (or Mac: `curl -X POST "$BASE/debug/send-wakeup" -H "Content-Type: application/json" -d '{"deviceId":"…","testMode":true}'`) → panel `Real WAKEUP_PING success` and server FCM enqueue success.
13. Within 30–120s (longer if unplugged/Doze): logcat shows `push handler ignored type=WAKEUP_PING`; ngrok does **not** show an app `POST /notifications/refresh`.
### Phase E — Optional local notification proof
14. Enable Daily Reminder or New Activity; wait for wall-clock fire with app backgrounded; confirm the OS notification is **not** the retired generic `api_*` “Reminder” copy.
### End-to-end pass criteria
| Phase | Pass |
|-------|------|
| A | Health OK local + ngrok |
| B | Override URL + testMode active |
| C | Register succeeded |
| D | Wakeup curl/panel OK → logcat ignored-type line |
| E | Optional visible Daily Reminder / New Activity notification |
---
## 13. Sample curl commands
Set your tunnel base URL:
```bash
export BASE="https://abc123.ngrok-free.app"
```
### Health
```bash
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
```
### Register device (mirror app payload)
```bash
curl -sS -X POST "$BASE/notifications/register" \
-H "Content-Type: application/json" \
-d '{
"deviceId": "00000000-0000-4000-8000-000000000001",
"fcmToken": "YOUR_FCM_TOKEN_FROM_DEBUG_PANEL",
"platform": "android",
"testMode": true
}'
```
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
-H "Content-Type: application/json" \
-d '{
"platform": "android",
"testMode": true
}'
```
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
Exact path and body depend on **notification-wakeup-service**; typical pattern:
```bash
curl -sS -X POST "$BASE/debug/send-wakeup" \
-H "Content-Type: application/json" \
-d '{
"deviceId": "00000000-0000-4000-8000-000000000001",
"testMode": true
}'
```
Confirm parameters (token vs `deviceId`, auth headers) in that repo’s README or OpenAPI spec. The server may still send FCM data including `type: "WAKEUP_PING"`; the app logs it as ignored and does not refresh.
---
## 14. Troubleshooting
Structured checks for local ngrok + FCM testing. Each item lists **symptoms**, **likely causes**, **verification**, and **fixes**.
### Token registration failures
**Symptoms:** Event Log shows token registration failure; no FCM token in debug panel; ngrok has no `POST /notifications/register`; curl register fails.
**Likely causes:** Notification permission denied; missing `google-services.json`; Firebase package mismatch (`app.timesafari.app`); `VITE_FIREBASE_*` not in build; JWT auth failure or **Skip JWT Authentication** mismatch with server expectations; duplicate-token skip.
**Verification:**
1. Settings → app → Notifications → allowed.
2. Logcat: `[FirebaseMessaging] Push permission not granted` or registration errors.
3. Panel shows a token string before **Register Token Now**.
4. ngrok inspect UI (`http://127.0.0.1:4040`) for register request status body.
**Fixes:** Grant permission and cold-start app; add `android/app/google-services.json` and rebuild; align Firebase Android app ID with Gradle `applicationId`; rebuild with correct `.env`; tap **Register Token Now** (forces re-register); for JWT-required servers, ensure active DID/endorser session and **Skip JWT Authentication** off; for local unauthenticated backends, enable **Skip JWT Authentication** (see [notification-debug-panel.md](./notification-debug-panel.md)).
---
### Backend unreachable
**Symptoms:** Backend Status unhealthy in panel; register/refresh network errors; Mac `curl localhost:3000/health` fails.
**Likely causes:** `notification-wakeup-service` not running; wrong `PORT`; firewall; typo in saved backend URL (not ngrok).
**Verification:**
```bash
curl -sS http://localhost:3000/health
```
Compare with panel **Backend Status** and Event Log error text.
**Fixes:** Start backend with `npm run dev` and `GOOGLE_APPLICATION_CREDENTIALS`; match `PORT` to ngrok target; paste full ngrok **HTTPS** URL into panel (no trailing slash).
---
### Stale ngrok URL
**Symptoms:** Worked yesterday; today all API calls fail or hit wrong host; ngrok shows no requests.
**Likely causes:** Free ngrok URL changed after tunnel restart; old URL still in `notificationDebug.backendBaseUrl`.
**Verification:** Compare panel URL to current `ngrok http` **Forwarding** line; `curl -sS "$NEW_URL/health"`.
**Fixes:** Copy new HTTPS URL → **Save Backend URL** in panel; or clear override only if intentionally returning to `DEFAULT_NOTIFY_API_SERVER`.
---
### Refresh endpoint failures
**Symptoms:** Mac `curl` of `/notifications/refresh` fails; ngrok missing that path. This is a **backend** issue. The app does not POST refresh.
**Verification:** `curl` health and refresh from the Mac; confirm the service still ships that route.
**Fixes:** Fix backend URL and health. App scheduling uses Daily Reminder / dual / native fetcher, not this payload.
---
### FCM message not received
**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `push handler ignored type=WAKEUP_PING` in logcat within 2 minutes.
**Likely causes:** Force-stopped app; wrong FCM token on server; Doze/OEM delay; no Google Play services; payload missing `data.type = "WAKEUP_PING"`; device offline.
**Note:** `Real WAKEUP_PING success` only means the backend accepted and sent the FCM request. Missing the ignored-type log indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-delivery-success)).
**Verification:**
1. App **backgrounded** (Home), not force-stopped.
2. Panel FCM token matches token used by server/register.
3. Wait 30–120s (longer on Doze/OEM).
4. Battery **Unrestricted** and OEM autostart enabled for test device.
**Fixes:** Re-register token; relaunch app; relax battery settings ([section 9](#9-battery-optimization-caveats)); confirm **notification-wakeup-service** message format; test on Pixel vs suspect OEM policy.
---
### Notification permission denied
**Symptoms:** No permission dialog or user denied; logcat `Push permission not granted`; empty FCM token; register skipped.
**Likely causes:** User denied prompt; permission revoked in Settings; testing on API 33+ without `POST_NOTIFICATIONS` grant.
**Verification:** Settings → Apps → TimeSafari → Notifications; logcat after cold start for `requestPermissions` result.
**Fixes:** Enable notifications in Settings; reinstall to re-prompt if needed; cold-start app so `initializeNativePushAndFirebaseMessaging()` runs again.
---
### Notifications not appearing
**Symptoms:** Daily Reminder or New Activity configured but no visible notification at fire time; Pending Inspector empty or stale.
**Likely causes:** Permission denied (display blocked); exact alarm permission on Android 12+; timestamps in past; plugin schedule error; DND/channel settings.
**Verification:**
1. Permission granted ([section 7](#7-android-notification-permissions)).
2. Pending Notification Inspector for Daily Reminder / dual identifiers (not `api_*` after Phase 4 cleanup).
**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs. Do not expect wakeup refresh payloads to populate the inspector.
---
### Duplicate notifications
**Symptoms:** Multiple identical local notifications.
**Likely causes:** Separate Daily Reminder vs New Activity schedules; duplicate dual config; leftover `api_*` before Phase 4 cleanup.
**Verification:** Pending Inspector identifiers; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues.
**Fixes:** Confirm Daily Reminder vs dual settings. WAKEUP_PING no longer stacks `api_*` refreshes.
---
### Device enters battery-saving mode
**Symptoms:** Intermittent wakeup; long delays; works on charger but not unplugged; OEM “battery saver” icon on.
**Likely causes:** Doze; Adaptive Battery; manufacturer saver (Samsung, Xiaomi, Oppo, Vivo, Huawei).
**Verification:** Settings → Battery; OEM battery app; reproduce unplugged screen-off vs charging.
**Fixes:** For dev testing set app battery to **Unrestricted**; enable **Autostart** / background activity on OEM; wait for maintenance window or wake device before concluding FCM failure.
---
### Gradle: Push Notifications won't work
**Symptoms:** Build log: `google-services.json not found, google-services plugin not applied`.
**Likely causes:** Missing `android/app/google-services.json`.
**Verification:** File exists on disk; rebuild shows Google Services plugin applied.
**Fixes:** Download from Firebase Console (package `app.timesafari.app`); place in `android/app/`; `npx cap sync android` and rebuild.
---
## 15. Key source files (crowd-funder-for-time-pwa)
| File | Purpose |
|------|---------|
| `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, and bypassAuth overrides |
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners, permission, token registration |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
| `android/app/build.gradle` | `applicationId`, conditional `google-services` plugin |
| `android/app/src/main/AndroidManifest.xml` | `POST_NOTIFICATIONS` and plugin permissions |
---
## 16. Related docs
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md) — iOS + APNs equivalent
- [local-android-testing-analysis.md](./local-android-testing-analysis.md) — reuse matrix used to author this guide
- [android-physical-device-guide.md](./android-physical-device-guide.md) — USB, `adb`, build commands
- [notification-system-overview.md](./notification-system-overview.md)
- [notification-from-api-call.md](./notification-from-api-call.md)
- [notification-permissions-and-rollovers.md](./notification-permissions-and-rollovers.md)
- [BUILDING.md](../BUILDING.md) — Android build commands
- [notification-debug-panel.md](./notification-debug-panel.md) — panel controls, authentication, troubleshooting
- [Notification Debug Panel (README)](../README.md#notification-debug-panel-dev-builds)
For plugin-native behavior (exact alarms, Android pending inspector), see **daily-notification-plugin** documentation. For FCM payload format and `/debug/send-wakeup` contract, see **notification-wakeup-service**.
+525
View File
@@ -0,0 +1,525 @@
# Local iOS Testing with ngrok (notification-wakeup-service)
**Last updated:** 2026-09-24 (retired WAKEUP_PING → refresh → `api_*` consumption)
**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service**
**Goal:** Exercise FCM token registration and silent-push **delivery** (`WAKEUP_PING` via `/debug/send-wakeup`) against a Mac-hosted backend reachable from a physical iPhone.
> **Retired (do not expect this in the app):** `WAKEUP_PING` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` → `api_*` local schedules. The app logs ignored push types and does not refresh or schedule from wakeup. `/debug/send-wakeup` remains an FCM/APNs diagnostic. Mac `curl` of `/notifications/refresh` only tests the backend.
---
## Architecture overview
End-to-end flow when testing FCM registration and wakeup **delivery** on a physical iPhone:
```text
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Mac (localhost) │ ◄───────────── │ ngrok edge │
│ notification- │ tunnel │ (public HTTPS URL) │
│ wakeup-service │ └──────────┬───────────┘
└──────────┬──────────┘ │
│ │ fetch
│ │ POST /notifications/register
│ ▼
│ ┌──────────────────────┐
│ │ crowd-funder-for- │
│ │ time-pwa (Capacitor │
│ │ iOS on iPhone) │
│ └──────────┬───────────┘
│ │
│ FCM data message (WAKEUP_PING) │ daily-notification-plugin
▼ ▼ (Daily Reminder / dual / fetcher)
┌─────────────────────┐ ┌──────────────────────┐
│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │
│ Messaging │ silent push │ app.timesafari │
└─────────────────────┘ └──────────────────────┘
```
### Repos and responsibilities
| Repo | Role |
|------|------|
| **notification-wakeup-service** | HTTP API: device registration, health, debug wakeup send; may still expose `/notifications/refresh` |
| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register`; logs `WAKEUP_PING` without refresh/`api_*` scheduling |
| **daily-notification-plugin** | Native iOS/Android: Daily Reminder, New Activity / dual, native fetcher; Phase 4 `clearApiNotifications()` |
### Silent wake sequence (current)
1. Backend `/debug/send-wakeup` sends an FCM **data** message with `data.type = "WAKEUP_PING"`.
2. APNs delivers to the device (best-effort; see iOS caveats below).
3. Capacitor `pushNotificationReceived` fires → `handleCapacitorPushNotificationReceived()`.
4. Handler logs `[Notifications] push handler ignored type=WAKEUP_PING`. It does **not** POST `/notifications/refresh` or schedule `api_*` notifications.
Console and debug panel lines are prefixed with **`[Notifications]`** (see `NotificationDebugEvents.ts`).
---
## Prerequisites
- Mac with Xcode, Node.js 18+, and the **notification-wakeup-service** repo cloned and runnable
- Physical iPhone (USB or wireless debugging) — **simulator is not sufficient** for reliable silent push / APNs behavior
- ngrok account (free tier is enough for dev)
- Firebase project with APNs configured for the iOS app bundle ID
- Non-production app build (Notification Debug Panel is dev-only)
---
## 1. Install and configure ngrok (macOS)
### Install
```bash
# Homebrew
brew install ngrok/ngrok/ngrok
```
Or download from [https://ngrok.com/download](https://ngrok.com/download).
### Account and auth token
1. Sign up at [https://dashboard.ngrok.com/signup](https://dashboard.ngrok.com/signup).
2. Copy your authtoken from **Your Authtoken** in the dashboard.
3. Configure the CLI:
```bash
ngrok config add-authtoken YOUR_AUTHTOKEN_HERE
```
### Start a tunnel to the wakeup service
Assume the service listens on port **3000** (confirm in **notification-wakeup-service** `README` or `.env`).
If the service already defaults to port 3000 internally, you may not need to export PORT manually.
```bash
# Terminal A — backend
cd /path/to/notification-wakeup-service
npm install
# one-time setup if needed
cp .env.example .env
# configure Firebase/service account/etc as required
export PORT=3000
npm run dev
```
```bash
# Terminal B — ngrok
ngrok http 3000
```
The backend only needs to be started once. The dedicated backend section below exists for verification and troubleshooting details, not as a second startup step.
ngrok prints a forwarding URL, for example:
```text
Forwarding https://abc123.ngrok-free.app -> http://localhost:3000
```
Use the **HTTPS** URL (not `http://127.0.0.1:3000`). The iPhone cannot reach your Mac’s localhost without the tunnel.
> **Note:** Free ngrok URLs change every time you restart ngrok unless you use a reserved domain (paid). Update the app debug override whenever the URL changes.
---
## 2. Start the backend locally
Example (adjust to match **notification-wakeup-service**). On first setup, copy `.env.example` to `.env` and set Firebase service account, `PORT`, and other variables per that repo's docs.
If the backend is not already running from section 1:
```bash
# If not already running from the previous step:
cd /path/to/notification-wakeup-service
npm run dev
```
Verify locally before ngrok:
```bash
curl -sS http://localhost:3000/health
```
Expected: HTTP 200 and a JSON body indicating the service is up (exact shape depends on that repo).
---
## 3. Obtain and use the ngrok HTTPS URL
1. Run `ngrok http <PORT>`.
2. Copy the `https://….ngrok-free.app` host from the **Forwarding** line.
3. Do **not** add a trailing slash when saving in the app (the debug config trims it).
4. Optional: open `http://127.0.0.1:4040` (ngrok web UI) to inspect requests and responses while testing.
Test through the tunnel from your Mac:
```bash
export NGROK_URL="https://abc123.ngrok-free.app"
curl -sS "$NGROK_URL/health"
```
---
## 4. Generate and open the iOS workspace
From **crowd-funder-for-time-pwa**, generate the Capacitor iOS project and open it in Xcode. **[Section 5](#5-firebase--apns-setup-first-time-setup) (Firebase + APNs)** needs this workspace—for example to add `GoogleService-Info.plist` and enable Push Notifications in the app target. The app does not need Firebase or push fully configured yet; the goal here is a buildable Xcode project on your Mac.
```bash
npm install
npm run build:ios:dev # or build:ios:test — non-production for debug panel
```
Open the generated Xcode workspace (for example `ios/App/App.xcworkspace`), select your **physical iPhone**, enable signing, and Run when you are ready to verify the app launches.
Ensure `VITE_FIREBASE_*` variables are set for the Capacitor build you use (see `.env` / build docs). Native push registration runs at startup via `initializeNativePushAndFirebaseMessaging()` in `main.capacitor.ts` once Firebase is configured in the next section.
---
## 5. Firebase + APNs setup (first-time setup)
Complete this section once before your first physical-device push test. If Firebase and APNs are already configured for this app, skip to [section 6](#6-configure-the-notification-debug-panel-backend-override).
### Create or access a Firebase account
1. Sign in with a Google account at [https://console.firebase.google.com/](https://console.firebase.google.com/).
2. If this is your first time using Firebase:
- Accept the Firebase terms.
- Create a new Firebase account/workspace when prompted.
3. No paid Firebase plan is required for local iOS notification testing. The free **Spark** plan is sufficient for:
- Firebase Cloud Messaging (FCM)
- APNs silent push testing
- local ngrok-based development
### Create a Firebase project
1. In the [Firebase Console](https://console.firebase.google.com/), click **Add project** (or **Create a project**).
2. Enter a project name (for example, `timesafari-dev`) and continue through the wizard.
3. **Google Analytics** is optional for this workflow; you can disable it for a simpler dev project.
4. When the project is created, open it. **Cloud Messaging** is available on all projects — you do not need a separate enable step for FCM.
### Register the iOS app in Firebase
1. In the project overview, click the **iOS** icon (**Add app** → iOS).
2. Enter the **Apple bundle ID**. It must **exactly** match the Capacitor / Xcode app ID:
- **`app.timesafari`** (see `appId` in `capacitor.config.ts` and the Xcode target **Bundle Identifier**).
3. App nickname and App Store ID are optional for local testing; continue.
4. Download **`GoogleService-Info.plist`** when prompted and keep it handy for the next step.
### Add GoogleService-Info.plist to Xcode
1. Open the iOS workspace you generated in [section 4](#4-generate-and-open-the-ios-workspace) (for example `ios/App/App.xcworkspace`).
2. In the Project Navigator, drag **`GoogleService-Info.plist`** into the **App** folder (the same one that contains AppDelegate.swift and Info.plist).
3. In the dialog that appears:
- Check **Copy items if needed** (so the file is copied into the project tree).
- Under **Add to targets**, ensure the main app target (not only the share extension) is checked.
4. Confirm the file appears under the app target in Xcode and is listed in **Build Phases** → **Copy Bundle Resources** if your project uses that phase for plists.
### Create an APNs Authentication Key
Apple uses APNs to deliver pushes to devices; Firebase needs an APNs key to talk to Apple on your behalf.
1. Sign in to [Apple Developer](https://developer.apple.com/account/) → **Certificates, Identifiers & Profiles**.
2. Open **Keys** → **+** (create a new key).
3. Name the key (for example, `Timesafari APNs Dev`).
4. Enable **Apple Push Notifications service (APNs)** and continue.
5. Register the key, then **Download** the `.p8` file. **You can download it only once** — store it securely.
6. Note:
- **Key ID** (shown on the key detail page)
- **Team ID** (top right of the developer portal, or **Membership** details)
### Upload APNs key to Firebase
1. Firebase Console → your project → **Project settings** (gear icon).
2. Open the **Cloud Messaging** tab.
3. Under **Apple app configuration**, select your iOS app (`app.timesafari`) if prompted.
4. Under **APNs Authentication Key**, click **Upload**.
5. Select the `.p8` file and enter:
- **Key ID**
- **Team ID**
6. Save. Firebase can now send FCM messages through APNs to your iOS app.
### Enable iOS capabilities in Xcode
1. Select the **App** target → **Signing & Capabilities**.
2. Click **+ Capability** and add **Push Notifications**.
3. Click **+ Capability** again and add **Background Modes**.
4. Under Background Modes, enable **Remote notifications**.
These match what silent / data wake flows expect for background delivery.
### Configure Firebase Admin for the backend
**notification-wakeup-service** uses the Firebase Admin SDK to send FCM (and thus APNs) messages from your Mac.
1. Firebase Console → **Project settings** → **Service accounts**.
2. Click **Generate new private key** and confirm download of the JSON file.
3. Store the JSON outside the repo (do not commit it).
4. Point the backend at it, for example:
```bash
export GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/service-account.json"
```
The backend uses this credential to authenticate with Firebase when calling endpoints such as `/debug/send-wakeup`. Set the same variable (or the equivalent env var documented in **notification-wakeup-service**) in the shell where you run `npm run dev`, or add it to that repo’s `.env` per its README.
### Verify Firebase configuration
Before ngrok end-to-end testing, confirm:
- [ ] App builds and launches on a **physical** iPhone without Firebase/plist errors in Xcode.
- [ ] iOS shows the push **permission** prompt (or Settings → app → Notifications is enabled).
- [ ] **Notification Debug Panel** shows an FCM token (after permission).
- [ ] **Register Token Now** succeeds and ngrok (or local backend) shows `POST /notifications/register`.
- [ ] Backend health and Firebase Admin env are set so `/debug/send-wakeup` can run when you reach that step in the workflow below.
---
## 6. Configure the Notification Debug Panel backend override
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
### Open the panel
1. Use a **non-production** bundle (e.g. dev/test build).
2. **Account** → enable **Show All General Advanced Functions**.
3. Open **Notification Debug Panel** (route `/dev/notifications`).
### Backend Testing section
| Control | Purpose |
|---------|---------|
| **Notification Backend URL** | Paste ngrok HTTPS URL → **Save Backend URL** (changes target server only) |
| **Test Mode** | Sends `testMode: true/false` in register / send-wakeup JSON bodies (default on when unset in storage) |
| **Skip JWT Authentication (Local Development Only)** | When on, omits `Authorization` headers for local servers that accept unauthenticated requests (default **off**) |
| **Register Token Now** | `POST /notifications/register` with current FCM token |
| **Upload AlertSearch Authorization** | Uploads AlertSearch delegated JWTs |
| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; FCM/APNs delivery diagnostic only |
| **Event Log** | Shared `[Notifications]` panel log (100 entries) |
Persistence: `localStorage` keys `notificationDebug.backendBaseUrl`, `notificationDebug.testMode`, and `notificationDebug.bypassAuth` (`NotificationDebugConfig.ts`).
### Authentication vs backend URL
These settings are **independent**:
- **Backend URL** — which server receives notification API calls.
- **Test Mode** — `testMode` field in JSON request bodies only.
- **Skip JWT Authentication** — whether JWT `Authorization` headers are sent.
For a **hosted shared test server**: set the backend URL, keep **Test Mode** on if required, leave **Skip JWT Authentication** **off**, and ensure an active DID exists.
For **local ngrok**: set the backend URL; enable **Skip JWT Authentication** only if your local backend accepts unauthenticated requests.
### Programmatic override (optional)
From Safari Web Inspector or a dev console attached to the WebView:
```javascript
import {
setBackendBaseUrl,
setTestMode,
setBypassAuth,
getNotificationApiBaseUrl,
} from "@/services/notifications";
setBackendBaseUrl("https://abc123.ngrok-free.app");
setTestMode(true);
setBypassAuth(true); // local dev only — omit for hosted servers that require JWT
getNotificationApiBaseUrl(); // → ngrok URL
```
---
## 7. Firebase and Xcode checklist (iOS)
This section is a quick verification checklist for the detailed Firebase/APNs setup steps above.
| Item | Action |
|------|--------|
| **Bundle ID** | Match Capacitor `appId` (`app.timesafari` in `capacitor.config.ts`) to Firebase iOS app and Xcode target |
| **APNs auth key** | Firebase Console → Project Settings → Cloud Messaging → upload **APNs Authentication Key** (.p8) or certificates |
| **Push Notifications** | Xcode target → **Signing & Capabilities** → **+ Capability** → **Push Notifications** |
| **Background Modes** | Enable **Remote notifications** (and any others required by your plugin docs) |
| **GoogleService-Info.plist** | Present in the iOS target if using Firebase iOS SDK paths in your build |
| **FCM token** | Confirm **Register Token Now** succeeds in the debug panel and ngrok shows `POST /notifications/register` |
Silent/data pushes used for wake typically use a **content-available** style payload; confirm **notification-wakeup-service** and Firebase message format. The app logs `WAKEUP_PING` as ignored and does not refresh.
---
## 8. iOS-specific testing notes
### Physical device required
- APNs silent delivery and background wake behavior are **not** representative on the iOS Simulator.
- Always validate on a plugged-in or trusted wireless device with a development provisioning profile.
### Silent push is best-effort
- iOS may **delay or coalesce** background pushes, especially on battery saver or under load.
- A successful `/debug/send-wakeup` from the server does not guarantee immediate app wake.
### Force-quit limitations
- If the user **swipes the app away** from the app switcher, iOS often **will not** deliver background notifications until the user launches the app again.
- Test with the app **backgrounded** (home button / gesture), not force-quit, when validating wake.
### Low Power Mode and Focus
- **Low Power Mode** can reduce background execution.
- **Focus / Do Not Disturb** may affect notification presentation (separate from silent data wake, but confusing during tests).
### Send Real WAKEUP_PING (FCM/APNs diagnostic)
**Send Real WAKEUP_PING** posts `/debug/send-wakeup`. Delivery is confirmed by `push handler ignored type=WAKEUP_PING`. Mock refresh, Simulate WAKEUP_PING, and Wakeup Ping Simulator were removed.
---
## 9. Recommended debug workflow
1. Start **notification-wakeup-service** on the Mac.
2. Start **ngrok** and copy the HTTPS URL.
3. Set URL + **Test Mode** in the Notification Debug Panel; confirm **Backend Status**.
4. Tap **Register Token Now** → confirm ngrok request and `[Notifications] Token registration success`.
5. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service, or tap **Send Real WAKEUP_PING**.
6. Watch **Xcode console** for `[Notifications] push handler ignored type=WAKEUP_PING`.
7. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests (register and send-wakeup; not app-initiated refresh).
8. Use **Pending Notification Inspector** for Daily Reminder / dual / fetcher schedules.
---
## 10. Sample curl commands
Set your tunnel base URL:
```bash
export BASE="https://abc123.ngrok-free.app"
```
### Health
```bash
curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
```
### Register device (mirror app payload)
```bash
curl -sS -X POST "$BASE/notifications/register" \
-H "Content-Type: application/json" \
-d '{
"deviceId": "00000000-0000-4000-8000-000000000001",
"fcmToken": "YOUR_FCM_TOKEN_FROM_DEBUG_PANEL",
"platform": "ios",
"testMode": true
}'
```
### Refresh (backend only; app does not consume)
```bash
curl -sS -X POST "$BASE/notifications/refresh" \
-H "Content-Type: application/json" \
-d '{
"platform": "ios",
"testMode": true
}'
```
This exercises **notification-wakeup-service** only. The app does **not** call `applyNotificationRefreshPayload` or schedule `api_*` from the response.
### Send wakeup push (debug)
Exact path and body depend on **notification-wakeup-service**; typical pattern:
```bash
curl -sS -X POST "$BASE/debug/send-wakeup" \
-H "Content-Type: application/json" \
-d '{
"deviceId": "00000000-0000-4000-8000-000000000001",
"testMode": true
}'
```
Confirm parameters (token vs deviceId, auth headers) in that repo’s README or OpenAPI spec.
---
## 11. Troubleshooting
### Refresh endpoint unreachable
| Symptom | Checks |
|---------|--------|
| Mac `curl` of `/notifications/refresh` fails | ngrok running? URL saved without typo/trailing slash? |
| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` (backend route; the app does not call it) |
| CORS (web only) | Native Capacitor fetch usually avoids browser CORS; if testing in Safari PWA, configure CORS on the service |
| ngrok browser warning | Free tier may show an interstitial for browser clients; native `fetch` from the app is usually unaffected |
### Token registration failures
- Push permission granted on the device?
- Firebase `VITE_FIREBASE_*` env vars baked into the build?
- `[Notifications] Token registration failure` in Xcode — read HTTP status in ngrok inspect
- Duplicate token skip: panel may show “skipped (duplicate)”; use **Register Token Now** to force re-register
### Silent push not waking the app
- App **backgrounded**, not force-quit
- Physical device, correct provisioning profile
- APNs key uploaded to Firebase; bundle ID matches
- FCM message includes `data.type = "WAKEUP_PING"` (logged as ignored in `NativeNotificationService.ts`)
- Server actually sent to the **same** FCM token shown in the debug panel
- Wait 30–120s — delivery is not instant
- Confirm Xcode shows `push handler ignored type=WAKEUP_PING` (there is no Simulate WAKEUP_PING / refresh API in the app)
### Notifications duplicating
- Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md`
- Leftover `api_*` before Phase 4 cleanup (startup `clearApiNotifications()`)
### Stale ngrok URL
- After restarting ngrok, update **Notification Backend URL** in the panel and tap **Save**
- Or clear override (empty field + Save) only if you intend to hit `DEFAULT_NOTIFY_API_SERVER` again
### Plugin / JWT errors after refresh
- Refresh calls `configureNativeFetcherIfReady()` before scheduling — ensure an **active DID** and endorser API settings exist in the app DB
- See `doc/notification-from-api-call.md` and `nativeFetcherConfig.ts`
---
## 12. Key source files (crowd-funder-for-time-pwa)
| File | Purpose |
|------|---------|
| `src/services/notifications/NotificationDebugConfig.ts` | Backend URL, testMode, and bypassAuth overrides |
| `src/services/notifications/NotificationDebugEvents.ts` | Panel event log + `logNotification()` |
| `src/services/notifications/notificationLog.ts` | Structured log helpers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
| `src/services/notifications/firebaseMessagingClient.ts` | Capacitor push listeners |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/main.capacitor.ts` | Native push init at startup |
---
## 13. Related docs
- [notification-debug-panel.md](./notification-debug-panel.md) — panel controls, authentication, troubleshooting
- [Notification Debug Panel (README)](../README.md#notification-debug-panel-dev-builds)
- [notification-system-overview.md](./notification-system-overview.md)
- [notification-from-api-call.md](./notification-from-api-call.md)
- [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md)
- [BUILDING.md](../BUILDING.md) — iOS build commands
For plugin-native behavior (exact alarm, iOS pending inspector), see **daily-notification-plugin** documentation. For FCM payload format and `/debug/send-wakeup` contract, see **notification-wakeup-service**.
@@ -0,0 +1,158 @@
# New Activity Notifications: iOS Parity with Android
**Purpose:** Describe what is required for **iOS** to match **Android** for the daily-notification-plugin **API-driven “New Activity”** flow (`scheduleDualNotification` / `cancelDualSchedule`, with prefetch and Endorser-backed content). The canonical product behavior is documented in `doc/notification-from-api-call.md` and `doc/notification-new-activity-lay-of-the-land.md`.
**Plugin source of truth:** The Capacitor package is `@timesafari/daily-notification-plugin`, pulled from the official remote in `package.json` (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`). Plugin development happens in that repository; this app bumps the dependency and runs `npm install` / `npx cap sync` after releases.
---
## 1. What “parity” means here
| Concern | Intended behavior |
|--------|---------------------|
| **Scheduling** | Dual schedule: prefetch job **before** notify time (app uses cron T−5 minutes), then user-visible notification at the chosen time. |
| **API content** | Prefetch calls the **same Endorser semantics** as the Android host: **`plansLastUpdatedBetween`** (POST) with **starred plan IDs**, JWT auth, aggregated titles/bodies consistent with `TimeSafariNativeFetcher`. |
| **Starred plans** | `updateStarredPlans({ planIds })` from the app must affect what the native prefetch queries. |
| **Configure** | `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken, … })` supplies credentials the native layer uses for prefetch. |
| **Lifecycle** | `cancelDualSchedule()` removes the dual prefetch + notify schedule without breaking the separate Daily Reminder. |
Platform differences (iOS **BGTaskScheduler** is opportunistic; Android **alarms/WorkManager** can be more exact) mean **timing** may never be identical, but **API behavior and user-visible copy** should align.
---
## 2. Current state: Android (this app)
- **Host native fetcher:** `android/.../TimeSafariNativeFetcher.java` implements the plugin’s `NativeNotificationContentFetcher` and calls **`POST …/api/v2/report/plansLastUpdatedBetween`** using starred plan IDs (via plugin storage from `updateStarredPlans`).
- **Registration:** `MainActivity` calls `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
- **Plugin (Android) — older notes:** Prior dual-schedule issues (native fetcher / fetch cron) are addressed in **plugin ≥ 3.0.0** (chained dual: notify after prefetch). Historical analysis: `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md`.
---
## 3. Current state: iOS (this app + bundled plugin)
### 3.1 This repository
- **iOS native fetcher:** `ios/App/App/TimeSafariNativeFetcher.swift` implements `NativeNotificationContentFetcher` (Endorser `plansLastUpdatedBetween`, same prefs keys as Java). **`AppDelegate`** calls `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any `configureNativeFetcher` from JS (see plugin `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` and **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
- **JS/TS is already shared:** `nativeFetcherConfig.ts`, `dualScheduleConfig.ts`, `syncStarredPlansToNativePlugin.ts`, and `AccountViewView.vue` call the same APIs on both platforms.
- **Info.plist** already lists `UIBackgroundModes` (fetch, processing) and `BGTaskSchedulerPermittedIdentifiers` for the plugin’s task IDs. Xcode **Signing & Capabilities** should still enable **Background fetch** and **Background processing** (see `doc/daily-notification-plugin-integration.md`).
- **AppDelegate** posts `DailyNotificationDelivered` for foreground presentation—aligned with plugin rollover behavior.
### 3.2 Bundled plugin (`node_modules/@timesafari/daily-notification-plugin`, iOS)
Requires **plugin ≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Version pinned in `ios/App/Podfile.lock` after `pod install`.
- **`scheduleDualNotification` / `cancelDualSchedule`** — see plugin release notes; clean sync + `pod install` if you see `UNIMPLEMENTED` (`doc/plugin-feedback-ios-scheduleDualNotification.md`).
- **`configureNativeFetcher`** — **requires** `DailyNotificationPlugin.registerNativeFetcher` first; the host Swift fetcher performs **`plansLastUpdatedBetween`** (plugin does not use in-plugin `offers` GET when a fetcher is registered—mirrors Android).
- **`updateStarredPlans`** — implemented on iOS in current plugin; persists **`daily_notification_timesafari.starredPlanIds`** for the host fetcher.
- **Chained dual** — user notification is armed **after** prefetch for that cycle (plugin); iOS remains subject to BG scheduling limits; see **§3.3**.
### 3.3 Prefetch before notify (ordering, not cron)
iOS has no system cron; the app/plugin may still **parse** cron to compute “next run” times. The hard part is **ordering**: if **prefetch** is driven by **`BGTaskScheduler`** (opportunistic) and **notify** by **`UNUserNotificationCenter`** at a fixed time **T**, those are **independent**. The OS can deliver the local notification at **T** while prefetch runs **after** **T** or not at all—so the awkward case (notify first, prefetch later, stale or fallback content) **can** happen. Two peer timers do **not** imply “fetch always completes before **T**.”
To **enforce** prefetch-before-notify as a rule, use **chaining**, not two unrelated schedules:
- After prefetch for that cycle **finishes** (success or explicit timeout policy), **then** schedule or **replace** the pending `UNNotificationRequest` for time **T** with the resolved title/body (or fallback). Until then, do not arm a user-visible notification that claims fresh API content.
- **Tradeoffs:** If prefetch is late, the notification may be **late**; if prefetch never runs before a deadline, use **fallback** copy at **T** or skip—product choice.
- **Parsing cron** remains useful to compute **T** and to decide when to **submit** BG work; **ordering** is a **pipeline** decision (fetch → cache → arm notify), not “BG at T−5 and UN at **T** both scheduled up front.”
Plugin work item **§4A.3** should reflect this: document the chosen strategy (chained arm vs best-effort dual timer) and how it interacts with `relationship.contentTimeout` / fallback.
---
## 4. Work breakdown
### 4A. Plugin (`daily-notification-plugin`) — status (v3.x)
Items below were the original gap list; **plugin ≥ 3.0.0** ships **iOS** `updateStarredPlans`, **`registerNativeFetcher`**, **chained dual** on iOS and Android, and Android dual-path fixes. Remaining work is **release coordination** (bump, sync, QA), not greenfield plugin implementation.
1. **`updateStarredPlans` on iOS** — shipped in current plugin.
2. **iOS `plansLastUpdatedBetween` / host fetcher** — shipped: host registers **`TimeSafariNativeFetcher`** (Swift); plugin does not duplicate Endorser logic when a fetcher is registered.
3. **Dual schedule / chaining** — shipped (notify after prefetch; see plugin release notes and **§3.3**).
4. **Android dual path** — chained dual + native fetcher alignment in current plugin (see `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` for historical context).
5. **JWT pool / expiry (Phase B)**
- **App:** Phase B is already implemented: `configureNativeFetcherIfReady()` passes `jwtTokens` from `mintBackgroundJwtTokenPool` on **both** iOS and Android (`src/services/notifications/nativeFetcherConfig.ts`).
- **Android:** `TimeSafariNativeFetcher` selects a bearer from the pool for background requests (`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`).
- **iOS:** The bundled plugin’s `configureNativeFetcher` **already accepts and persists** `jwtTokens` / `jwtTokenPoolJson`, and the in-plugin fetch path uses a bearer from the primary token or pool. What is **not** yet at parity with Android is **which API** that token is used for (`offers` GET vs `plansLastUpdatedBetween` + starred plans)—that falls under **§4A.2**, not “waiting for Phase B on iOS.”
- **Expiry:** Re-calling `configureNativeFetcherIfReady` on foreground / Account (see `notification-from-api-call.md`) remains relevant on both platforms.
### 4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes
1. **Bump `@timesafari/daily-notification-plugin`** to **≥ 3.0.0** via the git dependency in `package.json`, run `npm install`, `npx cap sync ios`, `cd ios/App && pod install`, clean build (`doc/plugin-feedback-ios-scheduleDualNotification.md`, **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
2. **iOS native fetcher** — **Done:** `TimeSafariNativeFetcher.swift` + `registerNativeFetcher` in `AppDelegate` (see handoff doc).
3. **Re-test** `syncStarredPlansToNativePlugin` on iOS; the helper may still catch `UNIMPLEMENTED` for older plugin binaries.
4. **Xcode:** Confirm Background Modes capabilities match `Info.plist`.
5. **QA:** Full matrix in `doc/notification-from-api-call.md` (enable/disable, empty starred list, JWT expiry, foreground/background); chained dual timing (notify after prefetch).
### 4C. Related product bug (both platforms)
- **`PushNotificationPermission.vue` vs New Activity:** Enabling New Activity can still schedule the **single** daily reminder by mistake; turning New Activity off may not cancel that reminder. See `doc/notification-new-activity-lay-of-the-land.md`. Fixing this is orthogonal to iOS/Android API parity but affects perceived “notifications behavior.”
---
## 5. Reference map (this repo)
| Topic | Document |
|-------|-----------|
| Plugin post-bump handoff (iOS fetcher + chained dual) | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
| Feature plan & file list | `doc/notification-from-api-call.md` |
| Dual vs Daily Reminder confusion | `doc/notification-new-activity-lay-of-the-land.md` |
| iOS `UNIMPLEMENTED` / PluginHeaders | `doc/plugin-feedback-ios-scheduleDualNotification.md` |
| Android dual schedule + native fetcher | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
| Integration & Xcode | `doc/daily-notification-plugin-integration.md` |
| Android host fetcher | `android/.../TimeSafariNativeFetcher.java`, `MainActivity.java` |
---
## 6. Handoff to plugin repo (Cursor / isolated workspace)
Use this section when **daily-notification-plugin** is open **without** the TimeSafari app tree, so implementers do not depend on paths that only exist in crowd-funder-for-time-pwa.
### 6.1 Bring reference material into scope
| Source (this app repo) | Why |
|------------------------|-----|
| `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | **Canonical Endorser behavior** for New Activity: POST body, pagination, aggregation copy, prefs keys for starred IDs and `last_acked_jwt_id`. Copy or open alongside the plugin when implementing iOS fetch or `setNativeFetcher`. |
| `src/services/notifications/dualScheduleConfig.ts` | Shape the app sends to `scheduleDualNotification` (`buildDualScheduleConfig`). |
| `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | Android plugin: dual path must call native fetcher at fetch cron. |
| `doc/plugin-feedback-ios-scheduleDualNotification.md` | iOS `UNIMPLEMENTED` / PluginHeaders troubleshooting. |
In the plugin repo itself, align with **`src/definitions.ts`** (`DualScheduleConfiguration`, `configureNativeFetcher`, `updateStarredPlans`) and **INTEGRATION_GUIDE** if present.
### 6.2 HTTP / storage contract (match `TimeSafariNativeFetcher`)
Implementations on **iOS** (in-plugin Swift or host `NativeNotificationContentFetcher`) should match this **unless** product explicitly changes:
- **Method & path:** `POST` `{apiBaseUrl}/api/v2/report/plansLastUpdatedBetween` (no trailing slash mismatch on `apiBaseUrl`).
- **Headers:** `Content-Type: application/json`, `Authorization: Bearer {token}` (token from `jwtToken` or **JWT pool** selection—see Java `selectBearerTokenForRequest`: UTC day mod pool size).
- **JSON body:** `planIds` (array of strings, possibly empty), `afterId` (string; use `"0"` if none stored).
- **Starred plans:** Android: SharedPreferences **`daily_notification_timesafari`** + key **`starredPlanIds`**. iOS (plugin + host): `UserDefaults.standard` key **`daily_notification_timesafari.starredPlanIds`** (JSON array string).
- **Pagination:** After a successful response with non-empty `data`, update **`last_acked_jwt_id`** from the last row’s `jwtId` (item or nested `plan.jwtId`)—see Java `updateLastAckedJwtIdFromResponse`. iOS host (`TimeSafariNativeFetcher.swift`) persists **`daily_notification_timesafari.last_acked_jwt_id`** in `UserDefaults.standard`.
- **Empty `data`:** Return **no** notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns empty `contents` when `data` is absent or empty.
- **Non-empty `data`:** One aggregated `NotificationContent`: titles **Starred Project Update** / **Starred Project Updates**, bodies use typographic quotes around first project name and **has been updated.** / **+ N more have been updated.** (see Java `parseApiResponse`).
### 6.3 Likely plugin touchpoints (maintenance / debugging)
- **iOS:** `ios/Plugin/DailyNotificationPlugin.swift`, `DailyNotificationScheduleHelper.swift`, native fetcher registry, BG / UN paths.
- **Android:** `DailyNotificationPlugin.kt`, fetch workers / `ScheduleHelper`—see dual-schedule feedback doc for history.
### 6.4 Suggested order (plugin shipped ≥ 3.0.0)
1. Tag / publish **`@timesafari/daily-notification-plugin`**.
2. **Consuming app:** bump, `npm install`, `npx cap sync`, `pod install`, QA (`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`).
---
## 7. Acceptance checklist (iOS vs Android product intent)
- [ ] Prefetch uses **plansLastUpdatedBetween** (or host fetcher with identical behavior), not only `offers` GET.
- [ ] **Starred plan IDs** from settings change what is queried (`updateStarredPlans` works on iOS).
- [ ] Notification title/body match the **same rules** as Android for “starred project updates” (including empty updates).
- [ ] `configureNativeFetcher` + JWT refresh story documented; re-config on foreground if needed (`notification-from-api-call.md`).
- [ ] `cancelDualSchedule` clears dual prefetch/notify without leaving orphan schedules.
- [ ] Understand and document **iOS timing** limitations vs Android for support/Help copy.
- [ ] **Prefetch vs notify ordering** on iOS: chosen strategy (chained arm vs independent BG + UN) documented; avoids claiming fresh API content when prefetch has not run yet (**§3.3**).
+229
View File
@@ -0,0 +1,229 @@
# Notification Debug Panel
**Created:** 2026-07-07
**Updated:** 2026-09-24
**Audience:** Developers testing notification registration, AlertSearch authorization upload, and FCM delivery diagnostics on native (iOS/Android) dev builds.
The **Notification Debug Panel** is a dev-only UI for FCM token registration, AlertSearch authorization upload, FCM wakeup delivery diagnostics (`/debug/send-wakeup`), and local schedule inspection. It does not schedule notifications from `WAKEUP_PING`. The production `WAKEUP_PING` → `/notifications/refresh` → `api_*` path was retired; leftover `api_*` schedules are cleared once at startup (Phase 4).
---
## Notification API base URL
Notification HTTP calls (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`. The app does not call `/notifications/refresh`.
### Configuration constants
| Symbol | Location | Purpose |
|--------|----------|---------|
| `VITE_DEFAULT_NOTIFY_API_SERVER` | `.env.development` / `.env.test` / `.env.production` | Build-time default Notification API URL for that Vite mode (same pattern as other `VITE_DEFAULT_*` backends) |
| `DEFAULT_NOTIFY_API_SERVER` | `src/constants/app.ts` | Runtime constant: `import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER \|\| AppString.PROD_NOTIFY_API_SERVER` |
| `AppString.PROD_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded production fallback: `https://notify-api.timesafari.app` |
| `AppString.TEST_NOTIFY_API_SERVER` | `src/constants/app.ts` | Hardcoded test host: `https://test-notify-api.timesafari.app` (for explicit UI/debug use; not the automatic fallback) |
Production, test, and development builds get different Notification API URLs from their respective `.env.*` files. Runtime request code always goes through `DEFAULT_NOTIFY_API_SERVER` (via `getNotificationApiBaseUrl()`), not by reading the env var directly at each call site.
Typical values today:
| Build / env file | `VITE_DEFAULT_NOTIFY_API_SERVER` |
|------------------|----------------------------------|
| `.env.production` | `https://notify-api.timesafari.app` |
| `.env.test` | `https://test-notify-api.timesafari.app` |
| `.env.development` | `https://test-notify-api.timesafari.app` |
### URL resolution order
`getNotificationApiBaseUrl()` selects the base URL in this order:
1. **Debug Panel backend override** — `localStorage` key `notificationDebug.backendBaseUrl` (set via **Save Backend URL** or `setBackendBaseUrl()`)
2. **`VITE_DEFAULT_NOTIFY_API_SERVER`** — baked into the build as part of `DEFAULT_NOTIFY_API_SERVER`
3. **`AppString.PROD_NOTIFY_API_SERVER`** — hardcoded fallback when the env var is unset (`https://notify-api.timesafari.app`)
Clearing the Debug Panel override (empty field + Save) returns the app to step 2 / 3 (`DEFAULT_NOTIFY_API_SERVER`). The override never changes auth behavior by itself.
`APP_SERVER` / `VITE_APP_SERVER` remain for deep links and the main app web host only — not for notification API traffic.
---
## Access
1. Use a **non-production** build (for example `build:android:dev`, `build:ios:dev`, or `vite dev` with a non-`production` mode).
2. Open **Account** → enable **Show All General Advanced Functions**.
3. Open **Notification Debug Panel** (route `/dev/notifications`).
On native platforms, grant notification permission when prompted so FCM token registration and the debug actions work.
---
## Configuration (Backend Testing)
Settings persist in `localStorage` via `NotificationDebugConfig.ts`:
| Key | Default | Purpose |
|-----|---------|---------|
| `notificationDebug.backendBaseUrl` | *(unset — use `DEFAULT_NOTIFY_API_SERVER`)* | Override which notification server receives API calls |
| `notificationDebug.testMode` | `true` | Sent in JSON request bodies (`testMode: true/false`) |
| `notificationDebug.bypassAuth` | `false` | When `true`, omit JWT `Authorization` headers on notification API calls |
All notification API requests (`/notifications/register`, `/notifications/alert-authorization`, `/debug/send-wakeup`, etc.) obtain headers through `getNotificationApiHeaders()` in `notificationApiAuth.ts`.
### Notification Backend URL
Paste a base URL (no trailing slash) and tap **Save Backend URL**. This changes **only** which server the app calls (`getNotificationApiBaseUrl()`). It does **not** disable JWT authentication.
Leave empty to use the configured build default (`DEFAULT_NOTIFY_API_SERVER`, from `VITE_DEFAULT_NOTIFY_API_SERVER` or `AppString.PROD_NOTIFY_API_SERVER`). The Debug Panel override still wins whenever a non-empty URL is saved.
### Test Mode
When enabled (default if never saved), register and send-wakeup requests include `"testMode": true` in the JSON body. The backend can use this to route test traffic separately from production.
Test Mode is **independent of authentication**. It does not control whether `Authorization` headers are sent.
### Skip JWT Authentication (Local Development Only)
When **off** (default), the app resolves the active DID and sends `Authorization: Bearer …` on notification API calls.
When **on**, requests include only `Content-Type: application/json` — for local servers (localhost or ngrok) that intentionally accept unauthenticated notification requests during development.
Enable this **only** for local development backends that do not require JWT. Hosted shared test servers that require normal app authentication should leave this **off**.
The panel **Backend Status** section shows the active URL, `testMode`, and `bypassAuth` values.
---
## Recommended settings
### Hosted test server
Example: `https://test-notify-api.timesafari.app`
On development and test builds, this host is already the default via `VITE_DEFAULT_NOTIFY_API_SERVER`. You can leave **Notification Backend URL** empty, or paste the same URL explicitly.
| Setting | Value |
|---------|-------|
| **Notification Backend URL** | Empty (use default) or `https://test-notify-api.timesafari.app` |
| **Test Mode** | **ON** (if the server expects `testMode: true`) |
| **Skip JWT Authentication** | **OFF** |
Ensure the app has an **active identity (DID)** with a valid endorser session so JWT headers can be built.
### Local localhost / ngrok development
Example: `https://abc123.ngrok-free.app` or `http://127.0.0.1:3000`
| Setting | Value |
|---------|-------|
| **Notification Backend URL** | Your local or ngrok URL |
| **Test Mode** | **ON** or **OFF** — match what your local **notification-wakeup-service** expects |
| **Skip JWT Authentication** | **ON** only if the local server accepts unauthenticated requests; **OFF** if it validates JWT like production |
---
## Backend Testing actions
| Action | What it does |
|--------|----------------|
| **Register Token Now** | `POST {backend}/notifications/register` with current FCM token, `deviceId`, `platform`, and `testMode`. Forces re-registration (bypasses duplicate-token skip). |
| **Upload AlertSearch Authorization** | Mints and uploads delegated AlertSearch JWTs (`POST /notifications/alert-authorization`). Requires an active `did:ethr` identity; Test Mode is not used. |
| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. FCM delivery diagnostic only — the app logs `push handler ignored type=…` and does **not** call `/notifications/refresh` or schedule `api_*` notifications. Background the app before expecting delivery. |
**Current FCM Token** displays the last token from Capacitor/Firebase registration. **Event Log** shows the last 100 `[Notifications]` messages (also visible in logcat / Xcode console on native).
---
## Other panel sections
| Section | Purpose |
|---------|---------|
| **Pending Notification Inspector** | Lists locally scheduled notifications (Daily Reminder, New Activity / dual, and any leftover `api_*` until Phase 4 cleanup). |
| **Clear Notifications** | Clears/cancels plugin-scheduled notifications on native. Does not replace the one-time `api_*` startup cleanup. |
---
## Programmatic override (optional)
From a WebView dev console (`chrome://inspect` on Android, Safari Web Inspector on iOS):
```javascript
import {
setBackendBaseUrl,
setTestMode,
setBypassAuth,
getNotificationApiBaseUrl,
} from "@/services/notifications";
setBackendBaseUrl("https://abc123.ngrok-free.app");
setTestMode(true);
setBypassAuth(true); // local dev only
getNotificationApiBaseUrl();
```
---
## Troubleshooting
### 401 Unauthorized (`registerToken failed: unauthorized`)
**Likely causes:** JWT required but **Skip JWT Authentication** is off and the session is missing or expired; or JWT sent but the server rejected it.
**Checks:**
1. Panel **Backend Status** → `bypassAuth: false` for hosted servers.
2. App has an active DID and endorser login.
3. Event Log: look for `Using authenticated notification request` vs `Using debug unauthenticated notification request`.
4. For hosted test server: keep **Skip JWT Authentication** **OFF**.
**Fixes:** Sign in / restore identity; refresh endorser session; for local ngrok without JWT support, enable **Skip JWT Authentication**.
### `registerToken auth unavailable` / `Waiting for auth before registration`
The app deferred registration because JWT could not be built (no active DID or empty token) and **Skip JWT Authentication** is **off**.
**Fixes:** Complete identity setup in the app, or enable **Skip JWT Authentication** only for an intentionally unauthenticated local backend.
### Failed to fetch / network error
**Likely causes:** Backend down, wrong URL, stale ngrok tunnel, device offline, or TLS/certificate issues.
**Checks:** Panel **Backend Status** URL; `curl -sS "$URL/health"` from your machine; ngrok inspect UI for incoming requests.
**Fixes:** Restart backend and ngrok; **Save Backend URL** with the current HTTPS forwarding URL (no trailing slash).
### Backend unreachable / no requests in ngrok
Same as above. Confirm the **Active** URL in the panel matches your running tunnel or local server port.
### Register succeeds but Send Real WAKEUP_PING does not show delivery
**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. It does **not** schedule local `api_*` notifications. Delivery is confirmed when logcat / Event Log shows `push handler ignored type=WAKEUP_PING` (the retired refresh chain is gone).
**Checks:** App backgrounded (not force-stopped); FCM token matches registration; Firebase / Play services available.
See platform-specific guides for extended ngrok and FCM workflows:
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
---
## Key source files
| File | Purpose |
|------|---------|
| `src/constants/app.ts` | `DEFAULT_NOTIFY_API_SERVER`, `PROD_NOTIFY_API_SERVER`, `TEST_NOTIFY_API_SERVER` |
| `src/components/dev/NotificationDebugPanel.vue` | Dev UI |
| `src/services/notifications/NotificationDebugConfig.ts` | Base URL resolution, testMode, bypassAuth persistence |
| `src/services/notifications/notificationApiAuth.ts` | JWT vs unauthenticated headers |
| `src/services/notifications/notificationApiDebugMode.ts` | Auth bypass gate |
| `src/services/notifications/NotificationDebugService.ts` | Panel action handlers |
| `src/services/notifications/NotificationService.ts` | `POST /notifications/register` |
| `src/services/notifications/NativeNotificationService.ts` | Push delivery hook (logs ignored types; no refresh) |
---
## Related docs
- [notification-system-overview.md](./notification-system-overview.md)
- [notification-from-api-call.md](./notification-from-api-call.md)
- [local-android-testing-ngrok.md](./local-android-testing-ngrok.md)
- [local-ios-testing-ngrok.md](./local-ios-testing-ngrok.md)
+5
View File
@@ -59,6 +59,8 @@ The app must:
### iOS
**Parity outline (API, starred plans, plugin vs app work):** See **`doc/new-activity-notifications-ios-android-parity.md`**.
- **Confirm iOS native fetcher / dual schedule**
Plugin exposes `configureNativeFetcher` on iOS. Confirm whether the plugin expects an iOS-specific native fetcher registration (similar to Android’s `setNativeFetcher`) and, if so, register a TimeSafari fetcher implementation for iOS so API-driven notifications work on iPhone.
- **Verify dual schedule on iOS**
@@ -99,5 +101,8 @@ Add a short “New Activity notifications” section to BUILDING.md or a user-fa
| Settings type | `src/interfaces/accountView.ts` |
| Android native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
| Android registration | `android/app/src/main/java/app/timesafari/MainActivity.java` |
| iOS native fetcher | `ios/App/App/TimeSafariNativeFetcher.swift` |
| iOS registration | `ios/App/App/AppDelegate.swift` (`DailyNotificationPlugin.registerNativeFetcher`) |
| Plugin 3.x handoff | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
@@ -220,7 +220,7 @@ The steps and expected notification copy below are **Android-specific**: this re
## 8. Plugin Repo Alignment and Attention Items
Comparison with the **daily-notification-plugin** repo (e.g. `daily-notification-plugin_test` or gitea `master`) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
Comparison with the **daily-notification-plugin** repo on gitea (`trent_larson/daily-notification-plugin`, `master` or the tag this app pins) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
### 8.1 What lines up
-203
View File
@@ -1,203 +0,0 @@
# Plan: Background New Activity JWT — extended expiry + token pool
**Date:** 2026-03-27 14:29 PST
**Status:** Draft for implementation
**Audience:** TimeSafari / crowd-funder developers
**Related:** `doc/endorser-jwt-background-prefetch-options.md`, `android/.../TimeSafariNativeFetcher.java`, `src/services/notifications/nativeFetcherConfig.ts`, `src/libs/crypto/index.ts`
---
## 1. Problem statement
Background prefetch for New Activity calls Endorser with a Bearer JWT configured via `configureNativeFetcher`. The token previously came from `getHeaders()` → `accessToken()`, which used **`exp` ≈ 60 seconds** (`src/libs/crypto/index.ts`). Prefetch runs **minutes later** in WorkManager **without JavaScript**, so the JWT can be **expired** before the POST (`JWT_VERIFY_FAILED`).
**Goals:**
1. Use JWTs whose **`exp`** covers the gap between **last app-side configure** and **prefetch** (and ideally days without opening the app).
2. Optionally support a **pool** of distinct JWT strings so Endorser can enforce **duplicate-JWT** / **one-time-use** rules without breaking daily prefetch. **Pool size** should follow **`expiryDays + buffer`** (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); **implementation uses `BACKGROUND_JWT_POOL_SIZE = 100`** until policy changes.
3. Keep pool size and expiry policy **easy to change** (constants / remote config later).
---
## 2. Guiding principles
| Principle | Implication |
|-----------|-------------|
| **Background has no JS** | Token selection and HTTP must run in **native** (or plugin) code using **persisted** data. |
| **Single source of truth for signing** | Continue using **`createEndorserJwtForDid`** (same keys as today); do not fork crypto in Java/Kotlin. |
| **Configurable pool size** | One constant `BACKGROUND_JWT_POOL_SIZE`; **currently 100**. Size should satisfy **`≥ expiryDays + buffer`** (see below). |
| **Phased delivery** | Ship **extended expiry** first; add **pool** when server duplicate rules require it or in the same release if coordinated. |
### 2.1 Pool size rationale (`expiryDays + buffer`)
For **one New Activity prefetch per day**, each day should use a **distinct** JWT string if the server rejects reuse. Over the JWT lifetime (aligned with **`exp`**), you need at least **one token per day** the pool might be used without regeneration.
**Rule of thumb:**
```text
BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
```
- **`BACKGROUND_JWT_EXPIRY_DAYS`** — human-facing match to `exp` (e.g. **90**); convert to `BACKGROUND_JWT_EXPIRY_SECONDS` for the payload.
- **`BACKGROUND_JWT_POOL_BUFFER`** — extra slots for **same-day retries**, manual tests, or stricter duplicate rules (e.g. **10**).
**Example:** 90‑day `exp` + buffer 10 ⇒ **minimum 100** logical slots. **This plan keeps `BACKGROUND_JWT_POOL_SIZE = 100`** as the shipped default so it matches that example; if `expiryDays` or buffer change later, **bump the constant** so the inequality still holds.
---
## 3. Phases
### Phase A — Extended expiry only (minimum viable)
**Scope**
- Introduce a dedicated mint path for **background / native fetcher** use (name TBD, e.g. `accessTokenForBackgroundNotifications(did)`), producing **one** JWT per configure call with:
- `iss`: DID (unchanged)
- `iat`: now
- `exp`: now + **`BACKGROUND_JWT_EXPIRY_SECONDS`** (derived from **`BACKGROUND_JWT_EXPIRY_DAYS`**; see §2.1 / Phase B constants — **confirm** with Endorser policy)
- Optional: `jti` or nonce for uniqueness if needed for logging/debug
- **`configureNativeFetcherIfReady`** should pass this token (or keep using a thin wrapper) instead of reusing the **60s** `accessToken()` when configuring native fetcher **only** — **do not** change interactive `getHeaders()` / passkey caching behavior for normal API calls unless product asks for it.
**Files (likely)**
- `src/libs/crypto/index.ts` — new function or parameters; keep `accessToken()` default at 60s for existing callers.
- `src/services/notifications/nativeFetcherConfig.ts` — obtain background JWT via the new mint path, not `getHeaders()`’s generic path, **or** add a dedicated branch that calls the new mint after resolving `did`.
**Native**
- **`TimeSafariNativeFetcher`**: still one `jwtToken` field; no pool yet. Ensure `configure()` is called whenever TS refreshes (startup, resume, Account — already partially covered).
**Exit criteria**
- Logcat: prefetch POST returns **200** (or non-expired 4xx) when user has not opened the app for several **minutes** after configure.
- Endorser accepts **`exp`** far enough in the future (coordinate TTL policy).
---
### Phase B — Token pool (size 100; driven by `expiryDays + buffer`)
**Why**
- Endorser may **reject duplicate JWT strings** (same bearer used twice). One long-lived token could fail on **day 2** if the server marks each JWT as consumed.
- A **pool** of **N** distinct JWTs (different payload, e.g. unique `jti` per token) gives **N** independent strings with the same long **`exp`**. **N** should follow **§2.1** (`expiryDays + buffer`); **100** is the initial **`BACKGROUND_JWT_POOL_SIZE`** (satisfies e.g. 90 + 10).
**Scope**
1. **Constants** (single place, e.g. `src/constants/backgroundJwt.ts` or next to native fetcher config):
```text
BACKGROUND_JWT_EXPIRY_DAYS = 90 // align with Endorser; drives exp
BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60 // derived
BACKGROUND_JWT_POOL_BUFFER = 10 // retries / headroom; tune with server team
BACKGROUND_JWT_POOL_SIZE = 100 // must be >= expiryDays + buffer; adjust if policy changes
```
2. **Mint in TS** (uses `createEndorserJwtForDid`):
- Loop `i = 0 .. POOL_SIZE - 1`
- Payload: `{ iss, iat, exp, jti: `${did}#bg#${i}` or uuid }` — **confirm** `jti` format with Endorser if required.
3. **Persistence** — native code must read the pool **without JS**:
- **Option B1 (preferred):** Implement in **`@timesafari/daily-notification-plugin`** (not in the app): extend **`configureNativeFetcher`** to accept an optional JWT pool, persist it for native read. **Handoff spec:** `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` — copy or reference that file in the plugin repo PR.
- **Option B2 (app-only, no plugin release):** Write JSON to **Capacitor Preferences** or **encrypted storage** from TS; **TimeSafariNativeFetcher** reads the same store on Android (requires knowing Capacitor’s Android `SharedPreferences` name/key convention or a tiny **bridge** in `MainActivity`). Use only if plugin work is deferred.
4. **Selection policy in `TimeSafariNativeFetcher`** (before each POST):
- **By calendar day:** `index = (epochDay + offset) % POOL_SIZE` (stable per day).
- Or **sequential:** persist `lastUsedIndex` in prefs and increment (wrap). **Decision:** document chosen policy; day-based is easier to reason about for “one token per day.”
5. **configureNativeFetcherIfReady** (and any “reset notifications on startup” hook):
- Regenerate full pool when user opens app (per product decision), then call configure with pool + **current** `apiBaseUrl` / `did`.
6. **iOS:** When iOS native fetcher exists, mirror Android behavior.
**Exit criteria**
- Prefetch succeeds on **consecutive days** with duplicate-JWT enforcement enabled on a **staging** Endorser.
- Pool **refreshes** on startup without breaking dual schedule.
---
## 4. Detailed tasks (checklist)
### Crypto & TypeScript
- [ ] Add `BACKGROUND_JWT_EXPIRY_DAYS`, `BACKGROUND_JWT_EXPIRY_SECONDS`, `BACKGROUND_JWT_POOL_BUFFER`, and `BACKGROUND_JWT_POOL_SIZE` (exported constants), with a **comment** that `POOL_SIZE >= expiryDays + buffer` (see §2.1).
- [ ] Implement `mintBackgroundJwtPool(did: string): Promise<string[]>` (or split single + pool).
- [ ] Ensure each JWT has **unique** `jti` (or equivalent) for duplicate detection.
- [ ] **Do not** break existing `accessToken()` 60s behavior for unrelated features.
- [ ] Wire `configureNativeFetcherIfReady` to pass **single extended token** (Phase A) then **pool** (Phase B).
- [ ] On **logout / identity clear**, clear persisted pool and call plugin clear if needed.
### Android
- [ ] **Phase A:** No structural change if `configure()` still receives one string; verify non-null `jwtToken` after configure.
- [ ] **Phase B:** Parse pool from persisted JSON; implement `selectTokenForRequest()`; use selected token in `Authorization` header instead of sole `jwtToken` field (keep `configure` for `apiBaseUrl` / `did`).
- [ ] Unit or instrumentation tests optional: selection index deterministic.
### Plugin (Option B1 — **daily-notification-plugin** repo)
- [ ] Follow **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** (API shape, Android/iOS, versioning).
- [ ] Release new plugin version; bump dependency in this app.
### Product & server
- [ ] Endorser: confirm **max `exp`**, **duplicate JWT** semantics, recommended **`jti`** format.
- [ ] Document operational limit: if user never opens app for **longer than `exp` allows** (or longer than **pool × daily use** without refresh), prefetch may fail until next open — align with `doc/endorser-jwt-background-prefetch-options.md`.
---
## 5. Security notes
- Longer-lived JWTs and **many** tokens increase impact if device is compromised. Mitigations: **encrypted prefs** where possible, **no logging** of full JWTs, **revocation** story with Endorser (key rotation, deny list).
- Pool regeneration on **login** should replace old pools.
---
## 6. Testing plan
| Test | Expected |
|------|----------|
| Configure → wait **> 5 min** → prefetch | **200** from `plansLastUpdatedBetween` (Phase A) |
| Two consecutive **days** with duplicate-JWT staging | **200** both days (Phase B) |
| Logout | Pool cleared; no stale bearer |
| Lower `BACKGROUND_JWT_POOL_SIZE` in dev only (below `expiryDays + buffer`) | Expect possible reuse / server duplicate errors — use to reproduce failures |
---
## 7. Rollout / staging
1. Implement Phase A behind feature flag **optional** (or direct if low risk).
2. Verify on **test-api.endorser.ch** with server team.
3. Phase B behind flag or same release once server duplicate rules are understood.
---
## 8. Where plugin documentation lives
| Document | Purpose |
|----------|---------|
| **`doc/plan-background-jwt-pool-and-expiry.md`** (this file) | End-to-end app plan: crypto, pool sizing, native host, rollout. |
| **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** | **Plugin-only** handoff: extend `configureNativeFetcher`, persist pool, Android/iOS notes — intended for PRs in **daily-notification-plugin** (or Cursor on that repo). |
Keeping them **separate** avoids mixing consumer app tasks with plugin API contract; the plan **links** to the plugin feedback doc for Option B1.
---
## 9. References
| Topic | Location |
|--------|----------|
| Current 60s `accessToken` | `src/libs/crypto/index.ts` |
| `createEndorserJwtForDid` | `src/libs/endorserServer.ts` |
| Native configure | `src/services/notifications/nativeFetcherConfig.ts` |
| Android HTTP | `android/.../TimeSafariNativeFetcher.java` |
| Options doc (TTL, refresh, BFF) | `doc/endorser-jwt-background-prefetch-options.md` |
| Plugin: `configureNativeFetcher` + JWT pool | `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` |
---
*Update this plan when Phase A/B ship or when Endorser policy changes.*
@@ -2,7 +2,7 @@
**Date:** 2026-02-18
**Generated:** 2026-02-18 17:47:06 PST
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
**Target repo:** `@timesafari/daily-notification-plugin` (https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android
@@ -3,7 +3,7 @@
**Date:** 2026-03-27 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1)
**Related app plan:** `doc/background-jwt-pool.md`
---
@@ -85,7 +85,7 @@ When `configureNativeFetcher` exists on iOS, mirror Android: accept optional poo
| Topic | Location |
|--------|----------|
| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
| Pool design, slot ordering, lifecycle | `doc/background-jwt-pool.md` |
| Android fetcher | `android/.../TimeSafariNativeFetcher.java` |
| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` |
| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.md` |
+30
View File
@@ -0,0 +1,30 @@
# SMS Registration
All text providers now require 10DLC registration, which is a horrendous process. (I can refer you to others who have also found the process to be a nightmare. I just tried to look up docs on the official pages and found broken links... cool.)
The functionality here mirrors the server-push FCM functionality. We're taking this approach as well because A) iOS client-side notifications are unreliable, and B) some users prefer to get text messages.
## Details on iOS client-side problems
iOS in particular makes it impossible to guarantee that the user will get notifications,
even if we separate the data-fetch from the user-notify as designed in the daily-notification-plugin
You can see more details here: https://chatgpt.com/share/69e601ea-6434-8398-8d28-f1a3118f86ad
... which explains:
```
That implies one of these patterns:
- Polling (setInterval / timers / background fetch)
- Service worker / PWA background sync
- App wake-up logic (foreground or semi-background)
All three are fragile or outright blocked on iOS.
Unlike Android, iOS has these restrictions:
- No persistent timers when app is backgrounded
- No reliable background fetch at exact times
- No service worker push for non-installed PWAs (and even then, limited)
- No “wake up at X time and run JS”
```
+3
View File
@@ -133,6 +133,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://dev-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://dev-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://dev-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://dev.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true
# .env.test
@@ -141,6 +142,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://staging-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://staging-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://staging-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://staging.timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true
# .env.production
@@ -149,6 +151,7 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app
VITE_DEFAULT_NOTIFY_API_SERVER=https://notify-api.timesafari.app
VITE_PASSKEYS_ENABLED=true
```
+4 -4
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
}
@@ -72,7 +72,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},
+2 -1
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
+43 -34
View File
@@ -15,9 +15,13 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */; };
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 */; };
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */; };
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -55,11 +59,15 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSafariNativeFetcher.swift; sourceTree = "<group>"; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
C8E73DD32FC6ECC30057F59A /* AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AppDebug.entitlements; 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>"; };
E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInspectorPlugin.swift; 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 +82,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,8 +135,11 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8E73DD32FC6ECC30057F59A /* AppDebug.entitlements */,
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
@@ -149,6 +149,7 @@
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */,
);
path = App;
sourceTree = "<group>";
@@ -174,9 +175,9 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -204,8 +205,6 @@
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
@@ -218,7 +217,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
LastUpgradeCheck = 2660;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -260,6 +259,7 @@
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
C8E73DD12FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -267,6 +267,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8E73DD22FC6E5DC0057F59A /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -293,7 +294,7 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = {
2B3F98670AF3508A35AC3248 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -357,8 +358,10 @@
buildActionMask = 2147483647;
files = (
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
E9F1A0022EE05A8B00737D01 /* NotificationInspectorPlugin.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -452,10 +455,11 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -508,9 +512,10 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
@@ -522,19 +527,21 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -550,19 +557,21 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -580,21 +589,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -618,21 +627,21 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 70;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.4.4;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari.share</string>
</array>
</dict>
</plist>
+34 -8
View File
@@ -1,6 +1,7 @@
import UIKit
import Capacitor
import CapacitorCommunitySqlite
import TimesafariDailyNotificationPlugin
import UserNotifications
@UIApplicationMain
@@ -9,6 +10,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// New Activity / dual schedule: plugin requires a registered native fetcher before configureNativeFetcher (parity with Android setNativeFetcher).
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
// Set notification center delegate so notifications show in foreground and rollover is triggered
UNUserNotificationCenter.current().delegate = self
@@ -25,6 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
_ = registerNotificationInspectorPlugin()
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
@@ -60,6 +65,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
return true
}
@discardableResult
private func registerNotificationInspectorPlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
let pluginInstance = NotificationInspectorPlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered NotificationInspectorPlugin (exposed as 'NotificationInspector')")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -89,13 +108,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
if let notificationId = userInfo["notification_id"] as? String {
let scheduledTime: Int64? = {
if let v = userInfo["scheduled_time"] as? Int64 { return v }
if let n = userInfo["scheduled_time"] as? NSNumber { return n.int64Value }
if let i = userInfo["scheduled_time"] as? Int { return Int64(i) }
return nil
}()
if let scheduledTime = scheduledTime {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
}
}
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])
@@ -133,7 +159,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
// Note: Share Extension opens app with timesafari://shared-photo, which is handled by JavaScript
// via the appUrlOpen listener in main.capacitor.ts
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "splash@1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "splash@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "splash@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyDhiy46kW7TH4VvUxzl2pOTLEK7mT14mIo</string>
<key>GCM_SENDER_ID</key>
<string>1094643115061</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>app.timesafari</string>
<key>PROJECT_ID</key>
<string>pc-api-7249509642322112640-286</string>
<key>STORAGE_BUCKET</key>
<string>pc-api-7249509642322112640-286.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:1094643115061:ios:587b9422d019375e887d7c</string>
</dict>
</plist>
+27 -26
View File
@@ -2,10 +2,17 @@
<!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>
<string>TimeSafari</string>
<string>Giftopia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</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,14 @@
<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>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -47,30 +73,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>
@@ -0,0 +1,88 @@
import Foundation
import Capacitor
import UserNotifications
// DEV-only diagnostic plugin.
// Kept separate from DailyNotificationPlugin intentionally
// to avoid altering production notification scheduling behavior.
@objc(NotificationInspector)
public class NotificationInspectorPlugin: CAPPlugin, CAPBridgedPlugin {
public var identifier: String { "NotificationInspector" }
public var jsName: String { "NotificationInspector" }
public var pluginMethods: [CAPPluginMethod] {
[
CAPPluginMethod(#selector(getPendingNotifications(_:)), returnType: .promise)
]
}
/// Stable wall-clock target: plugin `userInfo["scheduled_time"]`, or epoch ms in API notification identifiers.
/// (Apple documents `UNTimeIntervalNotificationTrigger.nextTriggerDate()` as resampling ~now+interval when queried.)
/// API notification identifiers use the `api_` prefix.
private static let apiNotificationIdentifierPrefix = "api_"
private func wallClockMillis(from request: UNNotificationRequest) -> (ms: Int64, source: String)? {
let info = request.content.userInfo
if let v = info["scheduled_time"] as? Int64 {
return (v, "userInfo.scheduled_time")
}
if let n = info["scheduled_time"] as? NSNumber {
return (n.int64Value, "userInfo.scheduled_time")
}
if let i = info["scheduled_time"] as? Int {
return (Int64(i), "userInfo.scheduled_time")
}
let prefix = Self.apiNotificationIdentifierPrefix
if request.identifier.hasPrefix(prefix) {
let suffix = String(request.identifier.dropFirst(prefix.count))
if let ms = Int64(suffix) {
return (ms, "identifier (API notification)")
}
}
return nil
}
@objc public func getPendingNotifications(_ call: CAPPluginCall) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let pending: [[String: Any]] = requests.map { req in
var nextTriggerMs: NSNumber? = nil
var triggerType: String? = nil
if let trigger = req.trigger as? UNCalendarNotificationTrigger {
triggerType = "calendar"
if let next = trigger.nextTriggerDate() {
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
}
} else if let trigger = req.trigger as? UNTimeIntervalNotificationTrigger {
triggerType = "timeInterval"
if let next = trigger.nextTriggerDate() {
nextTriggerMs = NSNumber(value: Int64(next.timeIntervalSince1970 * 1000))
}
} else if req.trigger != nil {
triggerType = "other"
} else {
triggerType = nil
}
var obj: [String: Any] = [
"identifier": req.identifier
]
obj["nextTriggerDate"] = nextTriggerMs ?? NSNull()
obj["triggerType"] = triggerType ?? NSNull()
if let wall = self.wallClockMillis(from: req) {
obj["wallClockMillis"] = NSNumber(value: wall.ms)
obj["wallClockSource"] = wall.source
} else {
obj["wallClockMillis"] = NSNull()
obj["wallClockSource"] = NSNull()
}
return obj
}
call.resolve([
"pending": pending
])
}
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
import TimesafariDailyNotificationPlugin
/// Native content fetcher for API-driven New Activity notifications on iOS.
/// Mirrors `TimeSafariNativeFetcher.java` (POST `plansLastUpdatedBetween`, starred plans, JWT pool, pagination).
final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
static let shared = TimeSafariNativeFetcher()
private let endpoint = "/api/v2/report/plansLastUpdatedBetween"
private let readTimeoutSec: TimeInterval = 15
private let maxRetries = 3
private let retryDelayMs = 1_000
/// Matches plugin `updateStarredPlans` storage (`DailyNotificationPlugin.swift`).
private let prefsStarredKey = "daily_notification_timesafari.starredPlanIds"
/// Matches Java `TimeSafariNativeFetcher` prefs namespace `daily_notification_timesafari` + `last_acked_jwt_id`.
private let prefsLastAckedKey = "daily_notification_timesafari.last_acked_jwt_id"
private var apiBaseUrl: String?
private var activeDid: String?
private var jwtToken: String?
private var jwtTokenPool: [String]?
private init() {}
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
self.apiBaseUrl = apiBaseUrl.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(
of: "/$",
with: "",
options: .regularExpression
)
self.activeDid = activeDid
self.jwtToken = jwtToken
self.jwtTokenPool = (jwtTokenPool?.isEmpty == false) ? jwtTokenPool : nil
}
func fetchContent(context: FetchContext) async throws -> [NotificationContent] {
try await fetchContentWithRetry(context: context, retryCount: 0)
}
/// Picks the pool entry whose validity window covers today, falling back to the
/// primary `jwtToken` when no pool is configured. Same arithmetic as Java.
///
/// Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
/// (`mintBackgroundJwtTokenPool`) files the token for a given day at index
/// `epochDay % size`. That is why the index below is the raw epoch day rather than
/// a count from when the pool arrived: this side keeps no mint date, and the same
/// arithmetic on both ends is what lines the slot up with the day it covers.
/// A token read from the wrong slot is outside its window and Endorser rejects it.
private func selectBearerTokenForRequest() -> String? {
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
let idx = Int(epochDay) % pool.count
let t = pool[idx]
if t.isEmpty { return jwtToken }
return t
}
private func fetchContentWithRetry(context: FetchContext, retryCount: Int) async throws -> [NotificationContent] {
guard let base = apiBaseUrl, !base.isEmpty,
activeDid != nil,
let bearer = selectBearerTokenForRequest(), !bearer.isEmpty
else {
NSLog("[TimeSafariNativeFetcher] Not configured; call configureNativeFetcher from JS first.")
return []
}
guard let url = URL(string: base + endpoint) else {
return []
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = readTimeoutSec
let planIds = getStarredPlanIds()
var afterId = getLastAcknowledgedJwtId() ?? "0"
if afterId.isEmpty { afterId = "0" }
let body: [String: Any] = [
"planIds": planIds,
"afterId": afterId,
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
NSLog(
"[TimeSafariNativeFetcher] POST \(endpoint) planCount=\(planIds.count) afterId=\(afterId.prefix(12))…"
)
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = readTimeoutSec
config.timeoutIntervalForResource = readTimeoutSec
let session = URLSession(configuration: config)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
return []
}
if http.statusCode == 200 {
let bodyStr = String(data: data, encoding: .utf8) ?? ""
let contents = parseApiResponse(responseBody: bodyStr, context: context)
if !contents.isEmpty {
updateLastAckedJwtIdFromResponse(responseBody: bodyStr)
}
return contents
}
if retryCount < maxRetries && (http.statusCode >= 500 || http.statusCode == 429) {
let delayMs = retryDelayMs * (1 << retryCount)
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
}
NSLog("[TimeSafariNativeFetcher] API error \(http.statusCode)")
return []
} catch {
NSLog("[TimeSafariNativeFetcher] Fetch failed: \(error.localizedDescription)")
if retryCount < maxRetries {
let delayMs = retryDelayMs * (1 << retryCount)
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
}
return []
}
}
private func getStarredPlanIds() -> [String] {
guard let jsonStr = UserDefaults.standard.string(forKey: prefsStarredKey),
!jsonStr.isEmpty, jsonStr != "[]",
let data = jsonStr.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
return arr.compactMap { $0 as? String }
}
private func getLastAcknowledgedJwtId() -> String? {
let s = UserDefaults.standard.string(forKey: prefsLastAckedKey)
return (s?.isEmpty == false) ? s : nil
}
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
guard let data = responseBody.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
else { return }
let lastItem = dataArray[dataArray.count - 1]
var jwtId: String?
if let j = lastItem["jwtId"] as? String {
jwtId = j
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
jwtId = j
}
if let jwtId = jwtId, !jwtId.isEmpty {
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
}
}
private func extractProjectDisplayTitle(_ item: [String: Any]) -> String {
if let plan = item["plan"] as? [String: Any],
let name = plan["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return name.trimmingCharacters(in: .whitespacesAndNewlines)
}
return "Unnamed Project"
}
private func extractJwtIdFromItem(_ item: [String: Any]) -> String? {
if let plan = item["plan"] as? [String: Any], let j = plan["jwtId"] as? String, !j.isEmpty {
return j
}
if let j = item["jwtId"] as? String, !j.isEmpty { return j }
return nil
}
private func parseApiResponse(responseBody: String, context: FetchContext) -> [NotificationContent] {
guard let data = responseBody.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
else {
return []
}
let firstItem = dataArray[0]
let firstTitle = extractProjectDisplayTitle(firstItem)
let jwtId = extractJwtIdFromItem(firstItem)
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let scheduledMs: Int64 = context.scheduledTimeMillis ?? (nowMs + 3_600_000)
let n = dataArray.count
let quotedFirst = "\u{201C}\(firstTitle)\u{201D}"
let title: String
let body: String
if n == 1 {
title = "Starred Project Update"
body = "\(quotedFirst) has been updated."
} else {
title = "Starred Project Updates"
let more = n - 1
body = "\(quotedFirst) + \(more) more have been updated."
}
let id = "endorser_\(jwtId ?? "batch_\(nowMs)")"
return [
NotificationContent(
id: id,
title: title,
body: body,
scheduledTime: scheduledMs,
fetchedAt: nowMs,
url: apiBaseUrl,
payload: nil,
etag: nil
),
]
}
}
+88 -2
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
@@ -17,6 +17,8 @@ def capacitor_pods
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
@@ -28,11 +30,95 @@ target 'App' do
# Add your Pods here
end
def merge_sqlite_omit_load_extension_definition(config)
defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
if defs.nil?
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = ['$(inherited)', 'SQLITE_OMIT_LOAD_EXTENSION']
elsif defs.is_a?(Array)
unless defs.any? { |d| d.to_s.include?('SQLITE_OMIT_LOAD_EXTENSION') }
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = defs + ['SQLITE_OMIT_LOAD_EXTENSION']
end
else
s = defs.to_s
unless s.include?('SQLITE_OMIT_LOAD_EXTENSION')
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = "#{s} SQLITE_OMIT_LOAD_EXTENSION".squeeze(' ').strip
end
end
end
def strip_system_sqlite_from_pod_config(config)
bad_header = lambda do |path|
p = path.to_s
p.include?('/usr/include') || p.include?('/usr/local/include')
end
%w[HEADER_SEARCH_PATHS USER_HEADER_SEARCH_PATHS].each do |key|
paths = config.build_settings[key]
next unless paths
if paths.is_a?(Array)
config.build_settings[key] = paths.reject(&bad_header)
else
kept = paths.to_s.split(/\s+/).reject(&bad_header)
config.build_settings[key] = kept.join(' ')
end
end
%w[OTHER_LDFLAGS OTHER_LIBTOOLFLAGS].each do |key|
val = config.build_settings[key]
next unless val
if val.is_a?(Array)
config.build_settings[key] = val.reject do |x|
s = x.to_s
s.match?(/libsqlite3\.tbd/) || s == '-l"sqlite3"' || s.match?(/-l\s*sqlite3\b/)
end
else
s = val.to_s.gsub(/\s*-l"sqlite3"\s+/, ' ')
.gsub(/\s*-l\s*sqlite3\b/, ' ')
.gsub(/[^\s]*libsqlite3\.tbd[^\s]*/, ' ')
config.build_settings[key] = s.squeeze(' ').strip
end
end
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'
deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f
if deployment_target > 0.0 && deployment_target < 15.5
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5'
end
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
merge_sqlite_omit_load_extension_definition(config)
strip_system_sqlite_from_pod_config(config)
end
end
end
# Aggregate Pods-App xcconfigs merge -l"sqlite3" from dependencies; that pulls in Apple's
# libsqlite3 alongside SQLCipher. Strip it after CocoaPods writes the files (post_install is too early).
# Also strip SQLCipher header-guard macros leaked into GCC_PREPROCESSOR_DEFINITIONS: Swift explicit
# modules build the SDK SQLite3.modulemap PCM with the same -D flags; _SQLITE3_H_=1 empties sqlite3.h
# and breaks sqlite3ext.h (unknown sqlite3_* types).
def strip_aggregate_pods_app_xcconfig(contents)
# Unlink system libsqlite3 (SQLCipher is the only SQLite).
patched = contents.gsub(/\s+-l"sqlite3"\s+/, ' ')
.gsub(/\s+-lsqlite3\b/, ' ')
# SQLCipher leaks sqlite3*.h guard macros into GCC_PREPROCESSOR_DEFINITIONS; Swift explicit
# modules must not inherit them when building the SDK SQLite3 module.
%w[_SQLITE3_H_=1 _FTS5_H=1 _SQLITE3RTREE_H_=1].each do |macro|
escaped = Regexp.escape(macro)
patched.gsub!(/(?:^|\s)-D#{escaped}(?=\s|$)/, ' ')
patched.gsub!(/(?:^|\s)#{escaped}(?=\s|$)/, ' ')
end
patched.gsub(/[ \t]+/, ' ')
end
post_integrate do |installer|
support = File.join(installer.sandbox.root, 'Target Support Files', 'Pods-App')
%w[Pods-App.debug.xcconfig Pods-App.release.xcconfig].each do |name|
path = File.join(support, name)
next unless File.exist?(path)
contents = File.read(path)
patched = strip_aggregate_pods_app_xcconfig(contents)
File.write(path, patched) if patched != contents
end
end
+96 -94
View File
@@ -1,94 +1,88 @@
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)
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorStatusBar (6.0.2):
- CapacitorPushNotifications (7.0.7):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- CapacitorShare (7.0.4):
- 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):
- CapacitorStatusBar (7.0.6):
- Capacitor
- CapawesomeCapacitorFilePicker (7.2.0):
- Capacitor
- 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.1.1):
- TimesafariDailyNotificationPlugin (4.0.1):
- Capacitor
- ZIPFoundation (0.9.19)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -99,6 +93,8 @@ DEPENDENCIES:
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
@@ -110,8 +106,8 @@ SPEC REPOS:
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- IONFilesystemLib
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
@@ -138,6 +134,10 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorMlkitBarcodeScanning:
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
CapacitorPushNotifications:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapacitorStatusBar:
@@ -148,33 +148,35 @@ 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
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319
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: ab9860e6ab9db8019f64f3c08f115a0c4ffd32d9
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
PODFILE CHECKSUM: 5736811d271d5309d3e2de8f3eefdbb6632086a3
COCOAPODS: 1.16.2
@@ -46,7 +46,7 @@ class ShareViewController: UIViewController {
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)
// Open the main app at its shared-photo deep link.
self.openMainApp()
}
@@ -186,8 +186,7 @@ class ShareViewController: UIViewController {
}
private func openMainApp() {
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else {
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
+3
View File
@@ -1,6 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
// Unit tests live under src/. test-playwright/ holds Playwright specs,
// which use a different runner and fail if Jest collects them.
roots: ['<rootDir>/src'],
moduleFileExtensions: ['ts', 'js', 'json', 'vue'],
transform: {
'^.+\\.ts$': 'ts-jest'
+5808 -4069
View File
File diff suppressed because it is too large Load Diff
+26 -20
View File
@@ -1,19 +1,21 @@
{
"name": "timesafari",
"version": "1.4.1-beta",
"description": "Gift Economies Application",
"name": "giftopia",
"version": "1.4.4",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
},
"scripts": {
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"type-safety-check": "./scripts/type-safety-check.sh",
"type-check": "tsc --noEmit",
"type-check:vue": "vue-tsc --noEmit",
"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",
"test:unit": "jest",
"check:dependencies": "./scripts/check-dependencies.sh",
"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'",
"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 && npm run type-check && npm run type-check:vue && npm run test:unit && npm run test:web && npm run test:mobile && 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",
"test:android": "node scripts/test-android.js",
@@ -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,21 @@
},
"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/preferences": "^7.0.4",
"@capacitor/push-notifications": "^7.0.7",
"@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",
@@ -194,6 +198,7 @@
"electron-builder": "^26.0.12",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"firebase": "^12.12.1",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
@@ -279,6 +284,7 @@
"ts-jest": "^29.4.0",
"tsx": "^4.20.4",
"typescript": "~5.2.2",
"vite": "^5.2.0"
"vite": "^5.2.0",
"vue-tsc": "^2.1.10"
}
}
+4 -1
View File
@@ -56,7 +56,10 @@ npx capacitor-assets generate --web
## Configuration
Asset generation is configured in `capacitor-assets.config.json` at the project root.
`resources/` is this project's canonical asset source. `@capacitor/assets`
prioritizes a top-level `assets/` directory over `resources/`, so a legacy
`assets/` directory can prevent these assets from being discovered. Remove that
directory when it is empty or obsolete.
## Version Control
Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 624 KiB

+2 -2
View File
@@ -92,7 +92,7 @@ function generateAssetConfig(): AssetConfig {
},
splash: {
source: "resources/splash.png",
darkSource: "resources/splash_dark.png",
darkSource: "resources/splash-dark.png",
android: {
scale: "cover",
target: "android/app/src/main/res"
@@ -138,7 +138,7 @@ function validateSourceFiles(): void {
const requiredFiles = [
'resources/icon.png',
'resources/splash.png',
'resources/splash_dark.png'
'resources/splash-dark.png'
];
const missingFiles = requiredFiles.filter(file => {
+19 -19
View File
@@ -230,8 +230,8 @@ validate_android_assets() {
missing_assets+=("resources/splash.png")
fi
if [ ! -f "resources/splash_dark.png" ]; then
missing_assets+=("resources/splash_dark.png")
if [ ! -f "resources/splash-dark.png" ]; then
missing_assets+=("resources/splash-dark.png")
fi
if [ ${#missing_assets[@]} -gt 0 ]; then
@@ -278,14 +278,14 @@ validate_android_assets() {
# Copy source assets to assets directory for capacitor-assets
cp resources/icon.png assets/ 2>/dev/null || log_warn "Could not copy icon.png"
cp resources/splash.png assets/ 2>/dev/null || log_warn "Could not copy splash.png"
cp resources/splash_dark.png assets/ 2>/dev/null || log_warn "Could not copy splash_dark.png"
cp resources/splash-dark.png assets/ 2>/dev/null || log_warn "Could not copy splash-dark.png"
# Generate assets
if npx @capacitor/assets generate >/dev/null 2>&1; then
log_success "Android assets regenerated successfully"
# Clean up temporary assets
rm -f assets/icon.png assets/splash.png assets/splash_dark.png
rm -f assets/icon.png assets/splash.png assets/splash-dark.png
# Verify the resources were created
local verification_failed=false
@@ -492,21 +492,6 @@ log_info "Build type: $BUILD_TYPE"
# Setup environment for Capacitor build
setup_build_env "capacitor" "$BUILD_MODE"
# Override API servers for Android development
if [ "$BUILD_MODE" = "development" ]; then
if [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
else
# Use Android emulator IP (10.0.2.2) for Android development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
log_debug "Android development mode: Using 10.0.2.2 for emulator"
fi
fi
# Setup application directories
setup_app_directories
@@ -523,6 +508,21 @@ if [ -f ".env" ]; then
load_env_file ".env"
fi
# Override API servers for Android development
if [ "$BUILD_MODE" = "development" ]; then
if [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
else
# Use Android emulator IP (10.0.2.2) for Android development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
log_debug "Android development mode: Using 10.0.2.2 for emulator"
fi
fi
# Handle clean-only mode
if [ "$CLEAN_ONLY" = true ]; then
log_info "Clean-only mode: cleaning build artifacts"
+157 -23
View File
@@ -172,12 +172,12 @@ check_ios_resources() {
log_info "Checking iOS resources..."
# Check for required assets
if [ ! -f "assets/icon.png" ]; then
log_warn "App icon not found at assets/icon.png"
if [ ! -f "resources/icon.png" ]; then
log_warn "App icon not found at resources/icon.png"
fi
if [ ! -f "assets/splash.png" ]; then
log_warn "Splash screen not found at assets/splash.png"
if [ ! -f "resources/splash.png" ]; then
log_warn "Splash screen not found at resources/splash.png"
fi
# Check for iOS-specific files
@@ -192,6 +192,118 @@ check_ios_resources() {
log_success "iOS resource check completed"
}
# iOS app icon appearance variants (Dark, Tinted, …).
# Luminosity appearance values match Apple's asset catalog format (iOS 18+).
readonly _IOS_APP_ICON_APPEARANCE_LUMINOSITY="luminosity"
readonly _IOS_APP_ICON_APPEARANCE_DARK="dark"
readonly _IOS_APP_ICON_APPEARANCE_TINTED="tinted"
# Each row: source_file|luminosity_value|dest_filename
# To add a variant: append one row and place the source PNG under resources/ios/.
readonly _IOS_APP_ICON_APPEARANCE_VARIANTS=(
"resources/ios/icon/icon-dark.png|${_IOS_APP_ICON_APPEARANCE_DARK}|AppIcon-Dark.png"
"resources/ios/icon/icon-tinted.png|${_IOS_APP_ICON_APPEARANCE_TINTED}|AppIcon-Tinted.png"
)
# Update AppIcon.appiconset/Contents.json with one 1024×1024 appearance entry.
# Preserves all other images (including capacitor-assets output); replaces any
# existing entry for the same luminosity appearance.
_update_appicon_contents_for_appearance() {
local contents_json="$1"
local dest_filename="$2"
local luminosity_value="$3"
local tmp_file
tmp_file="$(mktemp)"
if ! jq --arg filename "$dest_filename" \
--arg appearance "$_IOS_APP_ICON_APPEARANCE_LUMINOSITY" \
--arg value "$luminosity_value" \
'
.images |= map(select((.appearances[0].value // "") != $value))
| .images += [{
"appearances": [{
"appearance": $appearance,
"value": $value
}],
"filename": $filename,
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}]
' "$contents_json" > "$tmp_file"; then
rm -f "$tmp_file"
log_error "Failed to update AppIcon Contents.json for appearance: $luminosity_value"
return 1
fi
mv "$tmp_file" "$contents_json"
}
# Install one appearance variant when its source PNG exists.
_apply_ios_app_icon_appearance_variant() {
local appiconset_dir="$1"
local contents_json="$2"
local source_file="$3"
local luminosity_value="$4"
local dest_filename="$5"
if [ ! -f "$source_file" ]; then
log_info "iOS app icon appearance variant not found ($source_file) — skipping"
return 0
fi
if [ ! -d "$appiconset_dir" ]; then
log_warn "AppIcon.appiconset not found — skipping appearance variant ($luminosity_value)"
return 0
fi
if ! cp "$source_file" "$appiconset_dir/$dest_filename"; then
log_error "Failed to copy $source_file to $appiconset_dir/$dest_filename"
return 1
fi
log_info "Installed iOS app icon appearance variant: $luminosity_value ($dest_filename)"
_update_appicon_contents_for_appearance "$contents_json" "$dest_filename" "$luminosity_value"
}
# Post-process capacitor-assets iOS icons: copy optional Dark/Tinted sources and
# register them in AppIcon.appiconset/Contents.json.
apply_ios_app_icon_appearances() {
local appiconset_dir="ios/App/App/Assets.xcassets/AppIcon.appiconset"
local contents_json="$appiconset_dir/Contents.json"
if [ ! -f "$contents_json" ]; then
log_info "AppIcon Contents.json not found — skipping appearance variants"
return 0
fi
if ! command -v jq &> /dev/null; then
log_error "jq is required to install iOS app icon appearance variants (install with: brew install jq)"
return 1
fi
local variant source_file luminosity_value dest_filename
for variant in "${_IOS_APP_ICON_APPEARANCE_VARIANTS[@]}"; do
IFS='|' read -r source_file luminosity_value dest_filename <<< "$variant"
_apply_ios_app_icon_appearance_variant \
"$appiconset_dir" "$contents_json" \
"$source_file" "$luminosity_value" "$dest_filename"
done
}
# Generate iOS assets (capacitor-assets), then apply optional appearance variants.
generate_ios_assets() {
ensure_ios_capacitor_asset_directories
if [ -d "assets" ]; then
log_warn "@capacitor/assets prioritizes the top-level assets/ directory over resources/."
log_warn "This project intentionally uses resources/ as the canonical asset source."
log_warn "Remove the legacy assets/ directory if it is empty or obsolete."
fi
npx capacitor-assets generate --ios
apply_ios_app_icon_appearances
}
# Function to clean iOS build
clean_ios_build() {
log_info "Cleaning iOS build artifacts..."
@@ -222,7 +334,9 @@ build_ios_app() {
if [ "$BUILD_TYPE" = "debug" ]; then
build_config="Debug"
destination="platform=iOS Simulator,name=iPhone 15 Pro"
# 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"
@@ -232,18 +346,35 @@ build_ios_app() {
cd ios/App
# Build the app
xcodebuild -workspace App.xcworkspace \
# 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" \
-configuration "$build_config" \
-destination "$destination" \
build \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_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"
}
@@ -313,14 +444,6 @@ log_info "Build type: $BUILD_TYPE"
# Setup environment for Capacitor build
setup_build_env "capacitor" "$BUILD_MODE"
# Override API servers for iOS development when custom IP is specified
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
fi
# Setup application directories
setup_app_directories
@@ -337,6 +460,14 @@ if [ -f ".env" ]; then
load_env_file ".env"
fi
# Override API servers for iOS development when custom IP is specified
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
# Use custom IP for physical device development
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
fi
# Validate iOS environment
validate_ios_environment
@@ -406,7 +537,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
safe_execute "Generating assets" "generate_ios_assets" || exit 7
log_success "Assets generation completed successfully!"
exit 0
fi
@@ -555,7 +686,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
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
safe_execute "Generating assets" "generate_ios_assets" || exit 7
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
@@ -564,16 +695,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
xcodebuild -workspace App.xcworkspace \
xcodebuild -quiet \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath build/App.xcarchive \
archive \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
CODE_SIGNING_ALLOWED=NO \
SWIFT_SUPPRESS_WARNINGS=YES \
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
xcodebuild -exportArchive \
xcodebuild -quiet -exportArchive \
-archivePath build/App.xcarchive \
-exportPath build/ \
-exportOptionsPlist exportOptions.plist
+33 -1
View File
@@ -183,6 +183,16 @@ setup_build_env() {
export VITE_GIT_HASH="$git_hash"
log_debug "Set VITE_GIT_HASH=$git_hash"
# Vite derives import.meta.env.DEV/PROD from NODE_ENV, not from --mode.
# Without this, every native build reports DEV=false and loads the
# production .env file in vite.config.common.mts.
case "$build_mode" in
"production") export NODE_ENV=production ;;
"test") export NODE_ENV=test ;;
*) export NODE_ENV=development ;;
esac
log_debug "Set NODE_ENV=$NODE_ENV"
case $build_type in
"capacitor")
export VITE_PLATFORM=capacitor
@@ -337,6 +347,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 +375,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
-103
View File
@@ -1,103 +0,0 @@
#!/bin/bash
# Type Safety Pre-commit Check Script
# This script ensures type safety before commits by running linting and type checking
set -e
echo "🔍 Running Type Safety Pre-commit Checks..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}✅ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}❌ $1${NC}"
}
# Check if we're in the right directory
if [ ! -f "package.json" ]; then
print_error "Must run from project root directory"
exit 1
fi
# Step 1: Run ESLint with TypeScript rules
print_status "Running ESLint TypeScript checks..."
if npm run lint > /dev/null 2>&1; then
print_status "ESLint passed - no type safety issues found"
else
print_error "ESLint failed - type safety issues detected"
echo ""
echo "Running lint with details..."
npm run lint
echo ""
print_error "Please fix the above type safety issues before committing"
exit 1
fi
# Step 2: Run TypeScript type checking
print_status "Running TypeScript type checking..."
if npm run type-check > /dev/null 2>&1; then
print_status "TypeScript compilation passed"
else
print_error "TypeScript compilation failed"
echo ""
echo "Running type check with details..."
npm run type-check
echo ""
print_error "Please fix the above TypeScript errors before committing"
exit 1
fi
# Step 3: Check for any remaining 'any' types
print_status "Scanning for any remaining 'any' types..."
ANY_COUNT=$(grep -r "any" src/ --include="*.ts" --include="*.vue" | grep -v "// eslint-disable" | grep -v "eslint-disable-next-line" | wc -l)
if [ "$ANY_COUNT" -eq 0 ]; then
print_status "No 'any' types found in source code"
else
print_warning "Found $ANY_COUNT instances of 'any' type usage"
echo ""
echo "Instances found:"
grep -r "any" src/ --include="*.ts" --include="*.vue" | grep -v "// eslint-disable" | grep -v "eslint-disable-next-line" || true
echo ""
print_error "Please replace 'any' types with proper TypeScript types before committing"
exit 1
fi
# Step 4: Verify database migration status
print_status "Checking database migration status..."
if grep -r "databaseUtil" src/ --include="*.ts" --include="*.vue" > /dev/null 2>&1; then
print_warning "Found databaseUtil imports - ensure migration is complete"
echo ""
echo "Files with databaseUtil imports:"
grep -r "databaseUtil" src/ --include="*.ts" --include="*.vue" | head -5 || true
echo ""
print_warning "Consider completing database migration to PlatformServiceMixin"
else
print_status "No databaseUtil imports found - migration appears complete"
fi
# All checks passed
echo ""
print_status "All type safety checks passed! 🎉"
print_status "Your code is ready for commit"
echo ""
echo "📚 Remember to follow the Type Safety Guidelines:"
echo " - doc/typescript-type-safety-guidelines.md"
echo " - Use proper error handling patterns"
echo " - Leverage existing type definitions"
echo " - Run 'npm run lint-fix' for automatic fixes"
exit 0
+2 -7
View File
@@ -130,6 +130,7 @@
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { SOMEONE_UNNAMED } from "@/constants/entities";
@@ -149,10 +150,7 @@ export default class BulkMembersDialog extends Vue {
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
$notify!: (
notification: { group: string; type: string; title: string; text: string },
timeout?: number,
) => void;
$notify!: (notification: NotificationIface, timeout?: number) => void;
// Notification system
notify!: ReturnType<typeof createNotifyHelpers>;
@@ -381,9 +379,6 @@ export default class BulkMembersDialog extends Vue {
contact,
);
if (result.success) {
if (result.embeddedRecordError) {
throw new Error(result.embeddedRecordError);
}
await this.$updateContact(member.did, { registered: true });
} else {
throw result;
+3
View File
@@ -25,6 +25,7 @@
<p :class="textClasses">{{ text }}</p>
<button
v-if="option1Text"
:class="option1ButtonClasses"
@click="handleOption1(close)"
>
@@ -32,6 +33,7 @@
</button>
<button
v-if="option2Text"
:class="option2ButtonClasses"
@click="handleOption2(close)"
>
@@ -39,6 +41,7 @@
</button>
<button
v-if="option3Text"
:class="option3ButtonClasses"
@click="handleOption3(close)"
>
+1
View File
@@ -106,6 +106,7 @@ import { Router } from "vue-router";
import * as R from "ramda";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
+3 -3
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 }}
+1 -1
View File
@@ -135,7 +135,7 @@ export default class EntitySummaryButton extends Vue {
}
// If the entity does not have a set name, but is not the special "Unnamed", use their DID
return this.entity?.did;
return this.entity?.did ?? "";
}
/**
+20 -1
View File
@@ -450,7 +450,26 @@ export default class GiftedDialog extends Vue {
TIMEOUTS.MODAL,
);
} else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
if (result.embeddedRecordError) {
// The claim was stored but the server could not record part of it,
// eg. the link to the project this gift came from. Reporting a plain
// success here is how such a gift comes to sit on a project page
// that never shows it.
logger.warn(
"Give recorded but part of it was not:",
result.embeddedRecordError,
);
this.safeNotify.warning(
"That gift was recorded, but some of it was not: " +
result.embeddedRecordError,
TIMEOUTS.MODAL,
);
} else {
this.safeNotify.success(
"That gift was recorded.",
TIMEOUTS.VERY_LONG,
);
}
// Show seed phrase backup reminder if needed
try {
-3
View File
@@ -43,9 +43,6 @@ export default class InfiniteScroll extends Vue {
/** Intersection Observer instance for detecting scroll position */
private observer!: IntersectionObserver;
/** Flag to track initial render state */
private isInitialRender = true;
/** Flag to prevent multiple simultaneous loading states */
private isLoading = false;

Some files were not shown because too many files have changed in this diff Show More