diff --git a/.env.development b/.env.development index 40b221fe..d30eedef 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.production b/.env.production index aef4d6a4..2a92d953 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/.env.test b/.env.test index dd3d755c..19e23299 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/.husky/commit-msg b/.husky/commit-msg index 4b8c242d..cc1ce805 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -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 diff --git a/.husky/pre-commit b/.husky/pre-commit index b31dbcc0..f122047f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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..." diff --git a/.husky/pre-push b/.husky/pre-push index 2d79fde4..67780a48 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -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)..." diff --git a/BUILDING.md b/BUILDING.md index 040b2fec..71058c0e 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -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 @@ -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: @@ -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. @@ -1654,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 @@ -1673,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 ``` @@ -1683,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 ``` @@ -1693,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 ``` @@ -1720,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" @@ -1745,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 @@ -1950,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a67d502..d573c40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ 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). +## [?] - 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. diff --git a/README.md b/README.md index 446f2d69..e42d9072 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ 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 @@ -110,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 notification registration, backend refresh, WAKEUP_PING handling, and local schedule inspection on native builds. + +**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 diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 9295e052..68734a4b 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -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') diff --git a/android/app/google-services.json b/android/app/google-services.json index c312374b..de689845 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -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 @@ } } } - ] -} \ No newline at end of file + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json index d7df4f7c..5c2e1fdc 100644 --- a/android/app/src/main/assets/capacitor.config.json +++ b/android/app/src/main/assets/capacitor.config.json @@ -16,6 +16,13 @@ ] } }, + "PushNotifications": { + "presentationOptions": [ + "badge", + "sound", + "alert" + ] + }, "SplashScreen": { "launchShowDuration": 3000, "launchAutoHide": true, diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index 1a7182ed..bdd3e55c 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -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" diff --git a/android/app/src/main/java/app/timesafari/MainActivity.java b/android/app/src/main/java/app/timesafari/MainActivity.java index e02a3b78..fed494f2 100644 --- a/android/app/src/main/java/app/timesafari/MainActivity.java +++ b/android/app/src/main/java/app/timesafari/MainActivity.java @@ -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; @@ -66,10 +67,17 @@ 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 registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class); + + // Register native content fetcher for API-driven daily notifications (Endorser.ch) + org.timesafari.dailynotification.DailyNotificationPlugin.setNativeFetcher( + new TimeSafariNativeFetcher(this)); // Initialize SQLite //registerPlugin(SQLite.class); diff --git a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java index 4aef1e52..485502e0 100644 --- a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java +++ b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java @@ -1,61 +1,405 @@ package app.timesafari; import android.content.Context; +import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.timesafari.dailynotification.FetchContext; import org.timesafari.dailynotification.NativeNotificationContentFetcher; import org.timesafari.dailynotification.NotificationContent; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; +/** + * Native content fetcher for API-driven daily notifications. + * Calls Endorser.ch plansLastUpdatedBetween with configured credentials and + * starred plan IDs (from plugin's updateStarredPlans), then returns notification content. + */ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher { private static final String TAG = "TimeSafariNativeFetcher"; - private final Context context; + private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween"; + private static final int CONNECT_TIMEOUT_MS = 10000; + private static final int READ_TIMEOUT_MS = 15000; + private static final int MAX_RETRIES = 3; + /** Max chars of response body logged at DEBUG (avoids huge log lines). */ + private static final int MAX_RESPONSE_BODY_LOG_CHARS = 4096; + private static final int RETRY_DELAY_MS = 1000; + + // Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager) + private static final String PREFS_NAME = "daily_notification_timesafari"; + private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; + private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; + + private final Gson gson = new Gson(); + private final Context appContext; + private final SharedPreferences prefs; - // Configuration from TypeScript (set via configure()) private volatile String apiBaseUrl; private volatile String activeDid; private volatile String jwtToken; + /** Distinct JWTs from configureNativeFetcher `jwtTokens`; null = use jwtToken only. */ + @Nullable + private List jwtTokenPool; public TimeSafariNativeFetcher(Context context) { - this.context = context; + this.appContext = context.getApplicationContext(); + this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } @Override public void configure(String apiBaseUrl, String activeDid, String jwtToken) { + configure(apiBaseUrl, activeDid, jwtToken, null); + } + + @Override + public void configure( + String apiBaseUrl, + String activeDid, + String jwtToken, + @Nullable List jwtTokenPool) { this.apiBaseUrl = apiBaseUrl; this.activeDid = activeDid; this.jwtToken = jwtToken; - Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid); + this.jwtTokenPool = + jwtTokenPool != null && !jwtTokenPool.isEmpty() + ? new ArrayList<>(jwtTokenPool) + : null; + int starredCount = getStarredPlanIds().size(); + Log.i( + TAG, + "Configured with API: " + + apiBaseUrl + + ", starredPlanIds count=" + + starredCount + + (this.jwtTokenPool != null + ? ", jwtPoolSize=" + this.jwtTokenPool.size() + : "")); + } + + /** + * Picks the pool entry whose validity window covers today, falling back to the + * primary jwtToken when no pool is configured. + * + *

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 pool = jwtTokenPool; + if (pool == null || pool.isEmpty()) { + return jwtToken; + } + long epochDay = System.currentTimeMillis() / (24L * 60 * 60 * 1000); + int idx = (int) (epochDay % pool.size()); + String t = pool.get(idx); + if (t == null || t.isEmpty()) { + return jwtToken; + } + Log.i(TAG, "Bearer from JWT pool: index=" + idx + " of " + pool.size()); + return t; } @NonNull @Override public CompletableFuture> fetchContent(@NonNull FetchContext fetchContext) { + Long scheduled = fetchContext.scheduledTime; + Log.i( + TAG, + "fetchContent START trigger=" + + fetchContext.trigger + + " scheduledTime=" + + (scheduled != null ? scheduled : "null") + + " callerThread=" + + Thread.currentThread().getName()); Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger); + return fetchContentWithRetry(fetchContext, 0); + } + private CompletableFuture> fetchContentWithRetry( + @NonNull FetchContext context, int retryCount) { return CompletableFuture.supplyAsync(() -> { try { - // TODO: Implement actual content fetching for TimeSafari - // This should query the TimeSafari API for notification content - // using the configured apiBaseUrl, activeDid, and jwtToken + Log.i(TAG, "fetchContent worker thread=" + Thread.currentThread().getName()); + String bearer = selectBearerTokenForRequest(); + if (apiBaseUrl == null || activeDid == null || bearer == null || bearer.isEmpty()) { + Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first."); + return Collections.emptyList(); + } - // Not implemented yet: return no content rather than fabricating a - // placeholder notification (previously hardcoded "Check your starred - // projects for updates!", which showed on every app startup). - Log.d(TAG, "Content fetching not yet implemented; returning no notifications"); - return Collections.emptyList(); + String urlString = apiBaseUrl + ENDORSER_ENDPOINT; + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + bearer); + connection.setDoOutput(true); + Map requestBody = new HashMap<>(); + List planIds = getStarredPlanIds(); + requestBody.put("planIds", planIds); + String afterId = getLastAcknowledgedJwtId(); + if (afterId == null || afterId.isEmpty()) { + afterId = "0"; + } + requestBody.put("afterId", afterId); + Log.i( + TAG, + "POST " + + ENDORSER_ENDPOINT + + " planCount=" + + planIds.size() + + " afterId=" + + (afterId.length() > 12 ? afterId.substring(0, 12) + "…" : afterId)); + + String jsonBody = gson.toJson(requestBody); + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + Log.i(TAG, "HTTP response code: " + responseCode); + + if (responseCode == 200) { + StringBuilder response = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + String responseBody = response.toString(); + String snippet = + responseBody.length() <= MAX_RESPONSE_BODY_LOG_CHARS + ? responseBody + : responseBody.substring(0, MAX_RESPONSE_BODY_LOG_CHARS) + "…"; + Log.d( + TAG, + "plansLastUpdatedBetween response len=" + + responseBody.length() + + " body=" + + snippet); + List contents = parseApiResponse(responseBody, context); + if (!contents.isEmpty()) { + updateLastAckedJwtIdFromResponse(responseBody); + } + Log.i(TAG, "Fetched " + contents.size() + " notification(s)"); + return contents; + } + + if (retryCount < MAX_RETRIES && (responseCode >= 500 || responseCode == 429)) { + int delayMs = RETRY_DELAY_MS * (1 << retryCount); + String errBody = readHttpErrorBodySnippet(connection); + Log.w( + TAG, + "Retryable error " + + responseCode + + (errBody.isEmpty() ? "" : " body: " + errBody) + + ", retrying in " + + delayMs + + "ms"); + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Collections.emptyList(); + } + return fetchContentWithRetry(context, retryCount + 1).join(); + } + + String errBody = readHttpErrorBodySnippet(connection); + if (errBody.isEmpty()) { + Log.e(TAG, "API error " + responseCode); + } else { + Log.e(TAG, "API error " + responseCode + " body: " + errBody); + } + return Collections.emptyList(); } catch (Exception e) { Log.e(TAG, "Fetch failed", e); + if (retryCount < MAX_RETRIES) { + try { + Thread.sleep(RETRY_DELAY_MS * (1 << retryCount)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return Collections.emptyList(); + } + return fetchContentWithRetry(context, retryCount + 1).join(); + } return Collections.emptyList(); } }); } + + /** + * Reads error response body for logging (HttpURLConnection puts 4xx/5xx bodies on + * {@link HttpURLConnection#getErrorStream()}). + */ + private static String readHttpErrorBodySnippet(HttpURLConnection connection) { + InputStream stream = connection.getErrorStream(); + if (stream == null) { + return ""; + } + final int maxChars = 4096; + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if (sb.length() > 0) { + sb.append('\n'); + } + if (sb.length() + line.length() > maxChars) { + sb.append(line, 0, Math.max(0, maxChars - sb.length())); + sb.append("…"); + break; + } + sb.append(line); + } + return sb.toString().trim(); + } catch (IOException e) { + return "(read error body failed: " + e.getMessage() + ")"; + } + } + + private List getStarredPlanIds() { + try { + String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]"); + if (idsJson == null || idsJson.isEmpty() || "[]".equals(idsJson)) { + return new ArrayList<>(); + } + JsonArray arr = JsonParser.parseString(idsJson).getAsJsonArray(); + List list = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + list.add(arr.get(i).getAsString()); + } + return list; + } catch (Exception e) { + Log.e(TAG, "Error loading starred plan IDs", e); + return new ArrayList<>(); + } + } + + private String getLastAcknowledgedJwtId() { + return prefs.getString(KEY_LAST_ACKED_JWT_ID, null); + } + + private void updateLastAckedJwtIdFromResponse(String responseBody) { + try { + JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject(); + if (!root.has("data")) return; + JsonArray dataArray = root.getAsJsonArray("data"); + if (dataArray == null || dataArray.size() == 0) return; + JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject(); + String jwtId = null; + if (lastItem.has("jwtId")) { + jwtId = lastItem.get("jwtId").getAsString(); + } else if (lastItem.has("plan")) { + JsonObject plan = lastItem.getAsJsonObject("plan"); + if (plan.has("jwtId")) { + jwtId = plan.get("jwtId").getAsString(); + } + } + if (jwtId != null && !jwtId.isEmpty()) { + prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply(); + } + } catch (Exception e) { + Log.w(TAG, "Could not extract JWT ID from response", e); + } + } + + /** + * Display title for a plansLastUpdatedBetween row; prefers {@code plan.name}, else "Unnamed Project". + */ + private String extractProjectDisplayTitle(JsonObject item) { + if (item.has("plan")) { + JsonObject plan = item.getAsJsonObject("plan"); + if (plan.has("name") && !plan.get("name").isJsonNull()) { + String name = plan.get("name").getAsString(); + if (name != null && !name.trim().isEmpty()) { + return name.trim(); + } + } + } + return "Unnamed Project"; + } + + @Nullable + private String extractJwtIdFromItem(JsonObject item) { + if (item.has("plan")) { + JsonObject plan = item.getAsJsonObject("plan"); + if (plan.has("jwtId") && !plan.get("jwtId").isJsonNull()) { + return plan.get("jwtId").getAsString(); + } + } + if (item.has("jwtId") && !item.get("jwtId").isJsonNull()) { + return item.get("jwtId").getAsString(); + } + return null; + } + + private List parseApiResponse(String responseBody, FetchContext context) { + List contents = new ArrayList<>(); + try { + JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject(); + JsonArray dataArray = root.has("data") ? root.getAsJsonArray("data") : null; + if (dataArray == null || dataArray.size() == 0) { + return contents; + } + + JsonObject firstItem = dataArray.get(0).getAsJsonObject(); + String firstTitle = extractProjectDisplayTitle(firstItem); + String jwtId = extractJwtIdFromItem(firstItem); + + NotificationContent content = new NotificationContent(); + content.setId("endorser_" + (jwtId != null ? jwtId : ("batch_" + System.currentTimeMillis()))); + int n = dataArray.size(); + String quotedFirst = "\u201C" + firstTitle + "\u201D"; + if (n == 1) { + content.setTitle("Starred Project Update"); + content.setBody(quotedFirst + " has been updated."); + } else { + content.setTitle("Starred Project Updates"); + int more = n - 1; + content.setBody(quotedFirst + " + " + more + " more have been updated."); + } + content.setScheduledTime( + context.scheduledTime != null + ? context.scheduledTime + : (System.currentTimeMillis() + 3600000)); + content.setPriority("default"); + content.setSound(true); + contents.add(content); + } catch (Exception e) { + Log.e(TAG, "Error parsing API response", e); + } + return contents; + } } diff --git a/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java b/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java new file mode 100644 index 00000000..82c6d0ae --- /dev/null +++ b/android/app/src/main/java/app/timesafari/notifications/NotificationInspectorPlugin.java @@ -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"); + } +} + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 0319aa38..6fd65f99 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -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') diff --git a/capacitor.config.ts b/capacitor.config.ts index 29aa5d1f..adbf52e2 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -18,6 +18,9 @@ const config: CapacitorConfig = { ] } }, + PushNotifications: { + presentationOptions: ['badge', 'sound', 'alert'] + }, SplashScreen: { launchShowDuration: 3000, launchAutoHide: true, diff --git a/doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md b/doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md index ff4063dd..502bfb7e 100644 --- a/doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md +++ b/doc/DAILY_NOTIFICATION_BUG_DIAGNOSIS.md @@ -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) diff --git a/doc/PLUGIN_NOTIFICATION_FIX_ANDROID.md b/doc/PLUGIN_NOTIFICATION_FIX_ANDROID.md new file mode 100644 index 00000000..7da70d52 --- /dev/null +++ b/doc/PLUGIN_NOTIFICATION_FIX_ANDROID.md @@ -0,0 +1,129 @@ +# Android plugin: New Activity notification when API has no activities + +**Audience:** Maintainers of `@timesafari/daily-notification-plugin` (Android / Kotlin). +**Host app:** TimeSafari (`crowd-funder-for-time-pwa`) — this file lives in the **app** repo only as a handoff; apply changes in the **plugin** repo. + +**Problem (product):** “New Activity” should notify only when the API reports new/updated activity. The host’s native fetcher (`TimeSafariNativeFetcher`) returns an **empty** `List` when the API’s `data` array is empty. Users still see a **daily** local notification. + +**Version note:** This diagnosis was first written against older plugin builds (e.g. **2.1.x / 2.2.x**). After upgrading the host to **`@timesafari/daily-notification-plugin` 3.0.0**, the Android files below were **re-read** from `node_modules`. The relevant logic is **unchanged** in 3.0.0: the same two mechanisms still explain unwanted daily notifications when the API returns no rows. If you maintain the plugin, re-verify after each major release. + +**Root cause (Android, confirmed in plugin v3.0.0 sources under `node_modules`):** Two mechanisms interact: + +1. **`FetchWorker.kt` — empty native fetch is converted to synthetic JSON instead of “skip”** + When the dual prefetch runs with the native fetcher and the list is empty, `notificationContentsToDualPayloadBytes` **replaces** the empty list with a JSON payload `"No updates"` / `"No new content"`, and the work unit still completes successfully. The dual path then **always** arms the chained notify alarm when `isDual && nextNotifyAt > 0L` — so a notification is still scheduled for the notify window. + + Reference (plugin): + + ```kotlin + // FetchWorker.kt — notificationContentsToDualPayloadBytes (~371–374 in v3.0.0) + if (contents.isEmpty()) { + return """{"title":"No updates","body":"No new content"}""".toByteArray(Charsets.UTF_8) + } + ``` + + ```kotlin + // FetchWorker.kt — doWork(), tail of success path (~306–309 in v3.0.0) + if (isDual && nextNotifyAt > 0L) { + DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt) + DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext) + } + ``` + +2. **`DualScheduleHelper.kt` — `fallbackBehavior: "show_default"` uses `userNotification` defaults** + At display time, if there is **no** fresh dual-scope cache within `relationship.contentTimeout`, the helper falls back to the **persisted** `userNotification.title` / `userNotification.body` when `fallbackBehavior` is `"show_default"`. The host app sets those defaults to copy such as “New Activity” / “Check your starred projects…”, so the user sees that **even when the API had nothing**, if the cache path doesn’t supply something else. + + Reference (plugin): + + ```kotlin + // DualScheduleHelper.kt — resolveDualContentBlocking (simplified; ~31–57 in v3.0.0) + val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default" + val defaultTitle = userNotification.optString("title", "Daily Notification") + val defaultBody = userNotification.optString("body", "Your daily update is ready") + // ... + } else { + if (fallbackBehavior != "show_default") return null + Pair(defaultTitle, defaultBody) + } + ``` + +**TypeScript contract (plugin `src/definitions.ts` in v3.0.0 — `DualScheduleConfiguration.relationship`):** + +```ts +relationship?: { + autoLink: boolean; + contentTimeout: number; + fallbackBehavior: 'skip' | 'show_default' | 'retry'; +}; +``` + +`skip` is only partially useful on Android **with the current fetch implementation**: it avoids the **default title/body** branch in `DualScheduleHelper` when cache is missing/stale, but it does **not** by itself stop a notification if the fetch path still materializes content (including the synthetic `"No updates"` payload) or if chained notify is already armed. + +**3.0.0 vs 2.2.x:** Plugin **3.0.0** advertises broader features (e.g. TTL-at-fire, observability). Those do **not** replace the dual-fetch pipeline inspected here: `FetchWorker` still maps an empty native list to JSON and still schedules the chained notify on success; `DualScheduleHelper` still applies `show_default` vs defaults when cache is absent or outside `contentTimeout`. Revisit this doc if a future release changes `notificationContentsToDualPayloadBytes` or the dual notify gate. + +--- + +## Recommended plugin changes (Android) + +### 1) Treat empty native fetch as “no notification” (primary) + +**File:** `android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt` + +**Issue:** `notificationContentsToDualPayloadBytes` must not turn an empty list into a non-empty payload if the product contract is “no rows in API → no notification.” + +**Direction:** + +- **Before:** Empty list → JSON `No updates` / `No new content` → success → chained notify scheduled. +- **After (one of):** + - **A)** Return a dedicated sentinel payload (e.g. `{ "skipNotification": true }`) and teach **`NotifyReceiver` / worker** that resolves dual content to **not post** when that sentinel is present; **or** + - **B)** On empty list, **do not** call `DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm` for this cycle (and optionally persist “last fetch had no content” for the helper); **or** + - **C)** Store an empty/marker cache row that `DualScheduleHelper.resolveDualContentBlocking` interprets as “return null” (no notification). + +Pick one strategy and keep behavior consistent with `relationship.fallbackBehavior`: + +- If `fallbackBehavior == "skip"`: skip notification when fetch returns empty or when sentinel indicates skip. +- If `fallbackBehavior == "show_default"`: keep current default-title/body behavior **only** when the product intends it (may be wrong for TimeSafari). + +### 2) Honor `relationship.fallbackBehavior` end-to-end + +**Files:** `FetchWorker.kt`, `DualScheduleHelper.kt`, any worker/receiver that posts the dual notification. + +**Issue:** `DualScheduleHelper` reads `fallbackBehavior`, but the fetch path does not use the same semantics for “empty API result.” + +**Direction:** When persisting dual config, pass `fallbackBehavior` into the fetch success path so that **empty fetch + `skip`** never schedules or displays a notification. + +### 3) Tests + +- Dual fetch + native fetcher returns **empty list** → **no** notification posted (or no chained alarm), matching host expectation. +- Non-empty list → notification with fetcher-provided title/body. +- Optional: `fallbackBehavior` matrix (`skip` / `show_default`) with stale cache vs fresh cache. + +--- + +## Host app follow-up (separate PR in `crowd-funder-for-time-pwa`) + +After the plugin implements empty-fetch semantics, set in `buildDualScheduleConfig` (`src/services/notifications/dualScheduleConfig.ts`): + +```ts +relationship: { + autoLink: true, + contentTimeout: 5 * 60 * 1000, + fallbackBehavior: "skip", // was "show_default" +}, +``` + +Only do this once Android behavior matches the contract (otherwise users may get **no** notification even when you would want defaults on network failure — product decision). + +--- + +## References in this repo (context only) + +- Host native fetcher returns no content when API `data` is empty: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` (`parseApiResponse`). +- Host dual config today uses `fallbackBehavior: "show_default"`: `src/services/notifications/dualScheduleConfig.ts`. + +--- + +## Plugin version verification + +- **Last verified against:** `@timesafari/daily-notification-plugin` **3.0.0** (`node_modules/.../package.json`). +- **Prior builds:** Behavior matched the earlier **2.1.x** analysis; **2.2.0 → 3.0.0** did not remove the empty-list → synthetic JSON mapping or the chained-notify success path in the inspected sources. +- Re-verify line numbers after rebasing or patching the plugin repo. diff --git a/doc/android-physical-device-guide.md b/doc/android-physical-device-guide.md index 22ddba76..bb74adab 100644 --- a/doc/android-physical-device-guide.md +++ b/doc/android-physical-device-guide.md @@ -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: diff --git a/doc/background-jwt-pool.md b/doc/background-jwt-pool.md new file mode 100644 index 00000000..8a5d1c8f --- /dev/null +++ b/doc/background-jwt-pool.md @@ -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. diff --git a/doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md b/doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md new file mode 100644 index 00000000..6ebc00bb --- /dev/null +++ b/doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md @@ -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`). diff --git a/doc/daily-notification-alignment-outline.md b/doc/daily-notification-alignment-outline.md index 2e45e47c..162b2827 100644 --- a/doc/daily-notification-alignment-outline.md +++ b/doc/daily-notification-alignment-outline.md @@ -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`). --- diff --git a/doc/endorser-jwt-background-prefetch-options.md b/doc/endorser-jwt-background-prefetch-options.md new file mode 100644 index 00000000..4023a6d2 --- /dev/null +++ b/doc/endorser-jwt-background-prefetch-options.md @@ -0,0 +1,152 @@ +# Options: expired JWT during background “New Activity” prefetch (mobile) + +**Date:** 2026-03-26 17:29 PST +**Audience:** TimeSafari / crowd-funder team; **Endorser server** maintainers (auth + API policy) +**Context:** Android Capacitor app, `POST /api/v2/report/plansLastUpdatedBetween`, native `TimeSafariNativeFetcher` invoked from WorkManager at **T−5 minutes** before the daily notification. + +--- + +## Problem (short) + +New Activity notifications prefetch Endorser data in **background** (no JavaScript, no WebView). The HTTP client uses a **Bearer JWT** supplied earlier via `configureNativeFetcher` / `getHeaders(activeDid)`. + +If the **access token’s `exp`** is **before** prefetch time, the API returns **400** with a body like: + +```json +{ + "error": { + "message": "JWT failed verification: ... JWT has expired: exp: … < now: …", + "code": "JWT_VERIFY_FAILED" + } +} +``` + +We **cannot** rely on the user opening the app immediately before prefetch (T−5), so **client-only** mitigations (e.g. refresh JWT on app resume) **reduce** failures but **do not guarantee** a valid token for headless background work. + +--- + +## Why this is different from normal in-app API calls + +| In-app | Background prefetch | +|--------|----------------------| +| `getHeaders()` runs in JS when needed; user often recently active | WorkManager runs **without** Capacitor / passkey / session refresh | +| Short TTL tokens are refreshed as the user uses the app | Same token may sit in native memory until **T−5** (or longer) | + +So **server-side** and **architecture** choices matter for this feature. + +--- + +## Options (for decision) + +### 1. Increase access token TTL (Endorser / IdP) + +**Idea:** Issue access JWTs with a longer `exp` so that **configure time → prefetch time** (often **5+ minutes**, sometimes **24h+** if the user rarely opens the app) usually still falls inside validity. + +| Pros | Cons | +|------|------| +| Simple to explain; one policy change | Longer-lived bearer tokens increase risk if exfiltrated; mitigate with scope, rotation, monitoring | +| No client protocol change | May not fit strict security posture without a dedicated scope | + +**Endorser owner:** token lifetime, scopes, and whether a **dedicated** lifetime or scope for “mobile background read” is acceptable. + +--- + +### 2. Scoped long-lived token for report reads only (Endorser) + +**Idea:** Mint a **separate** access token (or sub-scope) valid only for **read-only report** endpoints (`plansLastUpdatedBetween`, etc.), with a **longer TTL** than the interactive session token. + +| Pros | Cons | +|------|------| +| Limits blast radius vs “longer JWT for everything” | Requires auth model + issuance path; client must store/use this token only for prefetch | + +**Endorser owner:** feasibility of **narrow scope** + **longer TTL** for this use case. + +--- + +### 3. Refresh token or device grant (Endorser + mobile native) + +**Idea:** Client stores a **refresh token** (or OAuth **device** grant) in **Android Keystore / iOS Keychain**. Before `plansLastUpdatedBetween`, **native** code (no JS) exchanges it for a **new access token**. + +| Pros | Cons | +|------|------| +| Standard pattern; short TTL for access tokens remains | Endorser must support refresh (or equivalent); secure storage + rotation; **both** client and server work | +| Works when app is backgrounded for days | Implementation cost on mobile | + +**Endorser owner:** refresh endpoint, token rotation, revocation. +**Mobile owner:** native fetch path, secure storage, failure handling. + +--- + +### 4. Backend proxy / BFF (TimeSafari backend + Endorser) + +**Idea:** Phone calls **your** backend with a **device session** (or FCM registration id); **server** uses **server-to-server** credentials or a **service account** to call Endorser. The device **never** sends an Endorser JWT for this path. + +| Pros | Cons | +|------|------| +| No Endorser JWT lifetime problem on device | New service, auth, rate limits, privacy review | +| Central place for logging, abuse control | Operational cost | + +**Endorser owner:** partner / S2S auth model for the BFF. +**Product team:** hosting and trust boundaries. + +--- + +### 5. “Cron” or periodic jobs on the device to refresh JWT (JS) + +**Idea:** Use something like a **cron** schedule to refresh tokens. + +**Reality:** Scheduled **native** jobs can run, but **Capacitor / `getHeaders()` / passkey** do **not** run reliably in that context without waking the **WebView**. So **“cron”** only helps if refresh is **fully native** (see option 3) or you accept **unreliable** wake + JS. + +**Not recommended** as the primary fix unless paired with **native refresh** or **server** changes. + +--- + +### 6. Product / UX constraints (no server change) + +**Idea:** Accept that **headless** API calls may fail if the session is stale; show **fallback** copy; or require “open app once per day” for best results. + +| Pros | Cons | +|------|------| +| No Endorser change | Does not meet “API-driven notification” expectation for inactive users | + +--- + +## Client-side mitigations already in play (not sufficient alone) + +- **`configureNativeFetcherIfReady()`** after startup and when **Account** / identity is ready. +- **`appStateChange` → `isActive`:** refresh native fetcher when the app returns to foreground (reduces staleness when the user **does** open the app). +- **Error logging** of 400 bodies for diagnosis. + +These **do not** guarantee a fresh JWT at **T−5** if the user never opens the app before prefetch. + +--- + +## Suggested decision order + +1. **Align on security posture:** Is a **longer TTL** or **scoped long-lived read token** acceptable for Endorser? +2. If not, is **refresh token in native** (option 3) or **BFF** (option 4) on the roadmap? +3. **Parallel:** UX fallback when API is unavailable (option 6) so the app never silently looks “broken.” + +--- + +## References (this repo) + +| Topic | Location | +|--------|----------| +| Native fetcher + JWT from `getHeaders` | `src/services/notifications/nativeFetcherConfig.ts` | +| Android POST + errors | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | +| Web `plansLastUpdatedBetween` + `afterId` | `src/libs/endorserServer.ts` (`getStarredProjectsWithChanges`) | +| New Activity / dual schedule | `doc/notification-from-api-call.md`, `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | + +--- + +## Open questions for Endorser (server developer) + +1. What is the **current access token TTL** and can it be **increased** for mobile clients, or **per-scope**? +2. Is **refresh token** (or similar) available for **non-interactive** renewal? +3. Would a **read-only** scope for `plansLastUpdatedBetween` with a **longer** lifetime be acceptable? +4. Is there an existing **server-to-server** or **partner** path that a **BFF** could use instead of user JWT on device? + +--- + +*This document is for internal planning and decision; update it when the team chooses an approach.* diff --git a/doc/local-android-testing-analysis.md b/doc/local-android-testing-analysis.md new file mode 100644 index 00000000..b840fe68 --- /dev/null +++ b/doc/local-android-testing-analysis.md @@ -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) +**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. + +**Status:** Planning only — does not replace or modify the iOS guide. + +--- + +## 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). diff --git a/doc/local-android-testing-ngrok.md b/doc/local-android-testing-ngrok.md new file mode 100644 index 00000000..38c7788e --- /dev/null +++ b/doc/local-android-testing-ngrok.md @@ -0,0 +1,1030 @@ +# Local Android Testing with ngrok (notification-wakeup-service) + +**Last updated:** 2026-06-02 (verification checklist, end-to-end test) +**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service** +**Goal:** Exercise FCM wakeup (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical Android device. + +**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 New Activity / silent wake on a physical Android phone: + +```text +┌─────────────────────┐ HTTPS ┌──────────────────────┐ +│ Mac (localhost) │ ◄───────────── │ ngrok edge │ +│ notification- │ tunnel │ (public HTTPS URL) │ +│ wakeup-service │ └──────────┬───────────┘ +└──────────┬──────────┘ │ + │ │ fetch + │ POST /notifications/refresh │ POST /notifications/register + │ ▼ + │ ┌──────────────────────┐ + │ │ crowd-funder-for- │ + │ │ time-pwa (Capacitor │ + │ │ Android on device) │ + │ └──────────┬───────────┘ + │ │ + │ FCM data message (WAKEUP_PING) │ daily-notification-plugin + ▼ ▼ (local schedule replace) +┌─────────────────────┐ ┌──────────────────────┐ +│ 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, refresh payload (`nextNotifications`), health, debug wakeup send | +| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push | +| **daily-notification-plugin** | Native Android: clear + reschedule local notifications from refresh timestamps | + +### Android wakeup flow (production path) + +1. **notification-wakeup-service** (or `/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 calls `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })`, which `POST`s `{backend}/notifications/refresh` with `testMode` from the debug config. +5. Backend returns `nextNotifications: [{ timestamp }, ...]`. +6. App calls `applyNotificationRefreshPayload()` → **daily-notification-plugin** clears existing local alarms and schedules new ones. + +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 `. +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/refresh 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"` | +| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) | +| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend + ngrok test | +| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup` on the backend override URL; server sends a real FCM `WAKEUP_PING` to the panel’s current FCM token (see below) | +| **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 refresh 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 + +Three panel actions exercise different segments of the wakeup pipeline. Use them to bisect failures (see [Troubleshooting §14](#fcm-message-not-received)). + +| Button | Behavior | +|--------|----------| +| **Backend Testing → Simulate WAKEUP_PING (Local)** | Skips FCM; calls refresh API only (ngrok path test) | +| **Backend Testing → Send Real WAKEUP_PING** | Full pipeline: backend `/debug/send-wakeup` → FCM → Capacitor listener → refresh (see below) | +| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload (no FCM, no backend wakeup call) | + +Use **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh; use **Wakeup Ping Simulator** to verify handler + refresh chaining without FCM; use **Send Real WAKEUP_PING** for end-to-end FCM delivery on device. + +### 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()` → `refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" })` → `POST /notifications/refresh` → `applyNotificationRefreshPayload()` (clear + reschedule via **daily-notification-plugin**). + +That exercises the full **backend → FCM → Capacitor push listener → refresh request → notification rescheduling** path on Android without manual `curl` on the Mac. + +**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|pushNotificationReceived|WAKEUP_PING|Refresh started|Refresh completed)' +``` + +On a **successful end-to-end** 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] pushNotificationReceived type=WAKEUP_PING +[Notifications] WAKEUP_PING handler — invoking refresh +[Notifications] Refresh started (WAKEUP_PING) +[Notifications] Refresh completed (WAKEUP_PING) in …ms (scheduled N) +``` + +Intermediate lines (e.g. `WAKEUP_PING received — will trigger refresh`, `Schedule replacement: …`, `Using authenticated notification request` or auth bypass messages when **Skip JWT Authentication** is on) are normal. ngrok should also show a new `POST /notifications/refresh` after the push is handled. + +#### Send Real WAKEUP_PING vs end-to-end 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 or ran refresh. + +**Successful end-to-end delivery** is confirmed only when the subsequent **`pushNotificationReceived`**, **`WAKEUP_PING handler`**, and **`Refresh started (WAKEUP_PING)`** / **`Refresh completed (WAKEUP_PING)`** lines appear in logcat (or Event Log) within the delivery window. If you see `Real WAKEUP_PING success` but not those lines, 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 + +``` + +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 and your ngrok-backed refresh both need network: + +- Device must reach **Google’s FCM endpoints** (mobile data or Wi‑Fi). +- After wake, the app must reach the **ngrok HTTPS URL** for `POST /notifications/refresh`. +- Airplane mode, captive portals, VPNs, or flaky Wi‑Fi cause “server sent wakeup but app never refreshed” 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/refresh smoke tests. + +### Logcat + +```bash +adb logcat | grep -E '\[Notifications\]|\[FirebaseMessaging\]|\[NativeNotificationService\]' +``` + +Expect `pushNotificationReceived type=WAKEUP_PING` and `WAKEUP_PING handler — invoking refresh` 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 or prevent the refresh `fetch` from running promptly. + +| 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 → `refreshNotificationsWithDiagnostics()` → ngrok `POST /notifications/refresh`. +4. Any step can lag under battery savers; use **Simulate WAKEUP_PING (Local)** in the debug panel to separate FCM delay from refresh/API issues; use **Send Real WAKEUP_PING** for the full 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. Tap **Refresh Notifications** → confirm `POST /notifications/refresh` and `Refresh completed in Nms (scheduled X)` in Event Log. +8. Optional: tap **Simulate WAKEUP_PING (Local)** to verify ngrok + refresh without FCM. +9. 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**. +10. Watch logcat for `WAKEUP_PING` and `Refresh completed (WAKEUP_PING)` lines ([expected output](#expected-logcat-output)). +11. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate HTTP traffic. +12. Use **Pending Notification Inspector** on the panel to confirm locally scheduled fires after 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 | Refresh returns schedule data | §5 | +| 6 | Locals scheduled | §6 | +| 7 | Manual wakeup sends FCM | §7 | +| 8 | WAKEUP_PING → refresh | §8 | +| 9 | Replace after refresh | §9 | +| 10 | Test mode frequent refreshes | §10 | + +### 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 `/notifications/refresh`. + +--- + +### 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 returns schedule data + +**Actions:** + +1. Tap **Refresh Notifications** in the panel (or curl below). +2. Inspect ngrok response body. + +```bash +curl -sS -X POST "$BASE/notifications/refresh" \ + -H "Content-Type: application/json" \ + -d '{"platform":"android","testMode":true}' +``` + +**Expected outcome:** HTTP **200**; JSON includes `nextNotifications` array with at least one `{ "timestamp": }` in the **future** (epoch ms). Event Log: `Refresh completed in …ms (scheduled N)` with **N ≥ 1**. If `scheduled 0` or empty array, fix backend auth, DID/native fetcher, or `testMode` handling before scheduling tests. + +--- + +### 6. Local notifications are scheduled + +**Actions:** + +1. After a successful refresh (step 5), open **Pending Notification Inspector** → **Refresh** (list button). +2. Optionally cross-check logcat for `Schedule replacement applied (N timestamp(s))`. + +**Expected outcome:** Inspector lists **N** pending item(s) matching refresh count; each row has a **future** `nextTriggerDate` / wall-clock time. No `Schedule replacement aborted` or `skipped (no valid timestamps)` in Event Log. + +--- + +### 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 end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success)). + +--- + +### 8. WAKEUP_PING triggers refreshNotifications() + +**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|pushNotificationReceived|Refresh completed' +``` + +**Expected outcome (within ~30–120s, longer under Doze/OEM):** + +1. `pushNotificationReceived type=WAKEUP_PING` / `WAKEUP_PING received` +2. `WAKEUP_PING handler — invoking refresh` +3. `Refresh started (WAKEUP_PING)` +4. ngrok: new `POST /notifications/refresh` +5. `Refresh completed (WAKEUP_PING) in …ms (scheduled N)` + +**Isolation:** **Wakeup Ping Simulator** on the panel should produce lines 2–5 without FCM. **Simulate WAKEUP_PING (Local)** proves refresh only (no handler, source `WAKEUP_PING simulation`). Full expected logcat for **Send Real WAKEUP_PING**: [§6](#expected-logcat-output). + +--- + +### 9. Existing notifications are replaced after refresh + +**Actions:** + +1. Note pending count and identifiers in **Pending Notification Inspector**. +2. Tap **Refresh Notifications** again (or trigger step 8). +3. Refresh inspector; compare IDs/times to step 1. + +**Expected outcome:** Event Log shows `clearAllNotifications` or `cancelAllNotifications` then `Schedule replacement applied`; pending list reflects **new** timestamps (old alarms not accumulated). Total pending count should match latest refresh `N`, not double from duplicate refreshes unless backend returned more slots. + +--- + +### 10. Test mode produces frequent notification refreshes + +**Actions:** + +1. Confirm **Test Mode** checked; **Backend Status → testMode: true**. +2. Call refresh twice (panel or curl) with `testMode: true`. +3. Compare `nextNotifications` timestamps in ngrok responses. + +**Expected outcome:** With **testMode: true**, **notification-wakeup-service** returns **dev-friendly** schedule data—typically **sooner** fire times than production mode (shorter horizons). Pending Inspector updates to nearer triggers after each refresh. With Test Mode **off**, timestamps should be farther out (production-like); use that contrast to confirm the flag is wired end-to-end. + +--- + +## 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 and schedule + +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). + +11. **Refresh Notifications** → Event Log `scheduled N`, N ≥ 1; ngrok refresh **200** with `nextNotifications`. + +12. **Pending Notification Inspector** → **Refresh** → future alarm(s) listed. + +### Phase D — FCM wakeup path + +13. Background app (Home). + +14. 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. + +15. Within 30–120s (longer if unplugged/Doze): logcat shows `pushNotificationReceived` → `Refresh completed (WAKEUP_PING)`; ngrok shows second `POST /notifications/refresh`. + +16. Pending Inspector **Refresh** → timestamps updated (replacement, not duplicate stack). + +### Phase E — Optional delivery proof + +17. If `testMode` returned a trigger within a few minutes, wait for wall-clock fire with app backgrounded; confirm notification appears (permission + channel + exact alarm rules). + +18. If FCM step 15 failed but step 11 passed: run **Simulate WAKEUP_PING (Local)** then **Wakeup Ping Simulator** to bisect FCM vs handler; see [Troubleshooting](#14-troubleshooting) and [Send Real WAKEUP_PING vs end-to-end success](#send-real-wakeup_ping-vs-end-to-end-success). + +### End-to-end pass criteria + +| Phase | Pass | +|-------|------| +| A | Health OK local + ngrok | +| B | Override URL + testMode active | +| C | Register + refresh + pending list populated | +| D | Wakeup curl OK → logcat refresh chain → pending updated | +| E | Optional visible notification at scheduled time | + +--- + +## 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 (mirror app payload) + +```bash +curl -sS -X POST "$BASE/notifications/refresh" \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "android", + "testMode": true + }' +``` + +Example success body shape (actual fields may vary by service version): + +```json +{ + "shouldNotify": true, + "nextNotifications": [ + { "timestamp": 1710000000000 }, + { "timestamp": 1710003600000 } + ] +} +``` + +The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`). + +### 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 must send FCM data including `type: "WAKEUP_PING"` to match `handleCapacitorPushNotificationReceived`. + +--- + +## 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:** **Refresh Notifications** fails; Event Log HTTP error; no `scheduled X` line; ngrok missing `POST /notifications/refresh`. + +**Likely causes:** Stale ngrok URL; backend down; 404/wrong path; JWT/native fetcher not configured; refresh auth failure. + +**Verification:** + +1. **Simulate WAKEUP_PING (Local)** — if this fails, problem is ngrok/refresh API, not FCM. +2. ngrok inspect for refresh status code and response body. +3. Logcat: `refreshNotifications failed` or JWT errors from `configureNativeFetcherIfReady()`. + +**Fixes:** Fix backend URL and health; ensure active DID and endorser settings ([notification-from-api-call.md](./notification-from-api-call.md)); confirm `testMode` if backend requires it. + +--- + +### FCM message not received + +**Symptoms:** `/debug/send-wakeup` or panel **Send Real WAKEUP_PING** shows success (`Real WAKEUP_PING success` in Event Log); no `pushNotificationReceived` / `WAKEUP_PING` in logcat within 2 minutes; refresh never triggered. + +**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 downstream refresh logs indicates a **delivery** failure, not a failed wakeup API call ([§6](#send-real-wakeup_ping-vs-end-to-end-success)). + +**Verification:** + +1. App **backgrounded** (Home), not force-stopped. +2. Panel FCM token matches token used by server/register. +3. **Simulate WAKEUP_PING (Local)** works → isolates FCM path from refresh/API. +4. Wait 30–120s (longer on Doze/OEM). +5. 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:** Refresh succeeds (`scheduled X` in log) 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 after refresh. +3. Logcat: `Schedule replacement applied` vs aborted messages. +4. Confirm `nextNotifications` timestamps are in the future (curl refresh response). + +**Fixes:** Grant `POST_NOTIFICATIONS`; check `SCHEDULE_EXACT_ALARM` / alarm permission per plugin docs; fix refresh payload; test with nearer timestamps via backend `testMode`. + +--- + +### Duplicate notifications + +**Symptoms:** Multiple identical local notifications; Event Log shows repeated refresh lines. + +**Likely causes:** Multiple `WAKEUP_PING` deliveries; repeated manual **Refresh**; flood test; separate Daily Reminder vs New Activity schedules. + +**Verification:** Event Log count of refresh completions; ngrok inspect for duplicate `POST /notifications/refresh`. + +**Fixes:** Each refresh should **replace** schedule (clear + schedule)—if duplicates persist, check plugin logs; see [notification-new-activity-lay-of-the-land.md](./notification-new-activity-lay-of-the-land.md) for product-level double-schedule issues. + +--- + +### 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` | `refreshNotifications`, `WAKEUP_PING`, `applyNotificationRefreshPayload` | +| `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**. diff --git a/doc/local-ios-testing-ngrok.md b/doc/local-ios-testing-ngrok.md new file mode 100644 index 00000000..c091f067 --- /dev/null +++ b/doc/local-ios-testing-ngrok.md @@ -0,0 +1,544 @@ +# Local iOS Testing with ngrok (notification-wakeup-service) + +**Last updated:** 2026-05-18 +**Audience:** Developers on **crowd-funder-for-time-pwa**, **daily-notification-plugin**, and **notification-wakeup-service** +**Goal:** Exercise silent push wake (`WAKEUP_PING`), FCM token registration, and notification refresh against a Mac-hosted backend reachable from a physical iPhone. + +--- + +## Architecture overview + +End-to-end flow when testing New Activity / silent wake on a physical iPhone: + +```text +┌─────────────────────┐ HTTPS ┌──────────────────────┐ +│ Mac (localhost) │ ◄───────────── │ ngrok edge │ +│ notification- │ tunnel │ (public HTTPS URL) │ +│ wakeup-service │ └──────────┬───────────┘ +└──────────┬──────────┘ │ + │ │ fetch + │ POST /notifications/refresh │ POST /notifications/register + │ ▼ + │ ┌──────────────────────┐ + │ │ crowd-funder-for- │ + │ │ time-pwa (Capacitor │ + │ │ iOS on iPhone) │ + │ └──────────┬───────────┘ + │ │ + │ FCM data message (WAKEUP_PING) │ daily-notification-plugin + ▼ ▼ (local schedule replace) +┌─────────────────────┐ ┌──────────────────────┐ +│ Firebase Cloud │ ──APNs──────► │ iPhone (physical) │ +│ Messaging │ silent push │ app.timesafari │ +└─────────────────────┘ └──────────────────────┘ +``` + +### Repos and responsibilities + +| Repo | Role | +|------|------| +| **notification-wakeup-service** | HTTP API: device registration, refresh payload (`nextNotifications`), health, debug wakeup send | +| **crowd-funder-for-time-pwa** | Capacitor app: FCM token, `POST /notifications/register` & `/refresh`, handles `WAKEUP_PING` push | +| **daily-notification-plugin** | Native iOS/Android: clear + reschedule local notifications from refresh timestamps | + +### Silent wake sequence (production path) + +1. Backend (or `/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. App calls `POST {backend}/notifications/refresh` with `testMode` (from debug config). +5. Backend returns `nextNotifications: [{ timestamp }, ...]`. +6. App calls `applyNotificationRefreshPayload()` → plugin clears and schedules new local alarms. + +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 `. +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/refresh 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 | +| **Refresh Notifications** | `POST /notifications/refresh` (same as post-wakeup flow) | +| **Simulate WAKEUP_PING (Local)** | Calls refresh API directly (no FCM) — quick backend test | +| **Send Real WAKEUP_PING** | `POST /debug/send-wakeup`; server sends real FCM `WAKEUP_PING` (Android doc has full flow) | +| **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 match what `handleCapacitorPushNotificationReceived` expects (`data.type === "WAKEUP_PING"`). + +--- + +## 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). + +### Two “Simulate WAKEUP_PING” buttons + +| Button | Behavior | +|--------|----------| +| **Backend Testing → Simulate WAKEUP_PING** | Skips FCM; calls refresh API only (ngrok path test) | +| **Wakeup Ping Simulator** (lower on panel) | Runs production handler with synthetic `WAKEUP_PING` payload | + +Use the backend button to verify ngrok + refresh; use the simulator to verify handler + refresh chaining. + +--- + +## 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. Tap **Refresh Notifications** → confirm `Refresh completed in Nms (scheduled X)` in Event Log and ngrok `POST /notifications/refresh`. +6. From the backend, call **`/debug/send-wakeup`** (see curl below) with the registered `deviceId` / FCM token as required by that service. +7. Watch **Xcode console** for `[Notifications] pushNotificationReceived type=WAKEUP_PING` and refresh timing lines. +8. Open **ngrok inspect UI** (`http://127.0.0.1:4040`) to correlate requests. +9. Use **Pending Notification Inspector** on the panel to see locally scheduled fires after refresh. + +--- + +## 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 (mirror app payload) + +```bash +curl -sS -X POST "$BASE/notifications/refresh" \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "ios", + "testMode": true + }' +``` + +Example success body shape (actual fields may vary by service version): + +```json +{ + "shouldNotify": true, + "nextNotifications": [ + { "timestamp": 1710000000000 }, + { "timestamp": 1710003600000 } + ] +} +``` + +The app schedules those timestamps via **daily-notification-plugin** (`applyNotificationRefreshPayload` in `NativeNotificationService.ts`). + +### 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 | +|---------|--------| +| Network error in Event Log | ngrok running? URL saved without typo/trailing slash? | +| HTTP 404 | Tunnel port matches backend `PORT`; path is `/notifications/refresh` | +| 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"` (see `NativeNotificationService.ts`) +- Server actually sent to the **same** FCM token shown in the debug panel +- Wait 30–120s — delivery is not instant +- Try **Simulate WAKEUP_PING** (refresh API) to isolate app/plugin from FCM/APNs + +### Notifications duplicating + +- Multiple refresh calls (flood test, repeated wakeups) each **replace** schedule via clear + schedule — check Event Log for repeated refreshes +- Separate issue: Daily Reminder vs New Activity both scheduling — see `doc/notification-new-activity-lay-of-the-land.md` + +### 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` | Refresh, `WAKEUP_PING`, schedule replace | +| `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**. diff --git a/doc/new-activity-notifications-ios-android-parity.md b/doc/new-activity-notifications-ios-android-parity.md new file mode 100644 index 00000000..d56a1b3a --- /dev/null +++ b/doc/new-activity-notifications-ios-android-parity.md @@ -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**). diff --git a/doc/notification-debug-panel.md b/doc/notification-debug-panel.md new file mode 100644 index 00000000..0bba83b8 --- /dev/null +++ b/doc/notification-debug-panel.md @@ -0,0 +1,234 @@ +# Notification Debug Panel + +**Created:** 2026-07-07 +**Updated:** 2026-07-22 +**Audience:** Developers testing notification registration, refresh, and WAKEUP_PING flows on native (iOS/Android) dev builds. + +The **Notification Debug Panel** is a dev-only UI for exercising the same notification orchestration paths the production app uses: FCM token registration, backend refresh, wakeup handling, and local schedule inspection. It does not duplicate scheduling logic. + +--- + +## Notification API base URL + +Notification HTTP calls (`/notifications/register`, `/notifications/refresh`, `/debug/send-wakeup`, etc.) do **not** use `APP_SERVER`. They use a dedicated Notification API host, resolved at runtime by `getNotificationApiBaseUrl()` in `NotificationDebugConfig.ts`. + +### 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/refresh`, `/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 refresh requests include `"testMode": true` in the JSON body. The backend can use this to return dev-friendly schedules or 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). | +| **Refresh Notifications** | `POST {backend}/notifications/refresh` — same path used after a real WAKEUP_PING. Applies returned schedule to the native plugin. | +| **Simulate WAKEUP_PING (Local)** | Calls the refresh API directly (no FCM). Quick test of backend URL + auth + refresh parsing without push delivery. | +| **Send Real WAKEUP_PING** | `POST {backend}/debug/send-wakeup`; server sends a real FCM data message with `data.type = "WAKEUP_PING"`. Exercises backend → FCM → Capacitor listener → refresh → reschedule. 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 | +|---------|---------| +| **Mock Timing Presets** | Interval for mock refresh timestamps (30 sec – 10 min). | +| **Trigger Mock Refresh** | Applies synthetic future timestamps locally — no backend call. | +| **Wakeup Ping Simulator** | Runs the production push handler with a synthetic `WAKEUP_PING` payload (no FCM, no backend). | +| **Flood Test** | Runs 20 sequential mock refreshes (stress test). | +| **Pending Notification Inspector** | Lists locally scheduled notifications (iOS; Android may show unavailable). | +| **Clear Notifications** | Clears/cancels all plugin-scheduled notifications on native. | + +--- + +## 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 trigger refresh + +**Real WAKEUP_PING success** only means the backend accepted the wakeup request and attempted FCM delivery. Missing `pushNotificationReceived` / `Refresh completed (WAKEUP_PING)` indicates an FCM delivery or background execution issue — not necessarily a bad wakeup API call. + +**Checks:** App backgrounded (not force-stopped); FCM token matches registration; **Simulate WAKEUP_PING (Local)** works (isolates FCM from refresh API). + +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` | `POST /notifications/refresh`, WAKEUP_PING handler | + +--- + +## 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) diff --git a/doc/notification-from-api-call.md b/doc/notification-from-api-call.md new file mode 100644 index 00000000..4c997756 --- /dev/null +++ b/doc/notification-from-api-call.md @@ -0,0 +1,108 @@ +# New Activity Notification (API-Driven Daily Message) + +**Purpose:** Integrate the daily-notification-plugin’s second feature—the **daily, API-driven message**—into the crowd-funder (TimeSafari) app. The first feature (daily static reminder) is already integrated; this document covers the plan, completed work, and remaining tasks for the API-driven flow. + +**References:** + +- Plugin: `daily-notification-plugin` (INTEGRATION_GUIDE.md, definitions.ts) +- Alignment outline: `doc/daily-notification-alignment-outline.md` +- Help copy: `HelpNotificationTypesView.vue` (“New Activity Notifications”) + +--- + +## Plan Summary + +The API-driven flow: + +1. **Prefetch** – Shortly before the user’s chosen time, the plugin runs a background job that calls the Endorser.ch API (e.g. `plansLastUpdatedBetween`, and optionally offers endpoints) using credentials supplied by the app. +2. **Cache** – Fetched content is stored in the plugin’s cache. +3. **Notify** – At the chosen time, the user sees a notification whose title/body come from that content (or a fallback). + +The app must: + +- **Configure the native fetcher** with `apiBaseUrl`, `activeDid`, and a JWT so the plugin’s background workers can call the API. +- **Implement the native fetcher** (or register an implementation) so the plugin can perform the actual HTTP requests and parse responses into notification content. +- **Sync starred plan IDs** to the plugin via `updateStarredPlans` so the fetcher knows which plans to query. +- **Expose UI** to enable/disable the “New Activity” notification and choose a time, and call `scheduleDualNotification` / `cancelDualSchedule` accordingly. + +--- + +## Tasks Finished + +- **Configure native fetcher on startup and identity** + - Added `configureNativeFetcherIfReady()` in `src/services/notifications/nativeFetcherConfig.ts` (reads `activeDid` and `apiServer` from DB, gets JWT via `getHeaders(did)`, calls `DailyNotification.configureNativeFetcher()`). + - Called from `main.capacitor.ts` after the 2s delay (with deep link registration). + - Called from `AccountViewView.initializeState()` when on native and `activeDid` is set; when New Activity is enabled, also calls `updateStarredPlans(settings.starredPlanHandleIds)`. +- **Implement real API calls in Android native fetcher** + - `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher`: POST to `/api/v2/report/plansLastUpdatedBetween` with `planIds` (from SharedPreferences `daily_notification_timesafari` / `starredPlanIds`) and `afterId`; when `data` is non-empty, builds **one** aggregated `NotificationContent` (title **Starred Project Update** or **Starred Project Updates**, body from `plan.name` with typographic quotes, then `has been updated.` or `+ N more have been updated.`); when `data` is empty, returns an empty list (no “no updates” notification); updates `last_acked_jwt_id` for pagination when content is returned. + - Registered in `MainActivity.onCreate()` via `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`. +- **Sync starred plan IDs** + - Shared helper `syncStarredPlansToNativePlugin(planIds)` in `src/services/notifications/syncStarredPlansToNativePlugin.ts` (exported from `src/services/notifications/index.ts`) calls `DailyNotification.updateStarredPlans` on native only; ignores `UNIMPLEMENTED`. + - When user enables New Activity, `scheduleNewActivityDualNotification()` uses the helper with `settings.starredPlanHandleIds ?? []`. + - When Account view loads and New Activity is on, `initializeState()` uses the helper with the same list. + - When the user stars or unstars on a project (`ProjectViewView.toggleStar`), after a successful settings save, the helper runs if `notifyingNewActivityTime` is set so prefetch sees the current list without reopening Account. +- **Dual schedule config and scheduling** + - Added `src/services/notifications/dualScheduleConfig.ts`: `timeToCron()`, `timeToCronFiveMinutesBefore()`, `buildDualScheduleConfig({ notifyTime, title?, body? })` (contentFetch 5 min before, userNotification at chosen time). + - When user enables New Activity and picks a time, app calls `DailyNotification.scheduleDualNotification({ config })` with this config. + - When user disables New Activity, app calls `DailyNotification.cancelDualSchedule()`. +- **UI for New Activity notification** + - Unhid the “New Activity Notification” block in `AccountViewView.vue` (toggle + accessibility). + - Enable flow: time dialog → save settings → on native, `scheduleNewActivityDualNotification(timeText)` (configure fetcher, updateStarredPlans, scheduleDualNotification). + - Disable flow: on native, `cancelDualSchedule()` then save and clear settings. + - Added `starredPlanHandleIds` to `AccountSettings` in `interfaces/accountView.ts`. +- **Exports** + - `src/services/notifications/index.ts` exports `configureNativeFetcherIfReady`, `syncStarredPlansToNativePlugin`, `buildDualScheduleConfig`, `timeToCron`, `timeToCronFiveMinutesBefore`, and `DualScheduleConfigInput`. + +--- + +## Checklist of Remaining Tasks + +### 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** +Test `scheduleDualNotification` and `cancelDualSchedule` on iOS; ensure content fetch and user notification fire at the expected times and that foreground/background behavior matches expectations. + +### Testing and hardening + +- **Test full flow on Android** +Enable New Activity, set time, wait for prefetch and notification (or use a short rollover for testing). Confirm notification shows with API-derived or fallback content. +- **Test full flow on iOS** +Same as Android: enable, set time, verify prefetch and notification delivery and content. +- **Test with no starred plans** +Enable New Activity with empty `starredPlanHandleIds`; confirm no crash; the native fetcher returns no Endorser-derived items when there is nothing to query or no new rows (see `TimeSafariNativeFetcher`). +- **Test JWT expiry** +Ensure behavior when the token passed to `configureNativeFetcher` has expired (e.g. app in background for a long time); document or implement refresh (e.g. re-call `configureNativeFetcherIfReady` on foreground or when opening Account). + +### Optional enhancements + +- **Offers endpoints** +Extend `TimeSafariNativeFetcher` (and any iOS fetcher) to call offers endpoints (e.g. `offers`, `offersToPlansOwnedByMe`) and merge with project-update content for richer notifications. +- **Documentation** +Add a short “New Activity notifications” section to BUILDING.md or a user-facing help page describing how the feature works and how to troubleshoot (e.g. no notification, wrong content, JWT/API errors). + +--- + +## File Reference + + +| Area | Files | +| ---------------------- | ----------------------------------------------------------------------- | +| Fetcher config | `src/services/notifications/nativeFetcherConfig.ts` | +| Starred list → plugin | `src/services/notifications/syncStarredPlansToNativePlugin.ts` | +| Dual schedule config | `src/services/notifications/dualScheduleConfig.ts` | +| Notification exports | `src/services/notifications/index.ts` | +| Startup | `src/main.capacitor.ts` | +| Account UI and flow | `src/views/AccountViewView.vue` | +| Project star / unstar | `src/views/ProjectViewView.vue` (`toggleStar`) | +| 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` | + + diff --git a/doc/notification-new-activity-lay-of-the-land.md b/doc/notification-new-activity-lay-of-the-land.md new file mode 100644 index 00000000..4c463e70 --- /dev/null +++ b/doc/notification-new-activity-lay-of-the-land.md @@ -0,0 +1,250 @@ +# Lay of the Land: API-Driven Daily Message (New Activity) and Web-Push Confusion + +**Purpose:** Shareable analysis of the New Activity (API-driven daily message) implementation and the root cause of “always fires / can’t be turned off.” For discussion with teammates. + +**Related:** `doc/notification-from-api-call.md` (plan and progress), teammate note about web-push confusion and possibly removing that logic. + +--- + +## 1. Two Separate Notification Features + +There are **two** distinct native notification flows that both go through the same UI component: + +| Feature | Plugin API | Purpose | +|--------|------------|--------| +| **Daily Reminder** | `scheduleDailyNotification` / `cancelDailyReminder` | Single daily alarm, static title/body (user’s message). | +| **New Activity** (API-driven) | `scheduleDualNotification` / `cancelDualSchedule` | Prefetch from API 5 min before, then notify at chosen time with API or fallback content. | + +- **Daily Reminder** is driven from AccountViewView’s “Daily Reminder” toggle; on native it uses `NotificationService.getInstance().scheduleDailyNotification()` / `cancelDailyNotification()` (backed by `NativeNotificationService` and a single `reminderId`: `"daily_timesafari_reminder"`). +- **New Activity** is intended to be driven only by `scheduleNewActivityDualNotification()` / `cancelDualSchedule()` in AccountViewView (dual schedule only). + +So: one feature = single schedule (reminder), the other = dual schedule (prefetch + notify). They are different plugin APIs and different lifecycle (enable/disable) handling. + +--- + +## 2. Where the Bug Comes From: One Dialog, Two Behaviors + +**New Activity** reuses the same dialog as Daily Reminder: **`PushNotificationPermission.vue`**. + +- When the user turns **New Activity** on from AccountViewView: + - AccountViewView opens this dialog with `DAILY_CHECK_TITLE` and a callback that, on success, calls `scheduleNewActivityDualNotification(timeText)` on native. + - The dialog does **not** receive `skipSchedule: true` for this flow (only the “edit reminder” flow does). + +So when the user clicks “Turn on Daily Reminder” in the dialog for **New Activity**: + +1. **PushNotificationPermission** (native path) runs `turnOnNativeNotifications()` and always calls: + - `service.scheduleDailyNotification({ time, title: "Daily Check-In", body: "Time to check your TimeSafari activity", ... })` + - i.e. it schedules the **single** daily reminder (plugin’s `scheduleDailyNotification`), using the same `reminderId` as Daily Reminder (`"daily_timesafari_reminder"`). +2. Then the callback runs and AccountViewView calls **`scheduleNewActivityDualNotification(timeText)`**, which calls the plugin’s **`scheduleDualNotification`**. + +Result: + +- **Two schedules** are created when enabling New Activity: + - One **single** reminder (wrong for New Activity): static “Daily Check-In” message, same ID as Daily Reminder. + - One **dual** schedule (correct): prefetch + notify with API/fallback content. +- When the user turns **New Activity** off, AccountViewView only calls **`cancelDualSchedule()`**. It never calls `cancelDailyNotification()` (or equivalent) for the single reminder. +- So the **single** reminder stays scheduled and keeps firing at the chosen time. That’s the notification that “always fires” and “can’t be turned off.” + +So the “huge problem with confusion with the web-push” is really: **the same dialog and the same “Turn on” path are used for both Daily Reminder and New Activity, but the dialog always schedules the single daily reminder on native**, while New Activity is supposed to use only the dual schedule. That mixing is what makes the wrong schedule stick and not be cancellable from the New Activity toggle. + +--- + +## 3. Key Files and Flows + +- **`src/components/PushNotificationPermission.vue`** + - Shared dialog for both “Daily Reminder” and “New Activity” (via `pushType` = `DIRECT_PUSH_TITLE` vs `DAILY_CHECK_TITLE`). + - On native it always uses `NotificationService.getInstance().scheduleDailyNotification(...)` (single reminder) and does not branch on “New Activity” to skip scheduling or to call the dual API. + - Saves `notifyingNewActivityTime` when `pushType === DAILY_CHECK_TITLE` (lines 834–836). So the dialog both schedules the wrong thing and persists settings for New Activity. + +- **`src/views/AccountViewView.vue`** + - **Daily Reminder:** toggle opens same dialog with `DIRECT_PUSH_TITLE`; on native, disable path calls `service.cancelDailyNotification()`. + - **New Activity:** toggle opens same dialog with `DAILY_CHECK_TITLE`; on success callback calls `scheduleNewActivityDualNotification(timeText)`; on disable only calls `DailyNotification.cancelDualSchedule()`. + - `initializeState()`: on native with `activeDid`, calls `configureNativeFetcherIfReady(activeDid)` and, if New Activity is on, `updateStarredPlans(...)`. It does **not** re-call `scheduleNewActivityDualNotification` on load (so no double dual-schedule from here). + +- **`src/services/notifications/NativeNotificationService.ts`** + - Single reminder only: `scheduleDailyNotification` → plugin `scheduleDailyNotification` with `id: this.reminderId` (`"daily_timesafari_reminder"`); `cancelDailyNotification` → `cancelDailyReminder({ reminderId })`. No dual API here. + +- **`src/services/notifications/nativeFetcherConfig.ts`** + - Only configures the plugin for API calls (JWT, apiBaseUrl, activeDid). No scheduling. + +- **`src/services/notifications/dualScheduleConfig.ts`** + - Builds config for `scheduleDualNotification` (contentFetch 5 min before, userNotification at notify time). Used only from AccountViewView’s `scheduleNewActivityDualNotification`. + +- **`src/main.capacitor.ts`** + - Imports the daily-notification plugin; after a 2s delay calls `configureNativeFetcherIfReady()`. No scheduling; only fetcher config. + +So: the “always fires / can’t turn off” behavior is from the **single** reminder created in `PushNotificationPermission` for New Activity and never cancelled when New Activity is turned off. The “confusion with web-push” is the reuse of the same dialog and the same native “schedule single reminder” path for both features. + +--- + +## 4. Plugin Usage Summary + +- **Single daily reminder (Daily Reminder):** + - Scheduled/cancelled via `NativeNotificationService.scheduleDailyNotification` / `cancelDailyNotification` → plugin `scheduleDailyNotification` / `cancelDailyReminder` with one `reminderId`. +- **Dual schedule (New Activity):** + - Scheduled/cancelled only in AccountViewView via `DailyNotification.scheduleDualNotification` / `cancelDualSchedule` (and `configureNativeFetcherIfReady` + `updateStarredPlans` as per doc). +- **Fetcher config (New Activity):** + - `configureNativeFetcherIfReady()` from main.capacitor and from AccountViewView `initializeState` / `scheduleNewActivityDualNotification`; no scheduling by itself. + +--- + +## 5. Root Cause (Concise) + +- **Single code path in PushNotificationPermission** for native: it always schedules the **single** daily reminder, regardless of `pushType` (Daily Reminder vs New Activity). +- For **New Activity**, that creates an extra, wrong schedule (single reminder) in addition to the correct dual schedule. +- **Disable path for New Activity** only calls `cancelDualSchedule()` and never cancels the single reminder, so that reminder keeps firing and appears as “always fires” and “can’t be turned off.” + +--- + +## 6. Proper Fix: Options and Detail + +A fix should ensure that (1) enabling New Activity creates only the dual schedule, and (2) disabling New Activity removes every schedule that was created for it. Below are concrete options and implementation notes. + +### 6.1 Option A: Don’t schedule the single reminder when the dialog is for New Activity (recommended) + +**Idea:** On native, when the dialog is opened for **New Activity** (`pushType === DAILY_CHECK_TITLE`), the dialog should **not** call `scheduleDailyNotification`. Only the callback in AccountViewView should run, and it already calls `scheduleNewActivityDualNotification(timeText)`, which uses the dual API only. + +**Where:** `PushNotificationPermission.vue`, inside `turnOnNativeNotifications()`. + +**Implementation sketch:** + +- After requesting permissions and before calling `service.scheduleDailyNotification(...)`, branch on `pushType` and platform: + - If native **and** `pushType === this.DAILY_CHECK_TITLE`: skip the `scheduleDailyNotification` call entirely. Still run the rest of the flow (e.g. build `timeText`, save settings if desired, call `callback(true, timeText, ...)`). AccountViewView’s callback will then call `scheduleNewActivityDualNotification(timeText)` and that is the only schedule created for New Activity. + - Otherwise (web, or Daily Reminder on native): keep current behavior and call `scheduleDailyNotification` as today. + +**Pros:** Single source of truth for “what is scheduled for New Activity” (dual only). No leftover single reminder to cancel later. Clear separation: dialog collects time + permission; AccountViewView owns native scheduling for New Activity. + +**Cons:** Dialog’s native path now has two behaviors (schedule vs no schedule) depending on `pushType`; needs a quick comment so future changes don’t regress. + +**Note:** The “edit reminder” flow already uses `skipSchedule: true` so the dialog doesn’t schedule; only the parent does. For New Activity enable, we’re doing the same idea: dialog doesn’t schedule on native, parent does. + +### 6.2 Option B: When turning New Activity off, also cancel the single reminder + +**Idea:** Assume the wrong single reminder might already exist (e.g. from before the fix, or from a different code path). When the user turns **New Activity** off, in addition to `cancelDualSchedule()`, call the service’s `cancelDailyNotification()` so the single reminder (same `reminderId` as Daily Reminder) is cancelled too. + +**Where:** `AccountViewView.vue`, inside the disable branch of `showNewActivityNotificationChoice()` (where we currently only call `DailyNotification.cancelDualSchedule()`). + +**Implementation sketch:** + +- On native, when user confirms “turn off New Activity”: + 1. Call `DailyNotification.cancelDualSchedule()` (existing). + 2. Call `NotificationService.getInstance().cancelDailyNotification()` (new) so any single reminder that was mistakenly scheduled for this flow is removed. + +**Pros:** Defensive: cleans up the bad schedule even if it was created in the past or by another path. Complements Option A (e.g. A prevents new wrong schedules; B cleans up existing ones). + +**Cons:** That single `reminderId` is shared with **Daily Reminder**. If the user has **Daily Reminder** on and **New Activity** on, then turns only **New Activity** off, we must not cancel the reminder they still want for Daily Reminder. So either: +- Only call `cancelDailyNotification()` when we’re sure the single reminder was created for New Activity (e.g. we don’t have a separate “New Activity reminder ID”), which is hard without more state, or +- Don’t use Option B alone as the primary fix: use Option A so we never create the single reminder for New Activity, and only add B if we decide we need a one-time cleanup or a safety net (with care not to cancel Daily Reminder’s schedule). + +**Recommendation:** Use Option A as the main fix. Add Option B only if the team agrees we need to cancel the single reminder on “New Activity off” and can do so without affecting Daily Reminder (e.g. by introducing a distinct reminder ID for a “New Activity legacy” reminder and only cancelling that, or by documenting that B is a one-time migration and not long-term behavior). + +### 6.3 Optional cleanup: Separate reminder IDs or dialog responsibilities + +- **Separate reminder IDs:** Today both Daily Reminder and the mistaken New Activity single reminder use `"daily_timesafari_reminder"`. If we ever want to support “both features on” and cancel only one, we’d need a second ID (e.g. one for Daily Reminder, one for New Activity). With Option A in place, New Activity no longer creates a single reminder, so we might not need a second ID unless we add a dedicated “New Activity fallback” single alarm later. +- **Dialog responsibilities:** We could narrow the dialog’s role when used for New Activity on native to “collect time + request permission and report success,” and leave all scheduling to AccountViewView. That’s what Option A does without necessarily refactoring the rest of the dialog (e.g. web push, Daily Reminder) in the same change. +- **Removing web-push logic for New Activity:** If the team decides to “totally remove” web-push logic that was added for New Activity, that would be a separate change (e.g. ensure New Activity on web either uses a different mechanism or is explicitly unsupported). The lay-of-the-land and this fix section focus on native; web can be scoped in a follow-up. + +--- + +## 7. Testing New Activity on a Real Device (iOS or Android) + +Use this section to verify the New Activity flow end-to-end on a physical device after implementing the fix (or to reproduce the current bug). + +### Prerequisites + +- **Build:** Native app built and installed (e.g. `npx cap sync` then build/run from Xcode or Android Studio), or a dev build on device. +- **Identity:** User is signed in (active DID set) so `configureNativeFetcherIfReady` and the native fetcher can use a valid JWT. +- **Endorser API URL:** New Activity prefetch uses **Account → API Server URL** (the Endorser base URL passed to `configureNativeFetcher`), not the Partner API URL. You can run these tests against **production, test, or local Endorser** (e.g. the test preset `https://test-api.endorser.ch`); use an identity, JWT, and starred plans that exist on **that** server. Changing only **Partner API** URL does not change where `plansLastUpdatedBetween` is called. +- **Optional:** One or more starred plans so the API can return activity; with zero starred plans the notification should still show with a sensible fallback (e.g. “No updates in your starred projects”). + +### Enable flow + +1. Open **Account** (Profile). +2. In the **Notifications** section, turn **New Activity Notification** on. +3. In the dialog, choose a time. For quick testing, set the device clock or pick a time **2–5 minutes from now** (e.g. if it’s 14:00, choose 14:03). +4. Tap **Turn on Daily Reminder** (or equivalent), grant notification permission when the OS prompts, and confirm the dialog closes and the toggle shows on with the chosen time. +5. **Background the app** (home or switch to another app). The prefetch runs ~5 minutes before the chosen time; the user notification fires at the chosen time. + +### What to verify (after fix) + +- **One notification** at the chosen time, with content from the API or the fallback text (e.g. “Check your starred projects and offers for updates.”). You should **not** see a second, static “Daily Check-In” / “Time to check your TimeSafari activity” notification from the old single-reminder path. +- **Before the fix:** You may see two notifications (one static from the mistaken single schedule, one from the dual schedule), and turning New Activity off will only stop the dual one; the static one will keep firing. + +### Disable flow + +1. On **Account**, turn **New Activity Notification** off and confirm in the “turn off” dialog. +2. Wait until the next occurrence of the previously chosen time (or use the same “time a few minutes ahead” trick and wait). **No notification** should appear. If one still appears, the single reminder was not cancelled (current bug or Option B not applied correctly). + +### Device-specific notes + +- **Android:** This app has **exact alarm disabled** (no `SCHEDULE_EXACT_ALARM`). Notification permission must be granted; delivery may be inexact or batched by the system. If the app is killed by the OS, behavior may depend on plugin boot/recovery behavior. +- **iOS:** Notification permission and background capabilities (e.g. background fetch) may affect prefetch. Test with app in background, not force-quit. +- **Time zone:** The chosen time is in the device’s local time. Ensure the device date/time and time zone are correct when testing. + +### Optional test cases + +- **No starred plans:** Enable New Activity with no starred projects; confirm no crash and a sensible fallback message in the notification. +- **JWT / API errors:** After leaving the app in background for a long time, the JWT may expire. Re-opening Account (or app) may re-run `configureNativeFetcherIfReady`; document or test whether a new notification still gets valid content or shows fallback. +- **Daily Reminder and New Activity both on:** With the fix, turning off only New Activity should not affect the Daily Reminder notification (they use different plugin APIs; Option B must not cancel the single reminder if the user still has Daily Reminder on). + +### Testing: starred project with new activity (Android native fetcher) + +Use this to verify that when a **starred** plan has **new** activity reported by `plansLastUpdatedBetween`, the notification shows API-derived copy (not only the dual-schedule default from `dualScheduleConfig.ts`). + +The steps and expected notification copy below are **Android-specific**: this repo registers `TimeSafariNativeFetcher` only on Android today. Do not assume the same strings or behavior on iOS until native fetcher parity exists; see **`doc/notification-from-api-call.md`** (iOS checklist and remaining tasks). + +**How it works (short):** On Android, `TimeSafariNativeFetcher` POSTs to `/api/v2/report/plansLastUpdatedBetween` with `planIds` from the plugin (`updateStarredPlans`) and `afterId` from stored `last_acked_jwt_id` (or `"0"` initially). When the response `data` array is **non-empty**, the fetcher builds **one** `NotificationContent`: title **Starred Project Update** (one row) or **Starred Project Updates** (two or more rows); body uses each row’s `plan.name` when present (else **Unnamed Project**). For a single update: `[name] has been updated.` For multiple: typographic quotes around the first row’s name, then ` + N more have been updated.` (with `N` = number of additional rows). When `data` is **empty**, the fetcher returns **no** notification items (no “nothing to report” notification). (See `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`.) + +**Procedure (repeatable on device)** + +1. Sign in on the Endorser environment you mean to test (e.g. test API URL in Account—see **Prerequisites**, Endorser API URL) so `configureNativeFetcherIfReady` can set JWT and `activeDid`. +2. Star at least one project you can change (e.g. your own test plan on staging). +3. Turn **New Activity Notification** on and pick a time **2–5 minutes ahead** (same quick-test pattern as above). +4. Open **Account** once (or finish the enable flow) so `updateStarredPlans({ planIds })` runs with current `starredPlanHandleIds`. +5. **Background the app** (home out; do not force-quit). Prefetch runs on the cron **~5 minutes before** the chosen time; the user notification fires at the chosen time. +6. **Produce new activity the API will return:** before that prefetch window (i.e. early enough that the scheduled content fetch still sees it), make a real change to the starred plan so `plansLastUpdatedBetween` returns **new** rows after the current `afterId` (e.g. an edit or other update your backend exposes through that report). If you change the plan **after** prefetch already ran with no new rows, you may not get an API-derived notification until the next prefetch cycle (typically the next day at the same T−5 schedule, unless you reschedule). + +**What to verify** + +- **One notification** at the chosen time (no extra static “Daily Check-In” after the fix—see “What to verify (after fix)” above). +- **Success path (API returns updates):** Title/body match **Starred Project Update(s)** and the `[name] has been updated.` / `[first name] + N more have been updated.` patterns (names from `plan.name`), not the generic `buildDualScheduleConfig` defaults (**New Activity** / **Check your starred projects and offers for updates.**), which apply when the plugin falls back—e.g. fetch failure—not when the Android fetcher successfully returns Endorser-parsed content. +- **Contrast (cursor caught up, no new rows):** After a successful fetch that returned data, `last_acked_jwt_id` advances. Without further plan changes, a later prefetch may return an empty `data` array; the fetcher then supplies **no** Endorser-derived notification (useful to compare against the “has activity” case; the plugin may still show dual-schedule fallback text depending on configuration). + +**Repeatability:** Each successful fetch that returns data moves the `afterId` cursor forward. To see **Starred Project Update** copy again on subsequent tests, make **another** qualifying plan change (or accept heavier setup such as clearing app/plugin storage to reset cursor—usually unnecessary). + +**Debugging:** On Android, filter **logcat** for `TimeSafariNativeFetcher` (e.g. HTTP 200, `Fetched N notification(s)`) to confirm prefetch ran and how many `NotificationContent` items were built. + +**Note:** The in-app **New Activity** screen loads starred changes via the JS stack; the **push** path uses the native fetcher and plugin cache. Validate the notification using **background + prefetch timing**, not only by opening that screen. + +--- + +## 8. Plugin Repo Alignment and Attention Items + +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 + +- **API surface:** Plugin `definitions.ts` exposes `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })`, `scheduleDualNotification(config)`, `cancelDualSchedule()`, `updateStarredPlans({ planIds })`, `scheduleDailyNotification(options)`, and `cancelDailyReminder(reminderId)`. Our app uses these as described in this doc; `buildDualScheduleConfig` produces a `DualScheduleConfiguration` that matches the plugin’s `ContentFetchConfig` / `UserNotificationConfig` / `relationship` shape (cron schedules, title/body, `callbacks: {}`, `fallbackBehavior: "show_default"`, etc.). +- **Native fetcher:** Plugin is designed for a host-supplied JWT via `configureNativeFetcher` and a native fetcher implementation (e.g. Android `TimeSafariNativeFetcher`). Our `nativeFetcherConfig.ts` and Android `TimeSafariNativeFetcher.java` follow that model; prefetch runs in the plugin’s background workers and uses the configured credentials. +- **Dual vs single:** The plugin clearly separates: + - **Single daily path:** `scheduleDailyNotification(options)` (with `id` on Android) and `cancelDailyReminder(reminderId)` (iOS uses `reminder_` for the static-reminder path). + - **Dual path:** `scheduleDualNotification(config)` and `cancelDualSchedule()`. + So our analysis that “two schedules” are created when the dialog schedules the single reminder and AccountViewView schedules the dual is consistent with the plugin. +- **Exact alarm:** The plugin’s Android implementation does **not** require exact alarm: it proceeds with scheduling using inexact/windowed alarms when exact is not granted. The plugin’s `INTEGRATION_GUIDE.md` still shows `SCHEDULE_EXACT_ALARM` in the manifest example; this app has chosen to disable exact alarm, and the plugin supports that. No doc change needed beyond what we already state in section 7. + +### 8.2 Attention items + +- **`cancelDailyReminder` signature:** In the plugin’s `definitions.ts`, `cancelDailyReminder(reminderId: string)`. The app calls it with an object: `cancelDailyReminder({ reminderId })`. On iOS the plugin uses `call.getString("reminderId")`, so the object form works. If the plugin’s TypeScript definition is ever used for strict typing, prefer updating the plugin to accept `{ reminderId: string }` or document that the bridge accepts an object with a `reminderId` key. +- **Plugin INTEGRATION_GUIDE vs this app:** The guide describes generic polling, dual scheduling, and optional `SCHEDULE_EXACT_ALARM`. This app uses the dual-schedule + native-fetcher path only (no generic polling), and does not use exact alarm. When onboarding or debugging, treat the guide as the full plugin feature set; our flow is the “legacy dual scheduling” + native fetcher part plus `updateStarredPlans` and `configureNativeFetcher`. +- **iOS `scheduleDailyNotification` and stable `id`:** On **Android**, the plugin uses `options.getString("id")` as the stable `scheduleId` for “one per day” semantics and cleanup. On **iOS**, the implementation in the repo was observed to build notification content with an internally generated id (e.g. `daily_`) and not obviously use the app-provided `id` from the call. If the app ever relies on a stable id on iOS for the single reminder (e.g. to cancel or replace only that reminder), it’s worth confirming in the plugin’s iOS code whether the call’s `id` is read and used; if not, consider requesting or contributing a change so iOS also uses the app-provided id for consistency with Android. +- **Dual schedule and content fetch:** The plugin’s dual schedule runs the content-fetch job on its cron and then the user notification at the configured time; our config uses a 5-minute gap and `relationship.contentTimeout` / `fallbackBehavior: "show_default"`. The native fetcher is invoked by the plugin’s background layer when the content-fetch schedule fires; we don’t rely on JS `callbacks` in the config (we pass `callbacks: {}`). That matches the “native fetcher does the work” design. + +### 8.3 iOS `UNIMPLEMENTED` on `scheduleDualNotification` (other methods work) + +If iOS logs `scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}` while `configureNativeFetcher` succeeds, Capacitor is often rejecting the call in **JavaScript** because `scheduleDualNotification` is missing from `window.Capacitor.PluginHeaders` for `DailyNotification` (stale **Pods / Xcode binary** after upgrading the plugin). **Not** usually a missing Swift handler if `node_modules` already lists the method in `pluginMethods`. + +**Recovery:** `npx cap sync ios`, `cd ios/App && pod install`, Xcode **Clean Build Folder**, rebuild. See **`doc/plugin-feedback-ios-scheduleDualNotification.md`** (troubleshooting section). + +### 8.4 Summary + +The plugin repo aligns with how we use it for New Activity (dual schedule + native fetcher, no generic polling, exact alarm optional). The main follow-ups are: (1) clarify or align `cancelDailyReminder` argument shape in the plugin if needed for typing/tooling, and (2) confirm on iOS whether `scheduleDailyNotification` uses the app-provided `id` for stable single-reminder semantics. diff --git a/doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md b/doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md new file mode 100644 index 00000000..e1845266 --- /dev/null +++ b/doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md @@ -0,0 +1,126 @@ +# Plugin feedback: Android dual schedule — native fetcher not used; fetch timing wrong + +**Date:** 2026-03-24 21:56 PST +**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) +**Consuming app:** crowd-funder-for-time-pwa (TimeSafari) +**Platform:** Android (Kotlin / Java) +**Related:** New Activity notifications (`scheduleDualNotification` / `cancelDualSchedule`) + +--- + +## Summary + +On Android, the **dual (New Activity) schedule** path is **not** implementing the intended contract: + +1. **Prefetch does not call `NativeNotificationContentFetcher`.** + `ScheduleHelper.scheduleDualNotification` delegates fetch to `FetchWorker` (HTTP GET to optional `url`, or **mock JSON** when `url` is absent). The host app’s `TimeSafariNativeFetcher` is **never** invoked. Logcat shows `DNP-FETCH: Starting content fetch from: null, notificationTime=0` and **no** `TimeSafariNativeFetcher` `fetchContent` lines. + +2. **Fetch is not scheduled at `contentFetch.schedule` (e.g. T−5 minutes).** + `FetchWorker.enqueueFetch` enqueues **immediate** `OneTimeWorkRequest` work (no `setInitialDelay` aligned to the fetch cron). The **notify** alarm is scheduled correctly for `dual_notify_*`, but there is **no** corresponding alarm/work at the **fetch** cron time. A `dual_fetch_*` row may exist in the DB with `nextRunAt`, but the **actual** fetch runs at **enable/setup time**, not at T−5. + +3. **Cache vs `DualScheduleHelper` / `contentTimeout`.** + `DualScheduleHelper.resolveDualContentBlocking` only uses `contentCache` when the latest fetch is within `relationship.contentTimeout` (e.g. 5 minutes). If fetch runs **once at setup** and notify fires **~9+ minutes later**, cache is **stale** → `useCache=false` → default title/body from `userNotification`, even when mock payload was stored. + +**Recommended direction (plugin):** + +- For dual schedule when **no HTTP `url`** is configured (or when a flag indicates native mode), run **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** (same path as `DailyNotificationFetchWorker` uses), persist results into the same `contentCache` / pipeline `DualScheduleHelper` expects. +- **Schedule** that work (or an alarm that enqueues it) **at** `calculateNextRunTime(contentFetch.schedule)` — i.e. **before** the notify alarm, typically **5 minutes** earlier per app cron (see consuming app `timeToCronFiveMinutesBefore`). +- Optionally align **one** scheduling mechanism: either exact alarm for fetch + notify, or WorkManager with **initial delay** to the next fetch instant (and reschedule after run). + +--- + +## Symptoms (consuming app + logcat) + +- Notification shows **default** copy from `userNotification` (`title` / `body` from `buildDualScheduleConfig`), not API-derived or native “No updates” copy. +- Logcat: `DNP-DUAL: Resolved dual content: useCache=false` at notify time. +- Logcat: `DNP-FETCH: Starting content fetch from: null, notificationTime=0` followed by `Content fetch completed successfully` **at schedule/setup time**, not at T−5. +- **No** `TimeSafariNativeFetcher` `fetchContent START` / `POST …/plansLastUpdatedBetween` during prefetch window (host registers `NativeNotificationContentFetcher` and logs on configure + fetch). +- **No** activity at the **prefetch cron** time (e.g. 19:05 for notify at 19:10); only **notify** fires at T. + +--- + +## What the consuming app sends (contract) + +**File:** `src/services/notifications/dualScheduleConfig.ts` + +- `contentFetch.enabled: true` +- `contentFetch.schedule`: cron **5 minutes before** `userNotification.schedule` (e.g. `"25 19 * * *"` for notify `"30 19 * * *"`). +- **No** `contentFetch.url` — intended to use **native** Endorser API via `configureNativeFetcher` + `NativeNotificationContentFetcher`. +- `relationship.autoLink: true`, `relationship.contentTimeout: 5 * 60 * 1000`, `fallbackBehavior: "show_default"`. + +**Host app:** `android/.../TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher` and calls `POST /api/v2/report/plansLastUpdatedBetween` with starred plan IDs from `updateStarredPlans`. + +--- + +## Root cause (plugin code — paths to review) + +These paths are from a local clone of **daily-notification-plugin**; line numbers may drift. + +### 1. `FetchWorker` is URL/mock-only; does not call native fetcher + +`android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt` + +- `enqueueFetch` passes `config.url` into `InputData`; `doWork` logs `Starting content fetch from: $url`. +- `fetchContent(url, …)` when `url` is null/blank returns **`generateMockContent()`** — never calls `DailyNotificationPlugin.getNativeFetcherStatic().fetchContent(...)`. + +### 2. `scheduleDualNotification` runs fetch work immediately, not at fetch cron + +`android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt` — `object ScheduleHelper`, `suspend fun scheduleDualNotification(...)` + +- Calls `scheduleFetch(context, contentFetchConfig)` which resolves to `FetchWorker.scheduleFetchForDual` → `enqueueFetch` **without** delay tied to `contentFetchConfig.schedule`. +- Schedules **notify** via `NotifyReceiver.scheduleExactNotification` for `dual_notify_*` at `calculateNextRunTime(userNotificationConfig.schedule)`. +- Persists `dual_fetch_*` with `nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)` but **no** matching alarm/work is scheduled for that instant in the current flow (as observed). + +### 3. Native fetcher exists elsewhere + +`android/src/main/java/org/timesafari/dailynotification/DailyNotificationFetchWorker.java` + +- Contains logic to call `NativeNotificationContentFetcher.fetchContent(FetchContext)` (with timeout). Dual schedule **does not** enqueue this worker for the TimeSafari `contentFetch` payload. + +### 4. `DualScheduleHelper` behavior is consistent with “wrong fetch time” + +`android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt` + +- Uses latest `contentCache` only if `(now - fetchedAt) <= contentTimeoutMs`. If fetch ran at setup and notify is **later** than `contentTimeout`, **cache is ignored** → `useCache=false` in logs. + +--- + +## Acceptance criteria (plugin) + +After a fix, on a device with: + +- `configureNativeFetcher` + `updateStarredPlans` called (host app), +- `scheduleDualNotification` with `contentFetch.enabled: true`, no `url`, cron 5 min before notify, + +then: + +1. **At or before** the notify fire time, **within** `contentTimeout`, the cache used by `DualScheduleHelper` reflects **native** fetch results when the API returns data (or empty), not only mock JSON. +2. Logcat **includes** host tag `TimeSafariNativeFetcher` with `fetchContent START` (or equivalent) **when** prefetch runs, **or** plugin logs an explicit `NativeNotificationContentFetcher` invocation. +3. Prefetch **does not** run only at **INITIAL_SETUP**; it runs at the **next** occurrence of `contentFetch.schedule` (and reschedules for the following day after success, same as notify rollover). +4. **Optional:** If `url` is set, preserve HTTP GET behavior; if `url` is absent and native fetcher is registered, use native path. + +--- + +## References in consuming app + +| Topic | Location | +|--------|----------| +| Dual config builder | `src/services/notifications/dualScheduleConfig.ts` | +| `scheduleDualNotification` call | `src/views/AccountViewView.vue` (`scheduleNewActivityDualNotification`, `editNewActivityNotification`) | +| Native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | +| Registration | `MainActivity` / plugin init (host registers `DailyNotificationPlugin.setNativeFetcher`) | + +--- + +## Notes for Cursor / implementers + +- **Do not** assume `contentFetch.url` is present; TimeSafari intentionally omits it for native API. +- **Reuse** the same `FetchContext` / timeout semantics as `DailyNotificationFetchWorker` where possible to avoid two divergent native fetch implementations. +- After changing timing, **verify** `WorkManager` unique work name `fetch_dual` / `cancelDualSchedule` still cancel only dual fetch and do not break daily reminder. + +--- + +## Related docs in this repo + +- `doc/notification-from-api-call.md` — integration plan for API-driven New Activity. +- `doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md` — optional `timeout` / `retry*` JSON parsing (already addressed on the plugin side). diff --git a/doc/plugin-feedback-android-duplicate-reminder-notification.md b/doc/plugin-feedback-android-duplicate-reminder-notification.md index c03cf509..ab8397bf 100644 --- a/doc/plugin-feedback-android-duplicate-reminder-notification.md +++ b/doc/plugin-feedback-android-duplicate-reminder-notification.md @@ -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 diff --git a/doc/plugin-feedback-android-parseUserNotificationConfig-optional-fields.md b/doc/plugin-feedback-android-parseUserNotificationConfig-optional-fields.md new file mode 100644 index 00000000..3afcff18 --- /dev/null +++ b/doc/plugin-feedback-android-parseUserNotificationConfig-optional-fields.md @@ -0,0 +1,99 @@ +# Plugin feedback: Android `parseUserNotificationConfig` — optional fields vs `getBoolean` / `getString` + +**Date:** 2026-03-20 21:11 PST +**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) +**Consuming app:** crowd-funder-for-time-pwa (TimeSafari) +**Platform:** Android (Kotlin) +**Related:** Same class of issue as [plugin-feedback-android-scheduleDualNotification-contentFetch-json.md](./plugin-feedback-android-scheduleDualNotification-contentFetch-json.md) (`contentFetch` / `parseContentFetchConfig`). + +--- + +## Summary + +`DailyNotificationPlugin.parseUserNotificationConfig()` uses **`JSObject` / `JSONObject` strict getters** for fields that the published TypeScript **`UserNotificationConfig`** marks as **optional** (`sound?`, `vibration?`, `priority?`, `title?`, `body?`). If a key is omitted, Android throws **`JSONException`** (e.g. *No value for vibration*), and `scheduleDualNotification` fails before scheduling. + +**Recommended direction (plugin):** Align Kotlin parsing with `dist/esm/definitions.d.ts` by using **optional reads + defaults**, consistent with the fix already applied for `parseContentFetchConfig` (e.g. `optIntOrNull`, or Capacitor/JSON equivalents for booleans and strings). + +**Recommended direction (app / already done in TimeSafari):** Send explicit `sound`, `vibration`, and `priority` (and title/body) in `buildDualScheduleConfig()` so **older plugin builds** that still use strict getters continue to work. + +**Does it make sense to change both sides?** **Yes** — same reasoning as for `contentFetch`: the plugin should match its public contract; the app can stay explicit for compatibility and clarity. + +--- + +## Symptoms (consuming app) + +- In-app toast: *“Could not schedule New Activity notification. Please try again.”* (generic catch after `scheduleDualNotification` rejects.) +- Logcat: + +```text +E DNP-PLUGIN: Schedule dual notification error +E DNP-PLUGIN: org.json.JSONException: No value for vibration +E DNP-PLUGIN: at org.json.JSONObject.getBoolean(JSONObject.java:419) +E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.parseUserNotificationConfig(DailyNotificationPlugin.kt:2428) +E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.scheduleDualNotification(DailyNotificationPlugin.kt:1392) +``` + +(First failure observed after `contentFetch` timeouts were fixed was **`vibration`**; the same pattern can affect **`sound`** or **`priority`** if those keys are omitted.) + +--- + +## Root cause + +### Published TypeScript contract (`UserNotificationConfig`) + +From `definitions.d.ts` (representative): + +- `title?`, `body?`, `sound?`, `vibration?`, `priority?` — all optional. + +### Current Android implementation (strict) + +In `DailyNotificationPlugin.kt`, `parseUserNotificationConfig` (line numbers approximate; search for `parseUserNotificationConfig`): + +```kotlin +private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig { + return UserNotificationConfig( + enabled = configJson.getBoolean("enabled") ?: true, + schedule = configJson.getString("schedule") ?: "0 9 * * *", + title = configJson.getString("title"), + body = configJson.getString("body"), + sound = configJson.getBoolean("sound"), + vibration = configJson.getBoolean("vibration"), + priority = configJson.getString("priority") + ) +} +``` + +- **`getBoolean("vibration")`** (and **`getBoolean("sound")`**) throw if the key is **missing** — optional in TS, required at runtime on Android. +- **`getString("title")`**, **`getString("body")`**, **`getString("priority")`** likewise throw if missing (depending on `JSObject` / `JSONObject` behavior for absent keys). + +So minimal or TS-faithful payloads omit `vibration` → immediate `JSONException`. + +--- + +## Plugin-side recommendations + +1. **Treat `UserNotificationConfig` optional fields as optional on Android**, mirroring `definitions.d.ts`: + - **`vibration`:** e.g. `optBoolean` / nullable + default **`true`** (or `false` if that matches product default — document the default). + - **`sound`:** same pattern; default **`true`** is typical for notifications. + - **`priority`:** optional string with default **`"normal"`** (or map from TS union). + - **`title` / `body`:** if TS allows omission, use optional reads + defaults consistent with dual-schedule UX (or reject with a clear `call.reject` message instead of a raw `JSONException`). + +2. **Reuse the same helper style** as `parseContentFetchConfig` after the timeout fix (`optIntOrNull`, etc.) so one codebase convention applies to all dual-schedule JSON parsing. + +3. **Tests:** Unit or integration test that calls `scheduleDualNotification` with a **minimal** `userNotification` object (only what TS strictly requires, if anything) and asserts scheduling succeeds on Android. + +4. **iOS parity:** If iOS already accepts omitted `vibration` / `sound`, Android should match; if not, align both platforms to the same `UserNotificationConfig` rules. + +--- + +## App-side note (TimeSafari) + +`src/services/notifications/dualScheduleConfig.ts` — `buildDualScheduleConfig()` now includes **`vibration: true`** (with `sound: true`) so current native code paths succeed. Keeping this explicit is still recommended even after the plugin is fixed. + +--- + +## References + +- Plugin: `android/.../DailyNotificationPlugin.kt` — `parseUserNotificationConfig` +- TS: `dist/esm/definitions.d.ts` — `UserNotificationConfig`, `DualScheduleConfiguration` +- App: `src/services/notifications/dualScheduleConfig.ts` — `buildDualScheduleConfig` diff --git a/doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md b/doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md new file mode 100644 index 00000000..c9646859 --- /dev/null +++ b/doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md @@ -0,0 +1,117 @@ +# Plugin feedback: Android `scheduleDualNotification` — `JSONException: No value for timeout` + +**Date:** 2026-03-20 18:21 PST +**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) +**Consuming app:** crowd-funder-for-time-pwa (TimeSafari) +**Platform:** Android (Kotlin) +**Plugin version observed:** 2.1.2 (from app `node_modules`) + +--- + +## Summary + +Scheduling the **New Activity** dual notification on Android fails with a native `JSONException` because `DailyNotificationPlugin.parseContentFetchConfig()` uses **`JSONObject.getInt()`** for `timeout`, `retryAttempts`, and `retryDelay`. Those keys are **absent** from the app’s `contentFetch` object built by `buildDualScheduleConfig()`. The plugin’s own TypeScript `ContentFetchConfig` marks those fields as **optional**, so the Android parser is stricter than the published contract. + +**Recommended direction:** + +1. **Plugin (primary):** Parse optional numeric fields with defaults (e.g. `optInt` / nullable + defaults) so payloads that omit them do not crash and match `definitions.d.ts`. +2. **App (secondary / compatibility):** Include explicit `timeout`, `retryAttempts`, and `retryDelay` on `contentFetch` so older plugin versions that still use `getInt` continue to work. + +**Does it make sense to change both sides?** **Yes.** Fixing the plugin aligns behavior with the documented API and protects any consumer that omits those fields. Fixing the app is still valuable for **older shipped plugin builds** and makes network behavior explicit. Together you get backward compatibility, clearer intent, and no silent reliance on undocumented defaults. + +--- + +## Symptoms (consuming app) + +- In-app toast: *“Could not schedule New Activity notification. Please try again.”* (generic error path after `scheduleDualNotification` rejects.) +- Logcat (filtered on DNP / plugin tags): + +```text +E DNP-PLUGIN: Schedule dual notification error +E DNP-PLUGIN: org.json.JSONException: No value for timeout +E DNP-PLUGIN: at org.json.JSONObject.getInt(JSONObject.java:487) +E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.parseContentFetchConfig(DailyNotificationPlugin.kt:2403) +E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.scheduleDualNotification(DailyNotificationPlugin.kt:1391) +``` + +--- + +## Root cause + +### Call path + +`scheduleDualNotification` reads `config.contentFetch` and passes it to `parseContentFetchConfig`: + +- File: `android/.../DailyNotificationPlugin.kt` +- `scheduleDualNotification` ~1391: `parseContentFetchConfig(contentFetchObj)` +- `parseContentFetchConfig` ~2397–2411: uses `getInt` for three keys. + +### Strict Android parsing + +Illustrative (exact line numbers may shift between releases): + +```kotlin +// parseContentFetchConfig — timeout / retry fields are required via getInt() +timeout = configJson.getInt("timeout"), +retryAttempts = configJson.getInt("retryAttempts"), +retryDelay = configJson.getInt("retryDelay"), +``` + +`getInt` throws if the key is missing → first missing key in practice is `timeout` → `JSONException: No value for timeout`. + +### App payload today (consuming app) + +File: `src/services/notifications/dualScheduleConfig.ts` — `buildDualScheduleConfig()` sets `contentFetch` to: + +- `enabled`, `schedule`, `callbacks` only (no `timeout`, `retryAttempts`, `retryDelay`, no `url`). + +That matches the **TypeScript** contract in the plugin’s `dist/esm/definitions.d.ts`, where `timeout`, `retryAttempts`, and `retryDelay` are **optional** on `ContentFetchConfig`. + +### Contract mismatch + +| Layer | `timeout` / `retryAttempts` / `retryDelay` | +|--------|--------------------------------------------| +| TS `ContentFetchConfig` | Optional (`?`) | +| Android `parseContentFetchConfig` | Required (`getInt` — throws if absent) | + +The consuming app followed the TS API; Android rejected it at runtime. + +--- + +## Plugin-side recommendations + +1. **Use optional reads with defaults** for `timeout`, `retryAttempts`, and `retryDelay` (and any similar fields), e.g. Kotlin/Capacitor equivalents of `optInt` or `getInteger` with fallbacks documented in `ContentFetchConfig`. +2. **Document defaults** in the plugin README or API docs if they are applied on native when omitted. +3. **Consider tests** that call `scheduleDualNotification` with a minimal `contentFetch` (only `enabled`, `schedule`, `callbacks`) and assert scheduling succeeds on Android. +4. **Optional:** If `url` is also read in a way that assumes presence, align with TS (`url?`) the same way. + +--- + +## App-side recommendations (later; crowd-funder-for-time-pwa) + +When you implement the app fix: + +- Extend `contentFetch` in `buildDualScheduleConfig()` (`src/services/notifications/dualScheduleConfig.ts`) to include explicit integers, for example aligned with existing app/network conventions (the app’s `capacitor.config.ts` already uses a `timeout` value in one place — reuse or document chosen values). +- Ensure **both** code paths that build dual config stay in sync (e.g. `AccountViewView.vue` uses `buildDualScheduleConfig` for New Activity scheduling and for `updateDualScheduleConfig` fallback). + +This unblocks users on **current** plugin versions that still require those keys. + +--- + +## References (paths in consuming app workspace) + +- App config builder: `src/services/notifications/dualScheduleConfig.ts` +- Native scheduling entry: `node_modules/@timesafari/daily-notification-plugin/android/.../DailyNotificationPlugin.kt` (`scheduleDualNotification`, `parseContentFetchConfig`) + +--- + +## Answer: change both plugin and app? + +**Yes, it makes sense to change both**, for different reasons: + +| Side | Why | +|------|-----| +| **Plugin** | Fixes the real bug: native behavior must match the published optional TS fields; avoids breaking any client that sends a minimal `contentFetch`. | +| **App** | Defense in depth and support for **already-shipped** plugin binaries that will not get the Kotlin fix until users update the app. Explicit values also document intended fetch/retry behavior in one place. | + +If you only fix the plugin, new app releases still need users to update the **native** binary. If you only fix the app, any other consumer of the plugin or future minimal payloads can hit the same crash until the plugin is fixed. diff --git a/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md b/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md new file mode 100644 index 00000000..fa7f2357 --- /dev/null +++ b/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md @@ -0,0 +1,95 @@ +# Plugin feedback: `configureNativeFetcher` — optional JWT pool for background API calls + +**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/background-jwt-pool.md` + +--- + +## Summary + +The host app’s **`NativeNotificationContentFetcher`** (`TimeSafariNativeFetcher` on Android) calls Endorser with a Bearer JWT set via **`configureNativeFetcher`**. For **background** prefetch, the token must stay valid until WorkManager runs (often **minutes later**); Endorser may also reject **duplicate** JWT strings across days. + +The **app** will mint a **pool** of distinct JWTs (see app plan) and needs the plugin to **accept and persist** that pool so native code can select a token **without JavaScript** at prefetch time. + +**Requested change (plugin):** extend **`configureNativeFetcher`** to accept an optional **JWT pool** alongside the existing **`jwtToken`**, persist it in the same storage the host already relies on (e.g. SharedPreferences / app group), and document how **`NativeNotificationContentFetcher`** implementations should read it. + +--- + +## Motivation + +| Issue | Why plugin support helps | +|-------|---------------------------| +| Single short-lived `jwtToken` | Expires before background fetch | +| Server duplicate-JWT rules | Need many distinct bearer strings over time | +| No JS in WorkManager | Pool must be readable **only** from native | + +--- + +## Proposed API (TypeScript / Capacitor) + +**Extend** existing `configureNativeFetcher` options (names indicative — align with plugin naming conventions): + +```ts +configureNativeFetcher(options: { + apiBaseUrl: string; + activeDid: string; + /** Primary token; keep for backward compatibility and Phase A (single long-lived JWT). */ + jwtToken: string; + /** + * Optional. Distinct JWT strings for background use (e.g. one per day slot). + * If omitted, behavior matches today (single jwtToken only). + */ + jwtTokens?: string[]; +}); +``` + +**Alternatives** (if size limits matter for bridge payload): + +- `jwtTokenPoolJson: string` — JSON array string of JWT strings (single string across the bridge). + +**Validation (plugin):** + +- If `jwtTokens` present: length **≤** a sane cap (host will use ~100; plugin may enforce max e.g. 128). +- Empty array: treat as “no pool” (same as omitting). + +--- + +## Android + +1. **Parse** new fields in `DailyNotificationPlugin.configureNativeFetcher` (or equivalent). +2. **Persist** pool under the same prefs namespace used for other TimeSafari / dual-schedule data, or a **documented** key prefix (e.g. `jwt_token_pool` as JSON array string). +3. **Document** for host implementers: `NativeNotificationContentFetcher` should: + - Prefer **pool entry** for `fetchContent` when pool is non-empty (selection policy is **host** responsibility — e.g. day index % length), **or** + - Expose a small helper the host fetcher calls to resolve “current” bearer. +4. **Clear** pool when `configureNativeFetcher` is called with a new identity / empty pool / logout path (coordinate with host). +5. **Backward compatibility:** if only `jwtToken` is sent, behavior **unchanged** from current release. + +--- + +## iOS + +When `configureNativeFetcher` exists on iOS, mirror Android: accept optional pool, persist, document read path for native fetcher. + +--- + +## Versioning & release + +- Bump **plugin semver** (minor: new optional fields). +- Publish package; consuming app bumps **`@timesafari/daily-notification-plugin`** and updates `nativeFetcherConfig.ts` to pass `jwtTokens` when Phase B ships. + +--- + +## References (host app) + +| Topic | Location | +|--------|----------| +| 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` | + +--- + +*This document is intended to be copied or linked from PRs in **daily-notification-plugin**; keep app-specific details in the app plan.* diff --git a/doc/plugin-feedback-ios-scheduleDualNotification.md b/doc/plugin-feedback-ios-scheduleDualNotification.md new file mode 100644 index 00000000..beff9848 --- /dev/null +++ b/doc/plugin-feedback-ios-scheduleDualNotification.md @@ -0,0 +1,140 @@ +# Plugin Feedback: Implement scheduleDualNotification on iOS + +**Target repo:** daily-notification-plugin (iOS native layer) +**Purpose:** Document for implementing or fixing `scheduleDualNotification` on iOS so the consuming app (TimeSafari / crowd-funder) can enable “New Activity” notifications. +**Consuming app doc:** `doc/notification-new-activity-lay-of-the-land.md` + +--- + +## Troubleshooting: `UNIMPLEMENTED` on iOS (Capacitor 6) + +If **`configureNativeFetcher`** (or other DailyNotification methods) work but **`scheduleDualNotification`** still fails with **`{"code":"UNIMPLEMENTED"}`** and you **do not** see a native log line like `To Native -> DailyNotification scheduleDualNotification`, the failure is often **not** missing Swift code—it is **Capacitor’s JavaScript layer** rejecting the call because the method is **not listed** in `window.Capacitor.PluginHeaders` for `DailyNotification`. Those headers are built at runtime from the **compiled** plugin’s `pluginMethods` list (`CAPBridgedPlugin`). + +**Fix in the consuming app (usual cause: stale Pods / binary):** + +1. Ensure `node_modules/@timesafari/daily-notification-plugin` includes `scheduleDualNotification` in `DailyNotificationPlugin.swift`’s `pluginMethods` (v2.1.0+). +2. From the project root: `npx cap sync ios` +3. `cd ios/App && pod install` (or delete `Pods` + `Podfile.lock` and `pod install` if upgrading the plugin). +4. Xcode: **Product → Clean Build Folder**, then rebuild and run on device/simulator. + +**Verify:** Safari → Develop → attach to the app WebView → Console: inspect `window.Capacitor.PluginHeaders` and confirm the `DailyNotification` entry’s `methods` array includes `{ name: "scheduleDualNotification", ... }`. + +If a full clean rebuild still doesn't fix it, clear Xcode's **system** DerivedData (quit Xcode, run `rm -rf ~/Library/Developer/Xcode/DerivedData/*TimeSafari*`, reopen and rebuild). On launch the app logs `[Capacitor] DNP PluginHeaders methods: [...]`; if that list omits `scheduleDualNotification`, the native binary is still stale. + +If the method **is** present in headers but scheduling still fails, debug the Swift implementation (reject message, BG tasks, etc.). + +### Misleading `UNIMPLEMENTED` before `scheduleDualNotification` + +Capacitor’s `registerPlugin` proxy returns a **callable stub for every property name**. So `if (DailyNotification?.updateStarredPlans)` is **always truthy** even when iOS does not expose `updateStarredPlans` in `pluginMethods`. Calling that stub throws **`UNIMPLEMENTED`** in JS **before** any `To Native -> DailyNotification scheduleDualNotification` line appears—so logs look like “dual schedule is unimplemented” when the real failure was **`updateStarredPlans`**. + +**Consuming-app fix:** treat `updateStarredPlans` as optional: catch `UNIMPLEMENTED` and continue, or only call after verifying the method name exists on `PluginHeaders` for `DailyNotification`. If the plugin adds `updateStarredPlans` natively later, starred-plan filtering will start working without app changes. + +--- + +## Current behavior + +- The **consuming app** calls `DailyNotification.scheduleDualNotification({ config })` from TypeScript when the user turns on “New Activity Notification” and picks a time (native iOS). +- On **iOS**, the plugin rejects with **`code: "UNIMPLEMENTED"`** (observed in Xcode: `[AccountViewView] scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}`). +- On **Android**, the same call is expected to work (dual schedule: content fetch + user notification). + +The app has already: + +- Called `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })` so the plugin can use the native fetcher for API-driven content. +- Called `updateStarredPlans({ planIds })` so the fetcher knows which plans to query. +- Built a `config` object that matches the plugin’s `DualScheduleConfiguration` (see below). + +So the missing piece on iOS is a **working implementation** of `scheduleDualNotification` that accepts this config and schedules the dual flow (content fetch at one time, user notification at a later time). + +--- + +## Call from the consuming app + +```ts +await DailyNotification.scheduleDualNotification({ config }); +``` + +`config` is built by the app’s `buildDualScheduleConfig({ notifyTime })` and has the following shape. + +--- + +## Config shape the app sends + +The app sends a single `config` object that matches the plugin’s `DualScheduleConfiguration` (see `definitions.ts`). Example for `notifyTime: "18:30"` (6:30 PM): + +```json +{ + "contentFetch": { + "enabled": true, + "schedule": "25 18 * * *", + "callbacks": {} + }, + "userNotification": { + "enabled": true, + "schedule": "30 18 * * *", + "title": "New Activity", + "body": "Check your starred projects and offers for updates.", + "sound": true, + "priority": "normal" + }, + "relationship": { + "autoLink": true, + "contentTimeout": 300000, + "fallbackBehavior": "show_default" + } +} +``` + +- **Cron format:** `"minute hour * * *"` (daily at that local time). +- **contentFetch.schedule:** 5 minutes **before** the user’s chosen time (e.g. 18:25 for notify at 18:30). +- **userNotification.schedule:** The user’s chosen time (e.g. 18:30). +- **contentFetch.callbacks:** The app sends `{}`; the actual fetch is done by the **native fetcher** (already configured via `configureNativeFetcher`). The plugin should run the content-fetch job at the contentFetch cron and use the native fetcher to get content; at userNotification time it should show a notification using that content or the fallback title/body. +- **relationship.contentTimeout:** Milliseconds to wait for content before showing the notification (app uses 5 minutes = 300000). +- **relationship.fallbackBehavior:** `"show_default"` means if content isn’t ready in time, show the notification with the default title/body from `userNotification`. + +The app does **not** send `contentFetch.url` or `contentFetch.timesafariConfig`; it relies on the native fetcher and `configureNativeFetcher` / `updateStarredPlans` for API behavior. + +--- + +## Expected plugin behavior (iOS) + +1. **Accept** the `config` argument (object with `contentFetch`, `userNotification`, and optional `relationship`). +2. **Parse** the cron expressions for `contentFetch.schedule` and `userNotification.schedule` (e.g. using a shared cron parser or the same approach as Android). +3. **Schedule** two things: + - **Content fetch:** At the time given by `contentFetch.schedule`, run the **native notification content fetcher** (the one configured via `configureNativeFetcher`). Store the result in the plugin’s cache (or equivalent) for use when the user notification fires. + - **User notification:** At the time given by `userNotification.schedule`, show a local notification. Use cached content from the fetch if available and within `relationship.contentTimeout`; otherwise use `userNotification.title` and `userNotification.body` (per `relationship.fallbackBehavior: "show_default"`). +4. **Do not** reject with `UNIMPLEMENTED`; resolve the promise once scheduling has succeeded (or reject with a descriptive error if scheduling fails). +5. **cancelDualSchedule()** should cancel both the content-fetch schedule and the user-notification schedule so the user can turn off New Activity from the app. + +Alignment with **Android** (if implemented there) is desirable: same config shape, same semantics (prefetch then notify, fallback to default title/body). The plugin’s **definitions.ts** already defines `DualScheduleConfiguration`, `ContentFetchConfig`, `UserNotificationConfig`, and the `scheduleDualNotification` / `cancelDualSchedule` API. + +--- + +## Where to look in the plugin (iOS) + +- **Plugin entry:** `ios/Plugin/DailyNotificationPlugin.swift` (or equivalent)—find the handler for `scheduleDualNotification` (e.g. method that receives `call.getObject("config")`). +- **Android reference:** `android/` implementation of `scheduleDualNotification` and how it schedules WorkManager/alarms for content fetch and for the user notification. +- **Definitions:** `src/definitions.ts` — `DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`. +- **Native fetcher:** The app configures the native fetcher before calling `scheduleDualNotification`; the iOS plugin should invoke that same fetcher when the content-fetch job runs (BGAppRefreshTask or equivalent), not a URL from the config. + +--- + +## Acceptance criteria + +- [ ] On iOS, calling `DailyNotification.scheduleDualNotification({ config })` with the config shape above **does not** reject with `code: "UNIMPLEMENTED"`. +- [ ] The content-fetch job is scheduled at `contentFetch.schedule` and uses the configured native fetcher to fetch content. +- [ ] The user notification is scheduled at `userNotification.schedule` and shows with API-derived content when available, or with `userNotification.title` / `userNotification.body` as fallback. +- [ ] Calling `DailyNotification.cancelDualSchedule()` cancels both schedules on iOS. +- [ ] Behavior is consistent with Android where applicable (same config, same lifecycle). + +--- + +## Relationship to consuming app + +The consuming app will continue to call: + +1. `configureNativeFetcher(...)` on startup and when enabling New Activity. +2. `updateStarredPlans({ planIds })` when enabling or when Account view loads with New Activity on. +3. `scheduleDualNotification({ config })` when the user turns on New Activity and picks a time. +4. `cancelDualSchedule()` when the user turns off New Activity. + +No change to the app’s config shape or call order is planned; the fix is entirely on the plugin iOS side to implement or correct `scheduleDualNotification` (and ensure `cancelDualSchedule` clears the dual schedule). diff --git a/doc/plugin-fix-android-DailyNotificationWorker-duplicate-scheduleId.md b/doc/plugin-fix-android-DailyNotificationWorker-duplicate-scheduleId.md new file mode 100644 index 00000000..0a1c74ec --- /dev/null +++ b/doc/plugin-fix-android-DailyNotificationWorker-duplicate-scheduleId.md @@ -0,0 +1,96 @@ +# Plugin fix: Android compile error — duplicate `scheduleId` in `handleDisplayNotification` + +**Date:** 2026-03-20 +**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) +**Consuming app:** crowd-funder-for-time-pwa (TimeSafari) +**Platform:** Android (Java) + +--- + +## Summary + +The Android module fails to compile with **two** `javac` errors: `variable scheduleId is already defined in method handleDisplayNotification(String)`. The method already declares `String scheduleId` at the start of the `try` block; two nested blocks incorrectly **redeclare** `String scheduleId`, which Java forbids in the same method scope. Remove the redundant declarations and reuse the existing variable (or assign without `String` if you ever need to refresh it). + +--- + +## Problem + +- **File:** `android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java` +- **Method:** `private Result handleDisplayNotification(String notificationId)` + +**Compiler output (representative):** + +```text +DailyNotificationWorker.java:162: error: variable scheduleId is already defined in method handleDisplayNotification(String) + String scheduleId = inputData.getString("schedule_id"); + ^ +DailyNotificationWorker.java:193: error: variable scheduleId is already defined in method handleDisplayNotification(String) + String scheduleId = inputData.getString("schedule_id"); + ^ +``` + +**Root cause:** At the top of the `try` block, the code already has: + +```java +Data inputData = getInputData(); +String scheduleId = inputData.getString("schedule_id"); +``` + +Later, inside: + +1. The `if (isStaticReminder) { ... }` branch — a line like `String scheduleId = inputData.getString("schedule_id");` (around line 162). +2. The `else { ... }` branch — the same pattern (around line 193). + +In Java, a local variable name cannot be declared again in nested blocks that share the enclosing method’s scope for that name. These inner `String scheduleId` lines are **illegal** and break `:timesafari-daily-notification-plugin:compileDebugJavaWithJavac`. + +**Functional note:** Both inner reads use the same key (`"schedule_id"`) as the outer declaration, so they add **no** new information; the fix is to **delete** those inner declarations and keep using `scheduleId` from the first assignment. + +--- + +## Required change + +**Option A (recommended):** Delete the two redundant lines entirely: + +- Remove the inner `String scheduleId = inputData.getString("schedule_id");` in the **static reminder** branch (post-reboot/rollover comment block). +- Remove the inner `String scheduleId = inputData.getString("schedule_id");` in the **regular notification** branch (rollover/notify_* comment block). + +All subsequent uses of `scheduleId` in those branches should continue to refer to the variable declared immediately after `getInputData()`. + +**Option B (only if you must re-read input later):** Replace redeclaration with assignment: + +```java +scheduleId = inputData.getString("schedule_id"); +``` + +Do **not** prefix with `String` again inside the same method. + +--- + +## Verification + +1. **Compile:** From the plugin repo, run the Android Java compile for the library (or assemble debug). Expect **zero** errors for `DailyNotificationWorker.java`. +2. **Consuming app:** Bump/publish the plugin version, update `package.json` in TimeSafari, `npm install`, `npx cap sync android`, then run the usual Android debug build (e.g. `./scripts/build-android.sh --test` or `assembleDebug`). The task `:timesafari-daily-notification-plugin:compileDebugJavaWithJavac` must succeed. +3. **Behavior:** No intended behavior change: `schedule_id` is still read once per worker run from `getInputData()` and used for dual-prefix checks, static reminder DB fallback, and canonical content by `schedule_id` in the non-static path. + +--- + +## Context (how this was found) + +- Observed when running `npm run build:android:test:run` on crowd-funder-for-time-pwa; Vite/TypeScript succeeded; Gradle failed on the plugin’s Java sources under `node_modules/.../DailyNotificationWorker.java`. +- Line numbers in published packages may drift slightly; search for `handleDisplayNotification` and duplicate `String scheduleId` inside that method. + +--- + +## Cursor prompt (paste into plugin repo) + +You can paste the block below into Cursor in the **daily-notification-plugin** workspace: + +```text +Fix Android compile errors in DailyNotificationWorker.java: in handleDisplayNotification(String notificationId), scheduleId is declared once after getInputData(). Remove the two illegal inner redeclarations "String scheduleId = inputData.getString(\"schedule_id\");" (static reminder branch and else branch). Reuse the outer scheduleId variable. Do not shadow or redeclare String scheduleId in the same method. Verify compileDebugJavaWithJavac passes. +``` + +--- + +## After the fix + +Release a new plugin version and update the consuming app’s dependency so `node_modules` is not hand-edited (edits there are lost on `npm install`). diff --git a/doc/sms-notifications.md b/doc/sms-notifications.md new file mode 100644 index 00000000..7a3c8001 --- /dev/null +++ b/doc/sms-notifications.md @@ -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” +``` diff --git a/docker/README.md b/docker/README.md index a4032778..69d9e0dc 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 ``` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 4a0714a9..03e175eb 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = ""; }; C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = ""; }; + C8E73DD02FC6E5DC0057F59A /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + C8E73DD32FC6ECC30057F59A /* AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AppDebug.entitlements; sourceTree = ""; }; 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 = ""; }; + E9F1A0012EE05A8B00737D01 /* NotificationInspectorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationInspectorPlugin.swift; sourceTree = ""; }; 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 = ""; }; /* 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 = ""; - }; + C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = ""; }; /* 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 = ""; @@ -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 */, + 3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */, ); 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,19 +294,19 @@ shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n"; showEnvVarsInLog = 0; }; - 3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = { + 3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-resources.sh\"\n"; showEnvVarsInLog = 0; }; 92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = { @@ -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; }; @@ -456,6 +459,7 @@ 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"; }; @@ -511,6 +515,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; @@ -522,7 +527,8 @@ 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 = 70; DEVELOPMENT_TEAM = GM3FS5JQPH; @@ -551,6 +557,7 @@ 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 = 70; diff --git a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme index 2716efe2..b5182cce 100644 --- a/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +++ b/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -1,6 +1,6 @@ + + + + aps-environment + development + com.apple.security.application-groups + + group.app.timesafari.share + + + diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index bfc0d05e..c60baaed 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -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]) diff --git a/ios/App/App/GoogleService-Info.plist b/ios/App/App/GoogleService-Info.plist new file mode 100644 index 00000000..4f86ac9c --- /dev/null +++ b/ios/App/App/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDhiy46kW7TH4VvUxzl2pOTLEK7mT14mIo + GCM_SENDER_ID + 1094643115061 + PLIST_VERSION + 1 + BUNDLE_ID + app.timesafari + PROJECT_ID + pc-api-7249509642322112640-286 + STORAGE_BUCKET + pc-api-7249509642322112640-286.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:1094643115061:ios:587b9422d019375e887d7c + + \ No newline at end of file diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index c61ab2f2..3af5843f 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -2,6 +2,13 @@ + BGTaskSchedulerPermittedIdentifiers + + org.timesafari.dailynotification.fetch + org.timesafari.dailynotification.notify + org.timesafari.dailynotification.content-fetch + org.timesafari.dailynotification.notification-delivery + CFBundleDevelopmentRegion en CFBundleDisplayName @@ -18,6 +25,17 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + app.timesafari + CFBundleURLSchemes + + timesafari + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS @@ -26,6 +44,14 @@ Time Safari allows you to take photos, and also scan QR codes from contacts. NSPhotoLibraryUsageDescription Time Safari allows you to upload photos. + NSUserNotificationAlertStyle + alert + UIBackgroundModes + + fetch + processing + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -47,30 +73,5 @@ UIViewControllerBasedStatusBarAppearance - CFBundleURLTypes - - - CFBundleURLName - app.timesafari - CFBundleURLSchemes - - timesafari - - - - UIBackgroundModes - - fetch - processing - - BGTaskSchedulerPermittedIdentifiers - - org.timesafari.dailynotification.fetch - org.timesafari.dailynotification.notify - org.timesafari.dailynotification.content-fetch - org.timesafari.dailynotification.notification-delivery - - NSUserNotificationAlertStyle - alert diff --git a/ios/App/App/NotificationInspectorPlugin.swift b/ios/App/App/NotificationInspectorPlugin.swift new file mode 100644 index 00000000..84dd04bd --- /dev/null +++ b/ios/App/App/NotificationInspectorPlugin.swift @@ -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 + ]) + } + } +} + diff --git a/ios/App/App/TimeSafariNativeFetcher.swift b/ios/App/App/TimeSafariNativeFetcher.swift new file mode 100644 index 00000000..ffb5e5a5 --- /dev/null +++ b/ios/App/App/TimeSafariNativeFetcher.swift @@ -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 + ), + ] + } +} diff --git a/ios/App/Podfile b/ios/App/Podfile index 916af27d..8c5608ef 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -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,6 +30,91 @@ 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['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 diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index b15c3cb5..bc3950fe 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -18,6 +18,10 @@ PODS: - CapacitorMlkitBarcodeScanning (7.5.0): - Capacitor - GoogleMLKit/BarcodeScanning (= 7.0.0) + - CapacitorPreferences (7.0.4): + - Capacitor + - CapacitorPushNotifications (7.0.7): + - Capacitor - CapacitorShare (7.0.4): - Capacitor - CapacitorStatusBar (7.0.6): @@ -89,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`)" @@ -128,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: @@ -146,6 +156,8 @@ SPEC CHECKSUMS: CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5 CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296 CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011 + CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca + CapacitorPushNotifications: 0527809a9619ed775439d5ab2c2d996122b48319 CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41 CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6 CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9 @@ -165,6 +177,6 @@ SPEC CHECKSUMS: TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 -PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448 +PODFILE CHECKSUM: abe640043e6b8adea745693d980ead03251912f9 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index d53b4f82..9628716b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "@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", @@ -65,6 +67,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", @@ -150,7 +153,8 @@ "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" } }, "node_modules/@adraffy/ens-normalize": { @@ -203,6 +207,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "devOptional": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -257,7 +262,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "optional": true, - "peer": true, "dependencies": { "@babel/types": "^7.27.3" }, @@ -307,7 +311,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", @@ -329,7 +332,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -339,7 +341,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "regexpu-core": "^6.3.1", @@ -357,7 +358,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -367,7 +367,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -384,7 +383,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -401,8 +399,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@babel/helper-globals": { "version": "7.29.7", @@ -418,7 +415,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" @@ -462,7 +458,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "optional": true, - "peer": true, "dependencies": { "@babel/types": "^7.27.1" }, @@ -483,7 +478,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-wrap-function": "^7.27.1", @@ -501,7 +495,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", @@ -519,7 +512,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" @@ -558,7 +550,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", "optional": true, - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", @@ -600,7 +591,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -618,7 +608,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -696,7 +685,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -712,7 +700,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -725,7 +712,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -752,7 +738,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -926,7 +911,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-remap-async-to-generator": "^7.27.1", @@ -944,7 +928,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -962,7 +945,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -978,7 +960,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" @@ -995,7 +976,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" @@ -1012,7 +992,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.28.6", @@ -1033,7 +1012,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/traverse": "^7.28.5" @@ -1050,7 +1028,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1066,7 +1043,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/plugin-syntax-flow": "^7.27.1" @@ -1083,7 +1059,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" @@ -1100,7 +1075,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1131,7 +1105,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.28.6" @@ -1148,7 +1121,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1164,7 +1136,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -1184,7 +1155,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1200,7 +1170,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" @@ -1217,7 +1186,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1233,7 +1201,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" @@ -1250,7 +1217,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -1268,7 +1234,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1284,7 +1249,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -1304,7 +1268,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", "optional": true, - "peer": true, "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1" }, @@ -1320,7 +1283,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1337,7 +1299,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", @@ -1358,7 +1319,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -1368,7 +1328,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", @@ -1388,7 +1347,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" @@ -1405,7 +1363,6 @@ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", @@ -1424,6 +1381,7 @@ "version": "7.28.3", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1916,6 +1874,7 @@ "version": "7.6.4", "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.6.4.tgz", "integrity": "sha512-wRq5d8+EGaIztp5sEvmOyA5kmqFwwE+Ceocw2XTQSePFD11msdLXNERGfyQhMCyxnrPfrwhWqO3SjfC73Nxq6g==", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -1939,6 +1898,24 @@ "@capacitor/core": "^7.6.0" } }, + "node_modules/@capacitor/preferences": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-7.0.4.tgz", + "integrity": "sha512-TJhkwaI2RCs/iDC9PjNczZV4h6JM4gUtTuKMeFdy0OwGgFEHf4TrtW2mFMc1TnPDalYQ91p9NT+UKgiJs9/xtg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-7.0.7.tgz", + "integrity": "sha512-TgFsPV4uOez0RtFKsWPz3XCrLxc9th+UyAkillUY9vL6ezVKT35te9Sn4vGR0jeU2U0KUNU7Dt6oSCALnSiEKg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/share": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-7.0.4.tgz", @@ -2253,6 +2230,7 @@ "version": "8.3.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -2706,6 +2684,7 @@ "version": "5.6.1", "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-5.6.1.tgz", "integrity": "sha512-tU7Gxn76454M6boi0qKFJVmcdZ78dkeGMKf3C1R8g5RtH86yK7yjFlNnxSFgVIa6k2nGpRHmZBCxhyKTfputYA==", + "peer": true, "dependencies": { "@dicebear/converter": "^9.4.2" }, @@ -3606,7 +3585,6 @@ "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -3621,26 +3599,6 @@ "node": ">=14.14" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -4708,7 +4666,6 @@ "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz", "integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==", "optional": true, - "peer": true, "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", @@ -4790,7 +4747,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -4802,15 +4758,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/cli/node_modules/ansi-regex": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -4820,7 +4774,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -4833,7 +4786,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "optional": true, - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -4843,7 +4795,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -4856,7 +4807,6 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", "optional": true, - "peer": true, "dependencies": { "restore-cursor": "^2.0.0" }, @@ -4869,7 +4819,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "optional": true, - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -4878,15 +4827,13 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/cli/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "optional": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -4896,7 +4843,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "optional": true, - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -4914,7 +4860,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -4924,7 +4869,6 @@ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", "optional": true, - "peer": true, "dependencies": { "chalk": "^2.0.1" }, @@ -4937,7 +4881,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "optional": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4950,7 +4893,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -4965,7 +4907,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "optional": true, - "peer": true, "engines": { "node": "20 || >=22" } @@ -4975,7 +4916,6 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -4985,7 +4925,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -5001,7 +4940,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "optional": true, - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -5011,7 +4949,6 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", "optional": true, - "peer": true, "dependencies": { "mimic-fn": "^1.0.0" }, @@ -5024,7 +4961,6 @@ "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", "optional": true, - "peer": true, "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", @@ -5042,7 +4978,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "optional": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -5055,7 +4990,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -5070,7 +5004,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5087,7 +5020,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -5102,7 +5034,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -5112,7 +5043,6 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", "optional": true, - "peer": true, "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" @@ -5126,7 +5056,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "optional": true, - "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -5139,7 +5068,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -5152,7 +5080,6 @@ "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", "optional": true, - "peer": true, "dependencies": { "node-forge": "^1.3.3" } @@ -5162,7 +5089,6 @@ "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", "optional": true, - "peer": true, "dependencies": { "@expo/config-plugins": "~56.0.8", "@expo/config-types": "^56.0.5", @@ -5181,7 +5107,6 @@ "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.9.tgz", "integrity": "sha512-/6a/S9USwx8OC9tGjHxbviLFiBHyueN3aoNWMLvWDEJoZ1CIVW800ZBzwXq/FYNK2qzcN1LxFmQtzD1zeFQKNA==", "optional": true, - "peer": true, "dependencies": { "@expo/config-types": "^56.0.6", "@expo/json-file": "~10.2.0", @@ -5203,7 +5128,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "optional": true, - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -5213,7 +5137,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -5226,7 +5149,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -5244,7 +5166,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "optional": true, - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -5262,7 +5183,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "optional": true, - "peer": true, "engines": { "node": "20 || >=22" } @@ -5272,7 +5192,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -5288,7 +5207,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "optional": true, - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -5297,15 +5215,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/config-plugins/node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5322,7 +5238,6 @@ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", "optional": true, - "peer": true, "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" @@ -5336,7 +5251,6 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", "optional": true, - "peer": true, "engines": { "node": ">=4.0" } @@ -5345,15 +5259,13 @@ "version": "56.0.6", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.6.tgz", "integrity": "sha512-4Y6Aum5J4Re5NnxGVofRNe1aDwUBOmWhQYkynZsqzRtX/zEA1ADUeyHXuEckv9YD9djiyT7bKtLt5gKL3mA6VQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/config/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "optional": true, - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -5363,7 +5275,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -5376,7 +5287,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "optional": true, - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -5394,7 +5304,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "optional": true, - "peer": true, "engines": { "node": "20 || >=22" } @@ -5404,7 +5313,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -5420,7 +5328,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "optional": true, - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -5430,7 +5337,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5447,7 +5353,6 @@ "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", "optional": true, - "peer": true, "dependencies": { "@expo/sudo-prompt": "^9.3.1", "debug": "^3.1.0" @@ -5458,7 +5363,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.1" } @@ -5468,7 +5372,6 @@ "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-56.0.2.tgz", "integrity": "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==", "optional": true, - "peer": true, "dependencies": { "chalk": "^4.1.2" }, @@ -5490,7 +5393,6 @@ "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.5.tgz", "integrity": "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==", "optional": true, - "peer": true, "peerDependencies": { "expo": "*", "react": "*", @@ -5502,7 +5404,6 @@ "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", "optional": true, - "peer": true, "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", @@ -5516,15 +5417,13 @@ "version": "0.0.9", "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.0.9.tgz", "integrity": "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/fingerprint": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.2.tgz", "integrity": "sha512-+/cBrRHiHmldvT8ZPrrHobAOMTUTzOq6Qpr1YLSoIg0J9hbEkJOg9vUvpxiLNWSQY0eKtVTvMO03EIdPC2aQdQ==", "optional": true, - "peer": true, "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", @@ -5547,7 +5446,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "optional": true, - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -5557,7 +5455,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -5570,7 +5467,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "optional": true, - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -5588,7 +5484,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "optional": true, - "peer": true, "engines": { "node": "20 || >=22" } @@ -5598,7 +5493,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -5614,7 +5508,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "optional": true, - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -5624,7 +5517,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5641,7 +5533,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -5651,7 +5542,6 @@ "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", "optional": true, - "peer": true, "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", @@ -5667,7 +5557,6 @@ "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.12.tgz", "integrity": "sha512-SNIZr/HWfIQPTZBwmukItxpc7ws1SgMUywYq1dnQvDknQDjJcuWAasIRFUjsK15yQ1xb4G5CP7VHtbN3V4lENg==", "optional": true, - "peer": true, "dependencies": { "@expo/config-plugins": "~56.0.9" } @@ -5677,7 +5566,6 @@ "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" @@ -5688,7 +5576,6 @@ "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.8.tgz", "integrity": "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==", "optional": true, - "peer": true, "dependencies": { "@expo/config": "~56.0.9", "chalk": "^4.1.2" @@ -5699,7 +5586,6 @@ "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.13.tgz", "integrity": "sha512-QWRZSpWPyjkDLVQio4R7oAzg/Av2MOt/DciFkfjr8qQ3qxGVn1Rt1oHP/80hvcWDcHFV7N6PqpyxRXw6nbxzKQ==", "optional": true, - "peer": true, "dependencies": { "@expo/dom-webview": "^56.0.5", "anser": "^1.4.9", @@ -5717,7 +5603,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", "optional": true, - "peer": true, "dependencies": { "metro": "0.84.4", "metro-babel-transformer": "0.84.4", @@ -5740,7 +5625,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.14.tgz", "integrity": "sha512-O3CIHruaTJhswPAf/nf3i8QQ3f2jl+mEwSea1eb3khuplabdy/wTQz+JvHN8VGUFyg7JKwUGU1QfO6T3JiSQqA==", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", @@ -5780,7 +5664,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "optional": true, - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -5790,7 +5673,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "optional": true, - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -5803,7 +5685,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "optional": true, - "peer": true, "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", @@ -5821,7 +5702,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "optional": true, - "peer": true, "engines": { "node": "20 || >=22" } @@ -5831,7 +5711,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "optional": true, - "peer": true, "dependencies": { "brace-expansion": "^5.0.5" }, @@ -5847,7 +5726,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "optional": true, - "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -5857,7 +5735,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -5874,7 +5751,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -5884,7 +5760,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", "optional": true, - "peer": true, "dependencies": { "debug": "^4.3.4", "fb-watchman": "^2.0.2", @@ -5899,7 +5774,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -5912,7 +5786,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -5929,15 +5802,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/metro-file-map/node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -5955,7 +5826,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -5971,7 +5841,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -5984,7 +5853,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -6000,7 +5868,6 @@ "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", "optional": true, - "peer": true, "dependencies": { "@expo/spawn-async": "^1.8.0" }, @@ -6013,7 +5880,6 @@ "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", "optional": true, - "peer": true, "dependencies": { "@expo/json-file": "^10.2.0", "@expo/spawn-async": "^1.8.0", @@ -6028,7 +5894,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -6038,7 +5903,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "optional": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -6051,7 +5915,6 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", "optional": true, - "peer": true, "dependencies": { "restore-cursor": "^2.0.0" }, @@ -6064,7 +5927,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "optional": true, - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -6073,15 +5935,13 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/package-manager/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "optional": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -6091,7 +5951,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -6101,7 +5960,6 @@ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", "optional": true, - "peer": true, "dependencies": { "chalk": "^2.0.1" }, @@ -6114,7 +5972,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -6129,7 +5986,6 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -6139,7 +5995,6 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", "optional": true, - "peer": true, "dependencies": { "mimic-fn": "^1.0.0" }, @@ -6152,7 +6007,6 @@ "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", "optional": true, - "peer": true, "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", @@ -6170,7 +6024,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "optional": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -6185,7 +6038,6 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", "optional": true, - "peer": true, "dependencies": { "onetime": "^2.0.0", "signal-exit": "^3.0.2" @@ -6199,7 +6051,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "optional": true, - "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -6212,7 +6063,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -6225,7 +6075,6 @@ "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", "optional": true, - "peer": true, "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", @@ -6237,7 +6086,6 @@ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "optional": true, - "peer": true, "engines": { "node": ">=10.0.0" } @@ -6247,7 +6095,6 @@ "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.16.tgz", "integrity": "sha512-ce9ENfPWO4WUWUVQz0OaqL3KYZ7YofP8O35ncnn7CHCaKwQ7BqxcCGJbh+qvP1UjlWeNB3CjHPrXXJ3bnZwlJw==", "optional": true, - "peer": true, "dependencies": { "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", @@ -6266,7 +6113,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "optional": true, - "peer": true, "engines": { "node": ">= 10" } @@ -6276,7 +6122,6 @@ "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz", "integrity": "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw==", "optional": true, - "peer": true, "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", @@ -6292,7 +6137,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -6302,7 +6146,6 @@ "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", @@ -6322,7 +6165,6 @@ "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz", "integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==", "optional": true, - "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -6356,22 +6198,19 @@ "version": "56.0.1", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", "integrity": "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/sdk-runtime-versions": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/spawn-async": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "optional": true, - "peer": true, "dependencies": { "cross-spawn": "^7.0.6" }, @@ -6383,15 +6222,13 @@ "version": "9.3.2", "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@expo/ws-tunnel": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", "optional": true, - "peer": true, "peerDependencies": { "ws": "^8.0.0" } @@ -6401,7 +6238,6 @@ "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", @@ -6419,6 +6255,644 @@ "node": ">=14" } }, + "node_modules/@firebase/ai": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.16.0.tgz", + "integrity": "sha512-WRVxl58B61Orwf7st7949ZIW2L4huV80Gpaum1z8v2eH+m2xaxWtC8mOjQyVAOirr6O/yf89cBjlPvoyBpBfTg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.25", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.25.tgz", + "integrity": "sha512-oDagV3PHeNXBdxemksZCdj+GmUT19eCnTOcLdQePIIovFXlm3zWy3Gjeaokt9Nqx24kc4ljK8lU0XGcGmmKZ1A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.31.tgz", + "integrity": "sha512-msTlG9REujk5rjtOHNLngiuPmvaWGQte8ugL4/F6ZCMb+92Ue7SgjSEpNxINkM8ZtTrbGS83j5SZkt0r+M1Tjw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.25", + "@firebase/analytics-types": "0.8.5", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.5.tgz", + "integrity": "sha512-kdnooE7Bis2jEnsqcerRwn/UQpH5D3uvHpku7OdUM9TJN3omlu6iYtbyAQ6XkAJRgJtO0aNf9OOkW8J6D59oCA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.16.2.tgz", + "integrity": "sha512-fyCLPahl3r0Zb7Wzm+JLWcOOH3SaDgAyJzJ8EWIDiv51aNiv+2Pjbpa6myEuP7iXxjmJqrcbVm0GUF7h229T1Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.13.1.tgz", + "integrity": "sha512-l8y3dmnhodXks/APAwx4tWqRl3tk8b9874KF1FJyKg1DIU+kMD0l52iz7S9/MW+eK8k6cjJg0G0iJ5KQAsQpow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.7.tgz", + "integrity": "sha512-fBb/xSMyIKv1nDmccfzz3IAGBzx/cQOxmZai66rJzifb1hMMkztf824Xx7LhpTUe7HNUbXwY90IvJ66fi8q6yg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.13.1", + "@firebase/app-check-types": "0.5.5", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz", + "integrity": "sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.5.tgz", + "integrity": "sha512-+DF4gzFrlwGFyky38o4T/YN/r51l70yCtJ2HTkwmRj8FCMwPjm4hLH4fQbj6BUvzkiFqliBkitFsRpf1/YZkWA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.18.tgz", + "integrity": "sha512-77H57WuGEsgrv59HdtuhHG+eQdP8di6myz7O93yW6yTZlR/tLSmsCxCq5vHp6AWWDwSV0EVtKUvizXQiQZVBWA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app": "0.16.2", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.6.tgz", + "integrity": "sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/logger": "0.5.2" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.6.tgz", + "integrity": "sha512-MKRLvF72HJ/irNn2fJR0Gv5FOZNtqv1IBeJHRwK8l8pZ/wi9vuUw8ecyCMvQDDNxfS6uW0MWACL5KC4Y8Pp+fA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.11.tgz", + "integrity": "sha512-L+xp3DTJQrBV08Vt0UhcL/ofUOSjXom7JOJfbT6oas4o7gCrQnTYFKs8wfiuKLmHoo8BxMr5LdgjAs7o6aAsqQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.6", + "@firebase/auth-types": "0.13.2", + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz", + "integrity": "sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.2.tgz", + "integrity": "sha512-OU+miuoSxIWYN7GT291d2ylVJBL3k/OePo7/JDSoQtkFQoEiGBc4AHuMjj/seqFIsHrqnjrAfRCk4pfufoJolQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.5.tgz", + "integrity": "sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.4.tgz", + "integrity": "sha512-su1aGWlzhxb+xtggCUSsufJn1FDa06SBDK71y+fpQ+g2zMhMExik6FUv/odkKzOF/xfWVjabCbWBcjJY3MceDA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.5.tgz", + "integrity": "sha512-/JGpvszLoNXNgzilRXocigGfFF4hbcPA9wN1i1kjJx6oKkXgkHteZYl3lQs1lJX3ETDf6bv7zI15lPMVUp4sAQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.7.tgz", + "integrity": "sha512-lBq9sJm8MnJINKJnkAKSOj2MbC66xGoSVCcGkPtstl+lkoKEBtD46qHLbuDgW/WbLPoKVm17RIN8BxHj+Z2F2w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/database": "1.1.5", + "@firebase/database-types": "1.0.22", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + }, + "peerDependenciesMeta": { + "@firebase/app": { + "optional": true + }, + "@firebase/app-compat": { + "optional": true + } + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.22.tgz", + "integrity": "sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.6", + "@firebase/util": "1.15.3" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.17.2.tgz", + "integrity": "sha512-SiyQEK1dYPOjItFVA+kZGdvOoRAtMebDBvUaSxJPNZMkKcRwYYK2kzWo5UXTZZj523uMxJnkr+KKVySEMme3kQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "@firebase/webchannel-wrapper": "1.0.7", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^2.8.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.14.tgz", + "integrity": "sha512-HJ2HBNgNPrRsWIJ4E+Kc0u0h1U7HlF7If8AtYjie9HZXlMUmwYSJaXBj9VFm6gkSj5dlKZNcOQ0Ou25CVVG6rA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/firestore": "4.17.2", + "@firebase/firestore-types": "3.0.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.5.tgz", + "integrity": "sha512-dbdMAQkMd5dwWc48eupz/Y6/E9ruat3+gY5lhVKscvvT/HnDBEEMzJW38zdKhgdnglZHGk2vUsJMOHAphMHGMA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.14.0.tgz", + "integrity": "sha512-DhuYFr0eMhp+s/PNEk6SiMsYkc00+XVOUeKbrl8MOzQNZI3SKQpqoDR1d+keNDAutwmHRWTUAdXBoVPD+xxVUw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.5", + "@firebase/auth-interop-types": "0.2.6", + "@firebase/component": "0.7.5", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.5.0.tgz", + "integrity": "sha512-T3BDIToESZUHt7438wyKYMkRjKg3m1xAIux5fVpNvTRQlDOFLukWniQWBDhJ2znpU9kxMTR6+e31TVo8P15PZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/functions": "0.14.0", + "@firebase/functions-types": "0.6.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.5.tgz", + "integrity": "sha512-Zc0pURjthHXzSj54ZivCkzKDSV1r/wIpnmHdhq82q2yFFPVoncG/ZJjnVMiANvQfeww/QnElhzODpfwUucVwdA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.24", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.24.tgz", + "integrity": "sha512-Ui52ey8wHoWqkBbXRKJEKYWylI0JZogZmoLS+o8Anh1bxWtK27ZYjLla1UJAAdC5DCKLJ2qAp0VpJOjg8VOX1g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.24.tgz", + "integrity": "sha512-8M5nlcWwYt881x83COP2odq5vgf+NgwJh+RMd4LRSv8JI1pxwDfgrDJOERrjTgfC9J5Z0vnQX4b1pdN914e0Zw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/installations-types": "0.5.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.5.tgz", + "integrity": "sha512-e9UYcju3puDl1vdrcKIi5dExzHLameOT/Tc61Q48PYwxtsM1NzZh/ikGbdBQTsbRgg0EMZqdPr0/m5ODUBobrg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.2.tgz", + "integrity": "sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.3.tgz", + "integrity": "sha512-nRXPAp+z0kA3KziJtRY9kktHECvRwaaONzHvislFgllhCwJMscW7icWNQA5IjyP8Uc2+GS/4px8Uz6rx6ftTag==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/messaging-interop-types": "0.2.6", + "@firebase/util": "1.15.3", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.30", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.30.tgz", + "integrity": "sha512-m1DPT2ET5x0qYqXLoHwKMAyf1mgZZOhuoE6H6/H4OPsp8zSxMIDEFPaW7MaffPJsCA5J7kHzo+BHVNXK4ZoV+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/messaging": "0.13.3", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.6.tgz", + "integrity": "sha512-MVzvkKe2V4H2dHu5oOxRfeKQcfTwWmCgnzsC4V1q3ixun5iiL8riCZ2qI35rDhCL4glPGiu0jHxDbpVNuTKfow==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.14.tgz", + "integrity": "sha512-9PH1XEZVHErxGdbXluvGz4Uyjw4W955H8v7Mv9rHIybRrg5F2Ac2tvf5+mSf5EtnxWNmxrD2WLnvMFaZP4sMrQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.27.tgz", + "integrity": "sha512-O/ozTf/EbChN94Pk7bd1eUC0PAC36726AwsaiJyC0bZzSBWfLGSWASGPH5pebUWXQPJtGxIJYRkkbX5l0Qa+Hw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/performance": "0.7.14", + "@firebase/performance-types": "0.2.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.5.tgz", + "integrity": "sha512-PRzOgB+/M+6AKlEkY8a9xy5Ff5SfZPIh4iShXhn2WAcL/euSbK7bdLqWysHduunNJhGFsXa22Ag3ixqL6rQtYg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.2.tgz", + "integrity": "sha512-Rii93DkXjM+RE/ytHdYa7EIiQLJbdBr+W8EEiqd/vbLCOC+1FdkDuRiumJBp6t3yewHGbdqbpjB3fk3z0cZ+8g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/installations": "0.6.24", + "@firebase/logger": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.29.tgz", + "integrity": "sha512-mY7JtTISK6F4g4T0x3kVepr5cp0NXL7f5yjIUXXGaTrg4qUlFBWRbENaHaqZ78dwmNcLm24h/yPsOVRlDXEXhA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/logger": "0.5.2", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-types": "0.5.2", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.2.tgz", + "integrity": "sha512-i8k1omVfoAnaT1ZPv2FFjxMZrATYsO/GnFgHEj5+7fAJLzi8wLnGGv1WK06oEAD6ltrWXSO1HzeNyajn/NjMWw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.5.tgz", + "integrity": "sha512-r2tozN/BlEewLi70tJNUQPzWwbex9GM7NgXZsamHKQrjKEfcR0i4jGgHBKAKA4hbwiPE9eNMb3hcxWnDpeJZwg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.5.tgz", + "integrity": "sha512-vO0tFPxXbKDKdlTu8tYT08S9t9ezUTJYEdLCULpWxWq2aq6zojwN1QmAB+1a50AI/g47GlWUJn1XPncm/PDZsw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.5", + "@firebase/storage": "0.14.5", + "@firebase/storage-types": "0.8.5", + "@firebase/util": "1.15.3", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.5.tgz", + "integrity": "sha512-GEDs5P+rNUfNcS+wxIdOLAHficife2YXtvTJnyi3ssrX11AAtBg+nDUdbYh8vuBzWehVQZPmztGUUnud4L+4yg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.3.tgz", + "integrity": "sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.7.tgz", + "integrity": "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ==", + "license": "Apache-2.0" + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "license": "MIT", @@ -6429,6 +6903,7 @@ "node_modules/@fortawesome/fontawesome-svg-core": { "version": "6.7.2", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -6480,6 +6955,37 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hexagon/base64": { "version": "1.1.28", "license": "MIT" @@ -7308,7 +7814,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -7855,7 +8360,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "optional": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -8238,6 +8742,63 @@ "prettier": ">=2.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@pvermeer/dexie-encrypted-addon": { "version": "3.0.0", "license": "MIT", @@ -8268,7 +8829,6 @@ "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz", "integrity": "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==", "optional": true, - "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8278,7 +8838,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@react-native/codegen": "0.85.3" @@ -8292,7 +8851,6 @@ "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.29.0", @@ -8314,7 +8872,6 @@ "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz", "integrity": "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw==", "optional": true, - "peer": true, "dependencies": { "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", @@ -8345,7 +8902,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -8362,15 +8918,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/debugger-frontend": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz", "integrity": "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==", "optional": true, - "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8380,7 +8934,6 @@ "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz", "integrity": "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==", "optional": true, - "peer": true, "dependencies": { "cross-spawn": "^7.0.6", "debug": "^4.4.0", @@ -8395,7 +8948,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -8412,15 +8964,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/dev-middleware": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz", "integrity": "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==", "optional": true, - "peer": true, "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.85.3", @@ -8444,7 +8994,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -8461,15 +9010,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/dev-middleware/node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", "optional": true, - "peer": true, "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -8486,7 +9033,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -8508,7 +9054,6 @@ "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.3.tgz", "integrity": "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA==", "optional": true, - "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8518,7 +9063,6 @@ "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", "optional": true, - "peer": true, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } @@ -8527,15 +9071,13 @@ "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", "integrity": "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@react-native/virtualized-lists": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.85.3.tgz", "integrity": "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig==", "optional": true, - "peer": true, "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" @@ -9884,6 +10426,7 @@ "version": "1.9.20", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/geojson": "*" } @@ -10109,6 +10652,7 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -10925,6 +11469,35 @@ "vue": "^3.2.25" } }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/@vue-leaflet/vue-leaflet": { "version": "0.10.1", "license": "MIT", @@ -10983,6 +11556,17 @@ "@vue/shared": "3.5.13" } }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, "node_modules/@vue/devtools-api": { "version": "6.6.4", "license": "MIT" @@ -11047,6 +11631,7 @@ "version": "5.62.0", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -11210,6 +11795,31 @@ "node": ">=4.0" } }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@vue/reactivity": { "version": "3.5.13", "license": "MIT", @@ -11368,7 +11978,6 @@ "version": "1.3.8", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -11381,6 +11990,7 @@ "version": "8.15.0", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11451,6 +12061,7 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11470,12 +12081,18 @@ "ajv": "^6.9.1" } }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "optional": true, - "peer": true + "optional": true }, "node_modules/ansi-align": { "version": "3.0.1", @@ -11870,8 +12487,7 @@ "node_modules/asap": { "version": "2.0.6", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/asmcrypto.js": { "version": "0.22.0", @@ -12116,7 +12732,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "optional": true, - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", @@ -12131,7 +12746,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "optional": true, - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -12141,7 +12755,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", "core-js-compat": "^3.43.0" @@ -12155,7 +12768,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "optional": true, - "peer": true, "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, @@ -12168,7 +12780,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "optional": true, - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -12177,15 +12788,13 @@ "version": "0.21.2", "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/babel-plugin-syntax-hermes-parser": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", "optional": true, - "peer": true, "dependencies": { "hermes-parser": "0.33.3" } @@ -12195,7 +12804,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", "optional": true, - "peer": true, "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" } @@ -12230,7 +12838,6 @@ "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.12.tgz", "integrity": "sha512-8sOIpdzMXgx81CcCF4wwAQC8xo9akFgy32pciVjHo/4tphLOXez7wfqv5p9StgyMLQPEF4qhXG2Rkbz1QAgu2A==", "optional": true, - "peer": true, "dependencies": { "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", @@ -12851,6 +13458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13365,7 +13973,6 @@ "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -13384,7 +13991,6 @@ "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", @@ -13398,7 +14004,6 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "optional": true, - "peer": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -13421,7 +14026,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -13815,7 +14419,6 @@ "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", @@ -13831,7 +14434,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -13840,8 +14442,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/console-control-strings": { "version": "1.1.0", @@ -14084,7 +14685,6 @@ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "optional": true, - "peer": true, "dependencies": { "browserslist": "^4.28.1" }, @@ -14223,8 +14823,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-fetch": { "version": "4.1.0", @@ -14394,6 +14993,13 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.4", "license": "MIT", @@ -14598,7 +15204,6 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -14625,7 +15230,6 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -14669,6 +15273,7 @@ "node_modules/dexie": { "version": "3.2.7", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=6.0" } @@ -14817,6 +15422,7 @@ "version": "26.8.1", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -14870,8 +15476,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/doctrine": { "version": "3.0.0", @@ -15008,8 +15613,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "optional": true, - "peer": true + "optional": true }, "node_modules/ejs": { "version": "3.1.10", @@ -15163,7 +15767,6 @@ "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", "hasInstallScript": true, - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -15182,7 +15785,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -15196,7 +15798,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -15205,7 +15806,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -15263,7 +15863,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -15339,7 +15938,6 @@ "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "optional": true, - "peer": true, "dependencies": { "stackframe": "^1.3.4" } @@ -15438,8 +16036,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "optional": true, - "peer": true + "optional": true }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -15456,6 +16053,7 @@ "version": "8.57.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -15510,6 +16108,7 @@ "version": "9.1.2", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -15550,6 +16149,7 @@ "version": "9.33.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "globals": "^13.24.0", @@ -15698,7 +16298,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -16071,75 +16670,11 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/expo": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.4.tgz", - "integrity": "sha512-ZwoOkOTwITJrFQRRO5tUsBp6NlddvjWSs3ADb+zOu1UIQWBCk9dmwmSrdFxu0P+hYnU2hk5k/Y6xq6DPLNSKzg==", - "optional": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.11", - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.8", - "@expo/devtools": "~56.0.2", - "@expo/dom-webview": "~56.0.5", - "@expo/fingerprint": "^0.19.2", - "@expo/local-build-cache-provider": "^56.0.7", - "@expo/log-box": "^56.0.12", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.12", - "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.12", - "expo-asset": "~56.0.14", - "expo-constants": "~56.0.15", - "expo-file-system": "~56.0.7", - "expo-font": "~56.0.5", - "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.12", - "expo-modules-core": "~56.0.12", - "pretty-format": "^29.7.0", - "react-refresh": "^0.14.2", - "whatwg-url-minimum": "^0.1.2" - }, - "bin": { - "expo": "bin/cli", - "expo-modules-autolinking": "bin/autolinking", - "fingerprint": "bin/fingerprint" - }, - "peerDependencies": { - "@expo/dom-webview": "*", - "@expo/metro-runtime": "*", - "react": "*", - "react-dom": "*", - "react-native": "*", - "react-native-web": "*", - "react-native-webview": "*" - }, - "peerDependenciesMeta": { - "@expo/dom-webview": { - "optional": true - }, - "@expo/metro-runtime": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native-web": { - "optional": true - }, - "react-native-webview": { - "optional": true - } - } - }, "node_modules/expo-asset": { "version": "56.0.14", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.14.tgz", "integrity": "sha512-3rN/VGt4jhNOXbAz3gdSJzC/dSmX5ozHgtG/HQTCOzuRGBd1q2lzWoZewX8aU1fiWchQg9LasiLiAJi+u8oBbQ==", "optional": true, - "peer": true, "dependencies": { "@expo/image-utils": "^0.10.1", "expo-constants": "~56.0.15" @@ -16150,52 +16685,21 @@ "react-native": "*" } }, - "node_modules/expo-constants": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.18.tgz", - "integrity": "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/env": "~2.3.0" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo-file-system": { "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", "optional": true, - "peer": true, "peerDependencies": { "expo": "*", "react-native": "*" } }, - "node_modules/expo-font": { - "version": "56.0.7", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", - "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", - "optional": true, - "peer": true, - "dependencies": { - "fontfaceobserver": "^2.1.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo-keep-awake": { "version": "56.0.3", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", "integrity": "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==", "optional": true, - "peer": true, "peerDependencies": { "expo": "*", "react": "*" @@ -16252,7 +16756,6 @@ "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.7.tgz", "integrity": "sha512-iBAj4Xeh/8HT201VVxFlmf+VBfmtQV1ZUoJdLQQENm0+j9gnD2QswZLJyNo3CmNNXl46esJeLR5lpGpYZts/zA==", "optional": true, - "peer": true, "peerDependencies": { "react-native": "*" } @@ -16271,112 +16774,6 @@ "expo": "*" } }, - "node_modules/expo-server": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", - "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.16.0" - } - }, - "node_modules/expo/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expo/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true - }, - "node_modules/expo/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/expo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/expo/node_modules/expo-modules-autolinking": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.12.tgz", - "integrity": "sha512-Sn/LiLSL4as/YOGoatsuRQYS7rxAQHK2oYbFMT/I3iIXHLSeb0DQeuAIytVQ9ypWWck9s2krH2T6NeztyftnaA==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/require-utils": "^56.1.3", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.1.0", - "commander": "^7.2.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" - } - }, - "node_modules/expo/node_modules/expo-modules-core": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.12.tgz", - "integrity": "sha512-2Rf+FBU2EXe27km3m066xHu4kuUSpNT35nzk98fFxIV8B2Ah+FHub2rvAznEcGAUlDArVA2S/6+pMlHWijbicQ==", - "optional": true, - "peer": true, - "dependencies": { - "@expo/expo-modules-macros-plugin": "~0.0.9", - "expo-modules-jsi": "~56.0.7", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-worklets": "^0.7.4 || ^0.8.0" - }, - "peerDependenciesMeta": { - "react-native-worklets": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "optional": true, - "peer": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/exponential-backoff": { "version": "3.1.2", "license": "Apache-2.0" @@ -16504,12 +16901,23 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fb-dotslash": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", "optional": true, - "peer": true, "bin": { "dotslash": "bin/dotslash" }, @@ -16573,8 +16981,7 @@ "version": "0.4.10", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", - "optional": true, - "peer": true + "optional": true }, "node_modules/fflate": { "version": "0.6.10", @@ -16631,7 +17038,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -16650,7 +17056,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -16659,8 +17064,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/find-up": { "version": "5.0.0", @@ -16677,6 +17081,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.19.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.19.0.tgz", + "integrity": "sha512-kwXCLcI0ly2lkwygkbuRx6SsX3DSO96355YrWh9SWZ5ncsxK/y5cirn3sY6nZVSiBVE++2nL6Hchzu1EB8uohQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.16.0", + "@firebase/analytics": "0.10.25", + "@firebase/analytics-compat": "0.2.31", + "@firebase/app": "0.16.2", + "@firebase/app-check": "0.13.1", + "@firebase/app-check-compat": "0.4.7", + "@firebase/app-compat": "0.5.18", + "@firebase/app-types": "0.9.6", + "@firebase/auth": "1.13.6", + "@firebase/auth-compat": "0.6.11", + "@firebase/data-connect": "0.7.4", + "@firebase/database": "1.1.5", + "@firebase/database-compat": "2.1.7", + "@firebase/firestore": "4.17.2", + "@firebase/firestore-compat": "0.4.14", + "@firebase/functions": "0.14.0", + "@firebase/functions-compat": "0.5.0", + "@firebase/installations": "0.6.24", + "@firebase/installations-compat": "0.2.24", + "@firebase/messaging": "0.13.3", + "@firebase/messaging-compat": "0.2.30", + "@firebase/performance": "0.7.14", + "@firebase/performance-compat": "0.2.27", + "@firebase/remote-config": "0.9.2", + "@firebase/remote-config-compat": "0.2.29", + "@firebase/storage": "0.14.5", + "@firebase/storage-compat": "0.4.5", + "@firebase/util": "1.15.3" + } + }, "node_modules/fix-esm": { "version": "1.0.1", "license": "WTFPL OR CC0-1.0", @@ -16724,8 +17164,7 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/follow-redirects": { "version": "1.16.0", @@ -16750,8 +17189,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/for-each": { "version": "0.3.5", @@ -16838,7 +17276,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -17128,7 +17565,6 @@ "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -17530,22 +17966,19 @@ "version": "250829098.0.10", "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz", "integrity": "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==", - "optional": true, - "peer": true + "optional": true }, "node_modules/hermes-estree": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/hermes-parser": { "version": "0.33.3", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.33.3" } @@ -17594,7 +18027,6 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "optional": true, - "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -17615,11 +18047,16 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -17711,6 +18148,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/idb-wrapper": { "version": "1.7.2", "dev": true, @@ -17747,7 +18190,6 @@ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", "optional": true, - "peer": true, "dependencies": { "queue": "6.0.2" }, @@ -18295,6 +18737,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -18569,7 +19012,6 @@ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "optional": true, - "peer": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -18986,8 +19428,7 @@ "version": "0.16.1", "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", - "optional": true, - "peer": true + "optional": true }, "node_modules/jiti": { "version": "1.21.7", @@ -19035,8 +19476,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "optional": true, - "peer": true + "optional": true }, "node_modules/jsesc": { "version": "3.1.0", @@ -19379,18 +19819,6 @@ "node": ">=6" } }, - "node_modules/ky": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", - "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", - "optional": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, "node_modules/ky-universal": { "version": "0.8.2", "license": "MIT", @@ -19457,7 +19885,6 @@ "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", "optional": true, - "peer": true, "bin": { "lan-network": "dist/lan-network-cli.js" } @@ -19469,7 +19896,8 @@ }, "node_modules/leaflet": { "version": "1.9.4", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/level-blobs": { "version": "0.1.7", @@ -19694,7 +20122,6 @@ "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", "optional": true, - "peer": true, "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" @@ -19705,7 +20132,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -19714,15 +20140,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -19759,7 +20183,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19780,7 +20203,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19801,7 +20223,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19822,7 +20243,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19843,7 +20263,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19864,7 +20283,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19885,7 +20303,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19906,7 +20323,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19927,7 +20343,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19948,7 +20363,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -19969,7 +20383,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -20415,7 +20828,6 @@ }, "node_modules/lodash.camelcase": { "version": "4.3.0", - "dev": true, "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -20426,8 +20838,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "optional": true, - "peer": true + "optional": true }, "node_modules/lodash.get": { "version": "4.4.2", @@ -20486,8 +20897,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/lodash.uniq": { "version": "4.5.0", @@ -20701,6 +21111,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -21203,8 +21619,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/matcher": { "version": "3.0.0", @@ -21242,8 +21657,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "optional": true, - "peer": true + "optional": true }, "node_modules/meow": { "version": "8.1.2", @@ -21435,7 +21849,6 @@ "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", "optional": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", @@ -21489,7 +21902,6 @@ "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", @@ -21505,15 +21917,13 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro-babel-transformer/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -21523,7 +21933,6 @@ "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", "optional": true, - "peer": true, "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", @@ -21539,7 +21948,6 @@ "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -21552,7 +21960,6 @@ "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", "optional": true, - "peer": true, "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", @@ -21572,7 +21979,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -21585,7 +21991,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -21602,15 +22007,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro-config/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -21623,7 +22026,6 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -21636,7 +22038,6 @@ "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", @@ -21654,7 +22055,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -21669,7 +22069,6 @@ "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", @@ -21684,7 +22083,6 @@ "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", "optional": true, - "peer": true, "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", @@ -21705,7 +22103,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -21718,7 +22115,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -21735,15 +22131,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro-file-map/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -21761,7 +22155,6 @@ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -21779,7 +22172,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -21794,15 +22186,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro-file-map/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -21815,7 +22205,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -21831,7 +22220,6 @@ "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" @@ -21845,7 +22233,6 @@ "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -21858,7 +22245,6 @@ "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", "optional": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" @@ -21872,7 +22258,6 @@ "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", "optional": true, - "peer": true, "dependencies": { "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", @@ -21893,7 +22278,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -21903,7 +22287,6 @@ "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", @@ -21924,7 +22307,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -21934,7 +22316,6 @@ "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -21952,7 +22333,6 @@ "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", "optional": true, - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", @@ -21977,7 +22357,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -21990,7 +22369,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -22007,15 +22385,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "optional": true, - "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -22028,15 +22404,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "optional": true, - "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -22053,15 +22427,13 @@ "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/hermes-parser": { "version": "0.35.0", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", "optional": true, - "peer": true, "dependencies": { "hermes-estree": "0.35.0" } @@ -22071,7 +22443,6 @@ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", "optional": true, - "peer": true, "dependencies": { "@jest/types": "^29.6.3", "@types/node": "*", @@ -22095,7 +22466,6 @@ } ], "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -22105,7 +22475,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", @@ -22121,7 +22490,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "optional": true, - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -22137,15 +22505,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/metro/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -22155,7 +22521,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -22168,7 +22533,6 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -22178,7 +22542,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -22188,7 +22551,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -22204,7 +22566,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -22982,6 +23343,13 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/multibase": { "version": "4.0.6", "license": "MIT", @@ -23005,8 +23373,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/mz": { "version": "2.7.0", @@ -23180,7 +23547,6 @@ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "optional": true, - "peer": true, "engines": { "node": ">= 6.13.0" } @@ -23470,7 +23836,6 @@ "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", "optional": true, - "peer": true, "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", @@ -23486,7 +23851,6 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", "optional": true, - "peer": true, "dependencies": { "lru-cache": "^10.0.1" }, @@ -23499,7 +23863,6 @@ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", "optional": true, - "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -23544,8 +23907,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/number-to-bn": { "version": "1.7.0", @@ -23568,7 +23930,6 @@ "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", "optional": true, - "peer": true, "dependencies": { "flow-enums-runtime": "^0.0.6" }, @@ -23607,7 +23968,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "optional": true, - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -23892,7 +24252,6 @@ "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", "optional": true, - "peer": true, "dependencies": { "pngjs": "^3.3.0" }, @@ -23905,7 +24264,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -24255,7 +24613,6 @@ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", "optional": true, - "peer": true, "engines": { "node": ">=4.0.0" } @@ -24285,6 +24642,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -24408,7 +24766,6 @@ "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -24424,7 +24781,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -24465,6 +24821,7 @@ "version": "3.6.2", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -24544,7 +24901,6 @@ "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", "optional": true, - "peer": true, "dependencies": { "asap": "~2.0.6" } @@ -24594,6 +24950,29 @@ "signal-exit": "^3.0.2" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/protons-runtime": { "version": "5.6.0", "license": "Apache-2.0 OR MIT", @@ -24722,7 +25101,6 @@ "version": "2.7.16", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", - "peer": true, "dependencies": { "@babel/parser": "^7.23.5", "postcss": "^8.4.14", @@ -24737,7 +25115,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "optional": true, - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -24890,7 +25267,6 @@ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", "optional": true, - "peer": true, "dependencies": { "inherits": "~2.0.3" } @@ -24954,7 +25330,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -24994,14 +25369,13 @@ "node": ">=12" } }, - "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", - "optional": true, - "peer": true, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18.0.0" } }, "node_modules/react-devtools-core": { @@ -25009,7 +25383,6 @@ "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "optional": true, - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -25020,7 +25393,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -25040,8 +25412,7 @@ "node_modules/react-is": { "version": "18.3.1", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/react-is-18": { "name": "react-is", @@ -25062,7 +25433,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.3.tgz", "integrity": "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==", "optional": true, - "peer": true, "dependencies": { "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", @@ -25135,7 +25505,6 @@ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", "optional": true, - "peer": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -25147,15 +25516,13 @@ "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/react-native/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "optional": true, - "peer": true, "engines": { "node": ">=10" }, @@ -25168,7 +25535,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -25178,7 +25544,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "optional": true, - "peer": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -25193,7 +25558,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "optional": true, - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -25210,16 +25574,6 @@ } } }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -25425,15 +25779,13 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/regenerate-unicode-properties": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", "optional": true, - "peer": true, "dependencies": { "regenerate": "^1.4.2" }, @@ -25445,8 +25797,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/regexp-to-ast": { "version": "0.5.0", @@ -25458,7 +25809,6 @@ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", "optional": true, - "peer": true, "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", @@ -25499,15 +25849,13 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "optional": true, - "peer": true + "optional": true }, "node_modules/regjsparser": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "optional": true, - "peer": true, "dependencies": { "jsesc": "~3.1.0" }, @@ -25867,8 +26215,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", - "optional": true, - "peer": true + "optional": true }, "node_modules/responselike": { "version": "2.0.1", @@ -26365,8 +26712,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "optional": true, - "peer": true + "optional": true }, "node_modules/scrypt-js": { "version": "3.0.1", @@ -26415,7 +26761,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "optional": true, - "peer": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -26440,7 +26785,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "optional": true, - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -26449,15 +26793,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/send/node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -26467,7 +26809,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "optional": true, - "peer": true, "bin": { "mime": "cli.js" }, @@ -26479,15 +26820,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/send/node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "optional": true, - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -26500,7 +26839,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -26625,7 +26963,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "optional": true, - "peer": true, "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", @@ -26641,7 +26978,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -26706,8 +27042,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/sha.js": { "version": "2.4.12", @@ -26932,7 +27267,6 @@ "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", "optional": true, - "peer": true, "engines": { "node": ">=8.0.0" } @@ -27110,6 +27444,7 @@ "version": "5.1.7", "hasInstallScript": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -27167,15 +27502,13 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", "optional": true, - "peer": true, "dependencies": { "type-fest": "^0.7.1" }, @@ -27188,7 +27521,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -27206,7 +27538,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -27400,8 +27731,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/sucrase": { "version": "3.35.1", @@ -27458,7 +27788,6 @@ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", "optional": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -27496,6 +27825,7 @@ "version": "3.4.17", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -27636,7 +27966,6 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -27681,7 +28010,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -27736,7 +28064,6 @@ "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", "optional": true, - "peer": true, "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" @@ -27753,7 +28080,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "optional": true, - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -27771,8 +28097,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "optional": true, - "peer": true + "optional": true }, "node_modules/test-exclude": { "version": "6.0.0", @@ -27857,8 +28182,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/through": { "version": "2.3.8", @@ -27963,7 +28287,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "optional": true, - "peer": true, "engines": { "node": ">=0.6" } @@ -27972,8 +28295,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", - "optional": true, - "peer": true + "optional": true }, "node_modules/tr46": { "version": "0.0.3", @@ -28100,6 +28422,7 @@ "version": "10.9.2", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -28462,6 +28785,7 @@ "version": "5.2.2", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28557,7 +28881,6 @@ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -28567,7 +28890,6 @@ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "optional": true, - "peer": true, "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -28581,7 +28903,6 @@ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -28591,7 +28912,6 @@ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "optional": true, - "peer": true, "engines": { "node": ">=4" } @@ -28652,7 +28972,6 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "optional": true, - "peer": true, "engines": { "node": ">= 0.8" } @@ -28764,7 +29083,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "optional": true, - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -28813,7 +29131,6 @@ "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "optional": true, - "peer": true, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -28853,6 +29170,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -29337,12 +29655,19 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "optional": true, - "peer": true + "optional": true + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" }, "node_modules/vue": { "version": "3.5.13", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.13", "@vue/compiler-sfc": "3.5.13", @@ -29443,6 +29768,23 @@ "vue": "^3.2.0" } }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/walker": { "version": "1.0.8", "devOptional": true, @@ -29474,6 +29816,12 @@ "node": ">= 8" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/web3-utils": { "version": "1.10.4", "license": "LGPL-3.0", @@ -29528,12 +29876,34 @@ "npm": ">=3.10.0" } }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "optional": true, - "peer": true + "optional": true }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -29547,8 +29917,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", - "optional": true, - "peer": true + "optional": true }, "node_modules/which": { "version": "2.0.2", @@ -29716,6 +30085,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 370ae282..b1088b96 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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", "check:dependencies": "./scripts/check-dependencies.sh", @@ -150,6 +151,8 @@ "@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", @@ -196,6 +199,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", @@ -281,6 +285,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" } } diff --git a/scripts/auto-run.sh b/scripts/auto-run.sh index 51f43631..47460df5 100755 --- a/scripts/auto-run.sh +++ b/scripts/auto-run.sh @@ -204,7 +204,7 @@ run_android() { safe_execute "Launching app" "adb -s $device_id shell am start -n app.timesafari.app/app.timesafari.MainActivity" else log_info "Launching emulator and installing app" - safe_execute "Launching app" "npx cap run android" + safe_execute "Launching app" "npx cap run android --no-sync" fi } diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 6b18c015..ef048b62 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -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" @@ -645,11 +645,13 @@ if [ "$BUILD_AAB" = true ]; then fi # Step 11: Auto-run app if requested +# cap run runs sync by default, which would overwrite capacitor.plugins.json again; +# we already synced and ran restore-local-plugins.js above, so skip sync here. if [ "$AUTO_RUN" = true ]; then log_step "Auto-running Android app..." - safe_execute "Launching app" "npx cap run android" || { + safe_execute "Launching app" "npx cap run android --no-sync" || { log_error "Failed to launch Android app" - log_info "You can manually run with: npx cap run android" + log_info "You can manually run with: npx cap run android --no-sync" exit 9 } log_success "Android app launched successfully!" diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh index 11e9a872..05cdee08 100755 --- a/scripts/build-ios.sh +++ b/scripts/build-ios.sh @@ -351,6 +351,7 @@ build_ios_app() { 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; @@ -443,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 @@ -467,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 diff --git a/scripts/common.sh b/scripts/common.sh index bd142167..bde81ff1 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -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 diff --git a/src/App.vue b/src/App.vue index 13052500..0450e25c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -360,6 +360,7 @@ diff --git a/src/components/dev/NotificationDebugPanel.vue b/src/components/dev/NotificationDebugPanel.vue new file mode 100644 index 00000000..63b40a30 --- /dev/null +++ b/src/components/dev/NotificationDebugPanel.vue @@ -0,0 +1,581 @@ + + + diff --git a/src/composables/useNotifications.ts b/src/composables/useNotifications.ts index ca7bfd23..2f04ec59 100644 --- a/src/composables/useNotifications.ts +++ b/src/composables/useNotifications.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { inject } from "vue"; +import { inject, onBeforeUnmount, onMounted } from "vue"; import { NotificationIface } from "../constants/app"; +import { registerToken } from "@/services/notifications/NotificationService"; +import { refreshNotifications } from "@/services/notifications/NativeNotificationService"; /** * Vue 3 composable for notifications @@ -29,6 +31,38 @@ export function useNotifications() { ); } + let refreshTimer: number | undefined = undefined; + let refreshInFlight: Promise | null = null; + + async function refreshNotificationsDebounced(): Promise { + if (refreshTimer != null) { + window.clearTimeout(refreshTimer); + } + refreshTimer = window.setTimeout(() => { + if (!refreshInFlight) { + refreshInFlight = refreshNotifications().finally(() => { + refreshInFlight = null; + }); + } + }, 300); + } + + const onResume = () => { + void refreshNotificationsDebounced(); + }; + + onMounted(() => { + void refreshNotificationsDebounced(); + document.addEventListener("resume", onResume); + }); + + onBeforeUnmount(() => { + document.removeEventListener("resume", onResume); + if (refreshTimer != null) { + window.clearTimeout(refreshTimer); + } + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars function success(_notification: NotificationIface, _timeout?: number) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -93,5 +127,8 @@ export function useNotifications() { notAGive, notificationOff, downloadStarted, + /** POST FCM token to `/notifications/register` (same as startup native hook). */ + registerFcmToken: registerToken, + refreshNotifications: refreshNotificationsDebounced, }; } diff --git a/src/constants/accountView.ts b/src/constants/accountView.ts index 3953b264..8e72db94 100644 --- a/src/constants/accountView.ts +++ b/src/constants/accountView.ts @@ -40,6 +40,8 @@ export const ACCOUNT_VIEW_CONSTANTS = { NO_PROFILE_LOCATION: "No profile location is saved.", RELOAD_VAPID: "Now reload the app to get a new VAPID to use with this push server.", + NOTIFY_SERVER_INFO: + "The notify server URL can be modified on the Notification Debug screen.", }, // Warning messages @@ -57,11 +59,20 @@ export const ACCOUNT_VIEW_CONSTANTS = { // Notification messages NOTIFICATIONS: { NEW_ACTIVITY_INFO: ` - This will only notify you when there is new relevant activity for you personally. - Note that it runs on your device and many factors may affect delivery, - so if you want a reliable but simple daily notification then choose a 'Reminder'. + This will notify you with an app message when there is new relevant activity for you personally. Do you want more details? `, + NEW_ACTIVITY_SMS_INFO: ` + This is opt-in: by turning it on you agree to receive text messages when there is new relevant activity for you personally. + Standard message and data rates may apply, and you can turn it off at any time. + Do you want more details? + `, + SMS_OPT_IN_CHOICE: ` + This is opt-in: by entering your phone number you agree to receive text messages when there is new relevant activity for you personally. + Standard message and data rates may apply, and you can turn it off at any time. + `, + SMS_FORGET_PHONE_CONFIRM: + "This will remove your phone number and turn off all text messages. You will have to verify a number again to re-enable them. Are you sure?", REMINDER_INFO: ` This will notify you at a specific time each day. Note that it does not give you personalized notifications, diff --git a/src/constants/app.ts b/src/constants/app.ts index 2c8cd1e1..14ad730d 100644 --- a/src/constants/app.ts +++ b/src/constants/app.ts @@ -26,6 +26,10 @@ export enum AppString { TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", + PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app", + TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app", + LOCAL_NOTIFY_API_SERVER = "http://127.0.0.1:3003", + NO_CONTACT_NAME = "(no name)", } @@ -47,6 +51,11 @@ export const DEFAULT_PARTNER_API_SERVER = export const DEFAULT_PUSH_SERVER = import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER; +/** Base URL of the notify-api (FCM wakeup registration and SMS notifications). */ +export const DEFAULT_NOTIFY_API_SERVER = + import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER || + AppString.PROD_NOTIFY_API_SERVER; + export const IMAGE_TYPE_PROFILE = "profile"; /** diff --git a/src/constants/backgroundJwt.ts b/src/constants/backgroundJwt.ts new file mode 100644 index 00000000..7da1f44b --- /dev/null +++ b/src/constants/backgroundJwt.ts @@ -0,0 +1,27 @@ +/** + * JWT lifetime for the single-token native background prefetch path + * (`accessTokenForBackgroundNotifications`). Confirm the maximum `exp` Endorser + * accepts before raising this. + */ +export const BACKGROUND_JWT_EXPIRY_DAYS = 90; + +export const BACKGROUND_JWT_EXPIRY_SECONDS = + BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60; + +/** Seconds in a UTC day; the frame every pool slot is cut from. */ +export const BACKGROUND_JWT_SECONDS_PER_DAY = 24 * 60 * 60; + +/** + * Consecutive UTC days the native background prefetch pool covers, one JWT per + * day. This is the whole forward grant the user authorizes in a single mint: + * past the last day the pool carries no credential and prefetch stops until the + * app opens again. See `doc/background-jwt-pool.md`. + */ +export const BACKGROUND_JWT_POOL_SIZE = 100; + +/** + * Padding on each end of a day's validity window, for clock skew between the + * device and Endorser. Widening a window is safe; narrowing it can leave a + * prefetch inside the day with no usable token. + */ +export const BACKGROUND_JWT_WINDOW_SLACK_SECONDS = 5 * 60; diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index e98383a3..ce252f1d 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -1635,12 +1635,18 @@ export const NOTIFY_PUSH_SETUP_UNDERWAY = { "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.", }; -// Used in: PushNotificationPermission.vue (turnOnNotifications method - success) +// Used in: PushNotificationPermission.vue (turnOnNotifications method - success, Daily Reminder) export const NOTIFY_PUSH_SUCCESS = { title: "Notifications On", message: "Daily Reminder notifications are now enabled.", }; +// Used in: PushNotificationPermission.vue (turnOnNotifications method - success, New Activity only) +export const NOTIFY_PUSH_SUCCESS_NEW_ACTIVITY = { + title: "Notifications On", + message: "New Activity notifications are now enabled.", +}; + // Used in: PushNotificationPermission.vue (turnOnNotifications method - general error) export const NOTIFY_PUSH_SETUP_ERROR = { title: "Error Setting Notification Permissions", diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index b9ffc328..30291f1a 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -276,6 +276,17 @@ const MIGRATIONS = [ ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE; `, }, + { + name: "010_add_sms_notification_settings", + sql: ` + -- Verified mobile number this identity registered with the notify-api + ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsPhone TEXT; + -- Local time of day the notify-api was told to text, blank when off + ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsTime TEXT; + -- Master switch for the SMS channel; a paused channel keeps its number + ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE; + `, + }, ]; /** diff --git a/src/db/databaseUtil.ts b/src/db/databaseUtil.ts index 487742c9..b3aac0c7 100644 --- a/src/db/databaseUtil.ts +++ b/src/db/databaseUtil.ts @@ -258,13 +258,14 @@ export async function logToDb( try { const platform = PlatformServiceFactory.getInstance(); + const timestamp = new Date().toISOString(); const todayKey = new Date().toDateString(); try { - memoryLogs.push(`${new Date().toISOString()} ${message}`); + memoryLogs.push(`${timestamp} ${message}`); // Insert using actual schema: date, message (no level column) await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [ - todayKey, // Use date string to match schema + timestamp, `[${level.toUpperCase()}] ${message}`, // Include level in message ]); @@ -273,7 +274,7 @@ export async function logToDb( if (!lastCleanupDate || lastCleanupDate !== todayKey) { const sevenDaysAgo = new Date( new Date().getTime() - 7 * 24 * 60 * 60 * 1000, - ).toDateString(); // Use date string to match schema + ).toISOString(); memoryLogs = memoryLogs.filter( (log) => log.split(" ")[0] > sevenDaysAgo, ); diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 7e97e09a..b74e6cfd 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -50,11 +50,15 @@ export type Settings = { lastViewedClaimId?: string; notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server + notifyingNewActivitySmsPhone?: string; // verified mobile number registered with the notify-api for text alerts + notifyingNewActivitySmsTime?: string; // set to their chosen time if they have turned on new-activity texts notifyingReminderMessage?: string; // set to their chosen message for a daily reminder notifyingReminderTime?: string; // set to their chosen time for a daily reminder /** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */ reminderFastRolloverForTesting?: boolean; + smsNotificationsEnabled?: boolean; // master switch for the text-message channel + partnerApiServer?: string; // partner server API URL passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes diff --git a/src/interfaces/accountView.ts b/src/interfaces/accountView.ts index b4a336dc..bfd37ece 100644 --- a/src/interfaces/accountView.ts +++ b/src/interfaces/accountView.ts @@ -31,8 +31,12 @@ export interface AccountSettings { bbox: BoundingBox; }>; notifyingNewActivityTime?: string; + notifyingNewActivitySmsPhone?: string; + notifyingNewActivitySmsTime?: string; + smsNotificationsEnabled?: boolean; notifyingReminderMessage?: string; notifyingReminderTime?: string; + starredPlanHandleIds?: string[]; reminderFastRolloverForTesting?: boolean; partnerApiServer?: string; profileImageUrl?: string; @@ -79,6 +83,12 @@ export interface ProfileState { export interface NotificationState { notifyingNewActivity: boolean; notifyingNewActivityTime: string; + /** Master switch for the text-message channel. */ + smsEnabled: boolean; + /** Verified number, kept while the channel is paused so re-enabling is free. */ + notifyingNewActivitySmsPhone: string; + notifyingNewActivitySms: boolean; + notifyingNewActivitySmsTime: string; notifyingReminder: boolean; notifyingReminderMessage: string; notifyingReminderTime: string; diff --git a/src/interfaces/alertSearch.ts b/src/interfaces/alertSearch.ts new file mode 100644 index 00000000..1ccf85a1 --- /dev/null +++ b/src/interfaces/alertSearch.ts @@ -0,0 +1,125 @@ +/** + * alertSearch response item and envelope types. + * + * These buckets are new to this app. Item shapes are taken from the endorser-ch + * SELECT lists for alertSearch, not from similarly named existing report types. + * + * Existing types that were considered and not reused: + * - GenericCredWrapper — claims lack a `claim` body; extra jwt columns differ. + * - GiveSummaryRecord / OfferSummaryRecord — those use `jwtId` and give/offer + * summary fields; alertSearch jwt rows use `id` and jwt table columns. + * - PlanSummaryAndPreviousClaim — `/plansLastUpdatedBetween` wraps `{ plan, + * wrappedClaimBefore }`; alertSearch `trackedPlanUpdates` are plan_claim rows. + * - PlanSummaryRecord — overlapping plan fields, but the app type is a subset + * (missing fulfillsLinkConfirmed, result*, etc.) and required fields differ. + * - UserProfile — partner nearby rows include `updatedAt` / `rowId` and omit + * embedding flags that UserProfile models. + */ + +/** + * Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId / + * beforeId. Not an authentication JWT and not a delegated notification JWT. + */ +export type AlertSearchCursorUlid = string; + +/** + * JWT row from endorser `jwtsWithDidAfterId` (no claim body). + * Cursor field: `id`. + */ +export interface AlertSearchClaimRecord { + id: AlertSearchCursorUlid; + issuedAt: string; + issuer: string; + subject?: string; + claimType?: string; + handleId?: string; + fromEntity?: string; + toEntity?: string; +} + +/** + * JWT row from `jwtsForUserPlanContributions` and + * `jwtsGiveActionOfferForPlanHandleIds`. `claim` is the jwt table TEXT + * (canonical JSON string); alertSearch does not JSON.parse it. + * Cursor field: `id`. + */ +export interface AlertSearchJwtWithClaimRecord extends AlertSearchClaimRecord { + claim?: string; +} + +/** + * plan_claim row from `plansLastUpdatedBetween` and `plansByLocationAfterId`. + * Cursor field: `jwtId` (not `id`). + */ +export interface AlertSearchPlanRecord { + handleId: string; + jwtId: AlertSearchCursorUlid; + issuerDid?: string; + agentDid?: string; + fulfillsLinkConfirmed?: boolean | number; + fulfillsPlanClaimId?: string; + fulfillsPlanHandleId?: string; + name?: string; + description?: string; + image?: string; + endTime?: string; + startTime?: string; + locLat?: number; + locLon?: number; + resultDescription?: string; + resultIdentifier?: string; + url?: string; +} + +/** + * user_profile row from partner `profilesByLocationAfterDate`. + * Profiles have no JWT `id`; partner paging uses dates decoded from cursor ULIDs. + */ +export interface AlertSearchProfileRecord { + rowId?: number; + issuerDid: string; + updatedAt?: string; + description: string; + locLat?: number; + locLon?: number; + locLat2?: number; + locLon2?: number; +} + +export interface EndorserAlertSearchData { + claims: AlertSearchClaimRecord[]; + personalPlanContributions: AlertSearchJwtWithClaimRecord[]; + trackedPlanUpdates: AlertSearchPlanRecord[]; + trackedPlanClaims: AlertSearchJwtWithClaimRecord[]; + plansNearby: AlertSearchPlanRecord[]; +} + +export interface PartnerAlertSearchData { + profilesNearby: AlertSearchProfileRecord[]; +} + +/** + * Endorser GET/POST /api/v2/report/alertSearch body. + * Per-bucket SQL hitLimit is not currently copied onto this envelope. + * Timeouts may set `userMessage` instead. + */ +export interface EndorserAlertSearchResponse { + data: EndorserAlertSearchData; + userMessage?: string; +} + +/** + * Partner GET/POST /api/partner/alertSearch body. + */ +export interface PartnerAlertSearchResponse { + data: PartnerAlertSearchData; + userMessage?: string; +} + +/** + * Union of the six alertSearch buckets for a future combined daily run. + * Not returned by a single server endpoint today. + */ +export interface CombinedAlertSearchData + extends EndorserAlertSearchData, + PartnerAlertSearchData {} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 8197df86..10d379dc 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1,7 +1,9 @@ +export * from "./alertSearch"; export * from "./claims"; export * from "./claims-result"; export * from "./common"; export * from "./deepLinks"; export * from "./limits"; +export * from "./notifyApi"; export * from "./records"; export * from "./user"; diff --git a/src/interfaces/notifyApi.ts b/src/interfaces/notifyApi.ts new file mode 100644 index 00000000..57fb412a --- /dev/null +++ b/src/interfaces/notifyApi.ts @@ -0,0 +1,424 @@ +/** + * Wire payloads for the notify-api (notification-wakeup-service): request + * bodies, query parameters, the JWT payloads the app signs for it, and the + * bodies it answers with. + * + * Each type names the route it belongs to and holds the fields that route reads + * or writes; a field the app sends and the service ignores is marked "Not read + * by the service." The service's code is the source of truth, in + * notification-wakeup-service: `src/routes/notifications.ts`, + * `src/routes/notifySms.ts`, `src/routes/debug.ts`, `src/middleware/`, and + * `src/services/alertAuthorization.ts`. + * + * Two kinds of JWT travel to the service: + * - The Bearer token on each request authenticates the caller, and the service + * takes the DID from it; no body carries the DID. On `/notify-sms` that token + * also carries an {@link SmsNotificationActionClaim}. + * - The delegated JWTs inside an {@link AlertAuthorizationRequestBody} are + * stored credentials the service later presents to Endorser and Partner. + * They authenticate nothing about the upload that carries them. + * + * Refusals come in these shapes, by where they are raised: + * - {@link NotifyApiUncodedFailure}: a message and no code, from the auth + * stages in front of every authenticated route, and from a push + * alert-authorization route that fails to store or delete. + * - {@link NotificationDeviceRouteFailure}: a sentence under `error`, from + * register and refresh. + * - {@link DebugSendWakeupResponse} with `success: false`. + * - {@link AlertAuthorizationBatchFailure} and {@link NotifySmsFailure}: coded. + * + * A proxy in front of the service can answer with none of these, or with no + * JSON at all. + */ + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +/** + * Refusal with a message and no code. The auth stages send it with 401 for a + * missing, invalid, or Endorser-rejected Bearer token and 503 when Endorser + * cannot be reached; their messages end with a timestamp for finding the server + * log. The push alert-authorization routes send it with 500 when storing or + * deleting fails. + */ +export interface NotifyApiUncodedFailure { + success: false; + message: string; +} + +// --------------------------------------------------------------------------- +// Device routes: /notifications/register, /notifications/refresh, /debug +// --------------------------------------------------------------------------- + +/** + * Local-test switch on the device routes. `true` with no `Authorization` + * header makes the service skip JWT and Endorser checks and file the request + * under a synthetic test user. A request carrying a Bearer token is + * authenticated normally whatever this says, and no other route reads it. + */ +export interface NotifyApiTestModeFlag { + testMode?: boolean; +} + +/** + * Body of `POST /notifications/register`: store this device's FCM token under + * the caller's DID, keyed by `deviceId`. Success is a bare 200 with a + * plain-text body; a failure to store is a bare 500. + */ +export interface NotificationRegisterRequest extends NotifyApiTestModeFlag { + /** Stable per-install id; trimmed by the service and required non-empty. */ + deviceId: string; + /** Required non-empty. */ + fcmToken: string; + /** `Capacitor.getPlatform()`: `"ios"`, `"android"`, or `"web"`. Required non-empty. */ + platform: string; + /** The DID comes from the Bearer token; a body with a `userId` key is rejected. */ + userId?: never; +} + +/** + * Body of `POST /notifications/refresh`. The service finds the caller's device + * by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400 + * when neither is present and non-empty. When both are sent they must name the + * same device, or the answer is 404. + */ +export type NotificationRefreshRequest = NotifyApiTestModeFlag & { + /** Not read by the service. */ + platform?: string; +} & ( + | { deviceId: string; fcmToken?: string } + | { deviceId?: string; fcmToken: string } + ); + +/** Success body of `POST /notifications/refresh`. */ +export interface NotificationRefreshResponse { + shouldNotify: boolean; + /** Instants for the device to schedule, as Unix milliseconds. */ + nextNotifications: Array<{ timestamp: number }>; +} + +/** + * Refusal from `POST /notifications/register` (400) or + * `POST /notifications/refresh` (400, or 404 when no device of the caller's + * matches). `error` is a sentence such as `"Device not found"`, not a code. + */ +export interface NotificationDeviceRouteFailure { + error: string; +} + +/** + * Body of `POST /debug/send-wakeup`, which pushes a WAKEUP_PING to one of the + * caller's devices. The `/debug` routes exist only on a service started with + * `DEBUG_ENDPOINT` on. + */ +export interface DebugSendWakeupRequest extends NotifyApiTestModeFlag { + /** A token registered under the caller's DID; required non-empty. */ + fcmToken: string; + /** Not read by the service. */ + deviceId?: string; + /** Not read by the service. */ + platform?: string; +} + +/** + * Every answer `POST /debug/send-wakeup` writes itself. A 200 still carries + * `success: false` when the push was skipped or failed, so `success`, not the + * status, says whether a WAKEUP_PING went out. + */ +export interface DebugSendWakeupResponse { + success: boolean; + /** + * Why no push went out: `"fcmToken is required"` (400), `"Device not found"` + * (404), or with a 200, `"Device was notified within the eligibility + * threshold"` or `"FCM send failed"`. + */ + failureReason?: string; + /** Last six characters of the token; absent on the 400. */ + fcmTokenSuffix?: string; +} + +// --------------------------------------------------------------------------- +// Alert authorization: /notifications/alert-authorization and +// /notify-sms/alert-authorization +// --------------------------------------------------------------------------- + +/** + * Payload the app signs for one {@link DelegatedAlertJwt}; the signer adds + * `iss` (the DID) and `iat`. The service requires the signed `nbf` and `exp` to + * equal the values listed beside the JWT. + */ +export interface DelegatedAlertJwtPayload { + /** Unix seconds, at or before the midnight UTC that opens the entry's `day`. */ + nbf: number; + /** Unix seconds, at or after the midnight UTC that closes the entry's `day`. */ + exp: number; +} + +/** One day's delegated credential inside an {@link AlertAuthorizationRequestBody}. */ +export interface DelegatedAlertJwt { + /** Integer; the batch's values are consecutive, starting anywhere. */ + sequence: number; + /** UTC calendar day, `YYYY-MM-DD`, distinct across the batch. */ + day: string; + /** Unix seconds; equal to the signed `nbf`. */ + nbf: number; + /** Unix seconds; equal to the signed `exp`. */ + exp: number; + /** Signed by the caller's DID, which must be a `did:ethr`. */ + jwt: string; +} + +/** + * Body of `PUT /notifications/alert-authorization` (push) and + * `POST /notify-sms/alert-authorization` (SMS), which validate it with one + * rule. A stored batch replaces the unused inventory the DID holds in that + * channel; JWTs already spent stay behind. + */ +export interface AlertAuthorizationRequestBody { + /** Required non-empty; echoed in the response. */ + batchId: string; + /** UTC hour, integer 0-23. */ + notifyHourUtc: number; + /** UTC minute, integer 0-59. */ + notifyMinuteUtc: number; + /** + * IANA zone name such as `"America/Denver"`. Validated and stored; no + * scheduling decision reads it. + */ + timezone?: string; + /** Exactly 100 entries. */ + jwts: DelegatedAlertJwt[]; +} + +/** Success body of storing a batch, on either alert-authorization route. */ +export interface AlertAuthorizationResponse { + success: true; + batchId: string; + /** The stored UTC hour; null only for a batch stored without one. */ + notifyHourUtc: number | null; + /** The stored UTC minute; null only for a batch stored without one. */ + notifyMinuteUtc: number | null; + timezone: string | null; + /** JWTs stored from this batch. */ + storedCount: number; + /** Unused JWTs the DID holds in this channel once the batch is stored. */ + unusedCount: number; +} + +/** Success body of `DELETE` on either alert-authorization route. */ +export interface AlertAuthorizationRevokeResponse { + success: true; + /** Batches removed, spent ones included. */ + deletedBatches: number; + /** JWTs removed, spent ones included. */ + deletedJwts: number; +} + +/** + * Coded refusal of a batch (400) on either alert-authorization route: it failed + * validation, or the caller's identity cannot hold one. Nothing was stored. + */ +export interface AlertAuthorizationBatchFailure { + success: false; + error: + | "ALERT_AUTHORIZATION_INVALID_BATCH" + | "DELEGATED_JWT_UNSUPPORTED_IDENTITY"; + message: string; + /** Specific problems, at most 20 plus a count of the rest. */ + details: string[]; +} + +/** Refusal from `PUT` or `DELETE /notifications/alert-authorization`. */ +export type AlertAuthorizationFailure = + | AlertAuthorizationBatchFailure + | NotifyApiUncodedFailure; + +// --------------------------------------------------------------------------- +// SMS channel: /notify-sms +// --------------------------------------------------------------------------- + +/** + * What a `/notify-sms` action claim authorizes. The service checks `action` + * against the route and, for handset actions, checks that `phoneNumber` + * normalizes to the same E.164 number the request names. + */ +export type SmsActionTarget = + // Bound to one handset; the number must match the request's. + | { + action: "register-phone" | "verify-phone" | "delete-phone"; + phoneNumber: string; + } + // Bound to a number only when the request sends `?phoneNumber=`. + | { action: "list-phones"; phoneNumber?: string } + // Acts on the DID's whole alert authorization; the service reads no number. + | { + action: "authorize-alert-search" | "revoke-alert-search"; + phoneNumber?: undefined; + }; + +/** Actions the service's `requireSmsActionJwt` stage recognizes, one per route. */ +export type SmsAction = SmsActionTarget["action"]; + +/** The `claim` in the Bearer JWT on every `/notify-sms` request. */ +export type SmsNotificationActionClaim = { + /** Claim namespace; distinct from `https://giftopia.me`, the link in the texts. */ + "@context": "https://giftopia.tech"; + "@type": "SmsNotificationAction"; +} & SmsActionTarget; + +/** + * Payload the app signs as the Bearer JWT on every `/notify-sms` request; the + * signer adds `iss`, `iat`, and `exp`. The service refuses a token whose `iat` + * is further than `SMS_ACTION_JWT_MAX_AGE_SEC` from its clock or whose `exp` + * has passed, and accepts each token once. + */ +export interface SmsActionJwtPayload { + claim: SmsNotificationActionClaim; +} + +/** + * Query of `GET /notify-sms/phone`. With `phoneNumber`, the response adds the + * DIDs verified on that number, which the service reveals only to a DID that + * has verified it too. + */ +export interface SmsPhoneListQuery { + phoneNumber?: string; +} + +/** One of the caller's registrations, as `GET /notify-sms/phone` lists it. */ +export interface SmsPhoneRegistration { + /** The full E.164 number; these are the caller's own. */ + phoneNumber: string; + verified: boolean; + /** ISO 8601; null until verified. */ + verifiedAt: string | null; + /** ISO 8601. */ + createdAt: string; +} + +/** + * Success body of `GET /notify-sms/phone`, oldest registration first. An + * identity with no registrations gets an empty list. + */ +export interface SmsPhoneListResponse { + success: true; + phones: SmsPhoneRegistration[]; + /** The queried number, normalized; present exactly when `?phoneNumber=` was sent. */ + phoneNumber?: string; + /** + * Every DID verified on the queried number, the caller's included; present + * exactly when `?phoneNumber=` was sent. + */ + dids?: string[]; +} + +/** Body of `POST /notify-sms/phone`: record the number, unverified, and text it a code. */ +export interface SmsPhoneRegisterRequest { + /** E.164; the service normalizes, taking ten bare digits as a US number. */ + phoneNumber: string; +} + +/** + * Success body of `POST /notify-sms/phone`. `phoneNumber` is masked, such as + * `+1555*****23`, so it cannot stand in for the number that was sent. + */ +export type SmsPhoneRegisterResponse = + // The caller had already verified this number; no text was sent. + | { success: true; phoneNumber: string; verified: true } + | { + success: true; + phoneNumber: string; + verified: false; + /** ISO 8601; when the texted code stops working. */ + expiresAt: string; + /** + * The plaintext code, only from a service run with both + * `NODE_ENV=test-local` and `SMS_DEV_ECHO_CODE`. + */ + devCode?: string; + }; + +/** Body of `PUT /notify-sms/phone`: match the texted code. */ +export interface SmsPhoneVerifyRequest { + phoneNumber: string; + /** The six digits as texted. */ + code: string; +} + +/** + * Success body of `PUT /notify-sms/phone`, also for a number the caller had + * already verified. `phoneNumber` is masked. + */ +export interface SmsPhoneVerifyResponse { + success: true; + phoneNumber: string; + verified: true; +} + +/** + * Body, and also query, of `DELETE /notify-sms/phone`. The service reads the + * body first and falls back to the query, because proxies may drop DELETE + * bodies. + */ +export interface SmsPhoneDeleteRequest { + phoneNumber: string; +} + +/** Success body of `DELETE /notify-sms/phone`; `deleted` is false when the number was not registered. */ +export interface SmsPhoneDeleteResponse { + success: true; + deleted: boolean; +} + +/** Codes of `/notify-sms` refusals that carry nothing beyond the message. */ +export type NotifySmsPlainErrorCode = + | "SMS_DISABLED" // 503 + | "SMS_NOT_CONFIGURED" // 500 + | "SMS_PHONE_INVALID" // 400 + | "SMS_PHONE_BLOCKED" // 403 + | "SMS_PHONE_NOT_VERIFIED_BY_CALLER" // 403 + | "SMS_RECIPIENT_NOT_ALLOWED" // 403 + | "SMS_CODE_RATE_LIMITED" // 429 + | "SMS_CODE_SEND_FAILED" // 502 + | "SMS_CODE_EXPIRED" // 400 + | "SMS_CODE_ATTEMPTS_EXHAUSTED" // 429 + | "SMS_NO_VERIFIED_PHONE" // 409 + | "SMS_ALERT_AUTHORIZATION_FAILED" // 500 + | "SMS_ALERT_AUTHORIZATION_DELETE_FAILED" // 500 + | "SMS_ACTION_JWT_NOT_AUTHENTICATED" // 500 + | "SMS_ACTION_JWT_MISSING_CLAIM" // 403 + | "SMS_ACTION_JWT_WRONG_ACTION" // 403 + | "SMS_ACTION_JWT_PHONE_MISMATCH" // 403 + | "SMS_ACTION_JWT_STALE" // 401 + | "SMS_ACTION_JWT_EXPIRED" // 401 + | "SMS_ACTION_JWT_REPLAYED"; // 401 + +/** + * A coded refusal from any `/notify-sms` route. The auth stages in front of + * these routes refuse with a {@link NotifyApiUncodedFailure} instead. + */ +export type NotifySmsFailure = + | { success: false; error: NotifySmsPlainErrorCode; message: string } + | { + success: false; + error: "SMS_CODE_MISMATCH"; // 400 + message: string; + /** Wrong codes left before this code is cleared. */ + attemptsRemaining: number; + } + | { + success: false; + error: "SMS_PHONE_DID_LIMIT"; // 409 + message: string; + /** Most identities one number may carry. */ + limit: number; + /** Identities other than the caller already verified on the number. */ + verifiedCount: number; + /** + * Every DID verified on the number. Sent only by `PUT`, whose caller has + * just proved possession of the handset. + */ + dids?: string[]; + } + | AlertAuthorizationBatchFailure; diff --git a/src/libs/alertSearch.ts b/src/libs/alertSearch.ts new file mode 100644 index 00000000..98589e8e --- /dev/null +++ b/src/libs/alertSearch.ts @@ -0,0 +1,69 @@ +/** + * Typed alertSearch API contract only. No HTTP client yet. + * + * Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from + * `@/constants/app`. Do not duplicate those constants here. + * + * JWT kinds (do not mix these): + * - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as + * `Authorization: Bearer` for interactive API calls (`accessToken` / + * `getHeaders`). Identifies the requester DID. + * - Delegated notification JWT: a batch of 100 tokens, each valid for one whole + * UTC day, from `mintAlertAuthorizationBatch` + * (`@/services/notifications/alertAuthorizationBatch`), uploaded to the + * notify-api's push or SMS `alert-authorization` route. + * - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification + * plugin prefetch. Unrelated to alertSearch and to the delegated batch. + * - alertSearch cursor ULID: server-issued record/JWT primary id (26-char + * ULID). `afterId` means ids strictly greater than that ULID; `beforeId` + * means strictly less. First daily run omits afterId. `beforeId` is for + * pagination within a run. These are not auth JWTs and not signed tokens. + * + * Truncation: each endorser bucket query uses a server hit-limit (typically + * 50). That flag is not currently returned on the alertSearch JSON envelope. + * Timeouts may add `userMessage`. A later caller must still paginate with + * beforeId when a bucket may be incomplete. + */ + +import type { + AlertSearchCursorUlid, + CombinedAlertSearchData, + EndorserAlertSearchResponse, + PartnerAlertSearchResponse, +} from "@/interfaces/alertSearch"; + +export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch"; +export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch"; + +export interface AlertSearchLocationBBox { + minLocLat: number; + maxLocLat: number; + minLocLon: number; + maxLocLon: number; +} + +/** + * Query/body params accepted by endorser and partner alertSearch (GET or POST). + * GET is the planned daily method; the server also accepts POST. + */ +export interface AlertSearchRequestParams { + afterId?: AlertSearchCursorUlid; + beforeId?: AlertSearchCursorUlid; + afterDate?: string; + beforeDate?: string; + location?: AlertSearchLocationBBox; + minLocLat?: number; + maxLocLat?: number; + minLocLon?: number; + maxLocLon?: number; + planHandleIds?: string[]; + planIds?: string[]; + handleIds?: string[]; +} + +export type { + AlertSearchCursorUlid, + CombinedAlertSearchData, + EndorserAlertSearchResponse, + PartnerAlertSearchResponse, +}; diff --git a/src/libs/crypto/backgroundJwtPool.ts b/src/libs/crypto/backgroundJwtPool.ts new file mode 100644 index 00000000..aabe1308 --- /dev/null +++ b/src/libs/crypto/backgroundJwtPool.ts @@ -0,0 +1,93 @@ +/** + * Day-scoped JWT pool for native New Activity background prefetch. + * + * Kept apart from `@/libs/crypto` so minting depends only on the signer and the + * pool constants: the prefetch path is the one place in the app where a + * credential is handed to native code and used without any JavaScript running, + * and its dependencies should stay small enough to read in one sitting. + */ + +import { + BACKGROUND_JWT_POOL_SIZE, + BACKGROUND_JWT_SECONDS_PER_DAY, + BACKGROUND_JWT_WINDOW_SLACK_SECONDS, +} from "@/constants/backgroundJwt"; +import type { KeyMetaWithPrivate } from "@/interfaces/common"; +import { createEndorserJwtForKey } from "./vc"; + +/** Thrown for identities whose keys cannot sign a day-scoped background pool. */ +export class BackgroundJwtUnsupportedIdentityError extends Error { + constructor(message: string) { + super(message); + this.name = "BackgroundJwtUnsupportedIdentityError"; + } +} + +/** + * Mint one JWT per UTC day for native background prefetch + * (`configureNativeFetcher` `jwtTokens`), covering the + * {@link BACKGROUND_JWT_POOL_SIZE} consecutive days starting with the day of + * the call. + * + * Each token is valid only for the single day it covers: `nbf` at that day's + * opening midnight and `exp` at its closing one, each widened by + * {@link BACKGROUND_JWT_WINDOW_SLACK_SECONDS} for clock skew. A token that + * escapes through a log line or a captured header therefore buys one day of + * Endorser access rather than the whole grant. The day windows are also what + * make the tokens distinct: the ES256K signer is deterministic, so JWTs built + * from identical payloads are byte-identical, and a pool of identical strings + * defeats any duplicate-token rule the server may apply. + * + * Slot order is a contract with the native selector, which reads + * `pool[epochDay % pool.size()]` and has no record of when the pool was minted: + * the token covering a UTC day sits at index `epochDay % POOL_SIZE`. Any + * POOL_SIZE consecutive days hit every index exactly once, so the array is + * dense whatever day minting starts on. + * + * The caller supplies the decrypted account rather than a DID: signing + * {@link BACKGROUND_JWT_POOL_SIZE} tokens from a DID would decrypt the identity + * once per token, which costs seconds on a phone and runs on every app + * foreground. + * + * Passkey (`did:peer`) identities cannot mint this pool -- each signature is a + * WebAuthn assertion, and `createJwtNavigator` overrides the day window with its + * own one-minute `exp` -- so they raise + * {@link BackgroundJwtUnsupportedIdentityError} instead of prompting + * {@link BACKGROUND_JWT_POOL_SIZE} times for tokens that expire before the + * prefetch they were minted for. + * + * @param account decrypted identity the pool is issued by and for + * @throws BackgroundJwtUnsupportedIdentityError for passkey identities + */ +export async function mintBackgroundJwtTokenPool( + account: KeyMetaWithPrivate, +): Promise { + if (!account?.identity) { + throw new BackgroundJwtUnsupportedIdentityError( + `No day-scoped signing key for ${account?.did}; background prefetch ` + + "requires a seed-phrase identity.", + ); + } + const did = account.did; + const nowEpoch = Math.floor(Date.now() / 1000); + const firstEpochDay = Math.floor(nowEpoch / BACKGROUND_JWT_SECONDS_PER_DAY); + const tokens: string[] = new Array(BACKGROUND_JWT_POOL_SIZE); + for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) { + const epochDay = firstEpochDay + offset; + const dayStartEpoch = epochDay * BACKGROUND_JWT_SECONDS_PER_DAY; + const tokenPayload = { + nbf: dayStartEpoch - BACKGROUND_JWT_WINDOW_SLACK_SECONDS, + exp: + dayStartEpoch + + BACKGROUND_JWT_SECONDS_PER_DAY + + BACKGROUND_JWT_WINDOW_SLACK_SECONDS, + iat: nowEpoch, + iss: did, + }; + tokens[epochDay % BACKGROUND_JWT_POOL_SIZE] = await createEndorserJwtForKey( + account, + tokenPayload, + ); + } + return tokens; +} diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index c8bcfa41..8e46aee8 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -43,10 +43,41 @@ import "./utils/safeAreaInset"; // Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery) import "@timesafari/daily-notification-plugin"; +import { + configureNativeFetcherIfReady, + initializeNativePushAndFirebaseMessaging, + onNotificationAuthMayBeReady, +} from "@/services/notifications"; logger.log("[Capacitor] 🚀 Starting initialization"); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); +// Diagnostic: log DailyNotification methods from native PluginHeaders (helps debug UNIMPLEMENTED) +type CapacitorWindow = { + Capacitor?: { + PluginHeaders?: Array<{ name: string; methods?: Array<{ name: string }> }>; + }; +}; +const cap = + typeof window !== "undefined" + ? (window as unknown as CapacitorWindow).Capacitor + : undefined; +if (cap?.PluginHeaders) { + const dn = cap.PluginHeaders.find((h) => h.name === "DailyNotification"); + const methodNames = dn?.methods?.map((m) => m.name) ?? null; + logger.log( + "[Capacitor] DNP PluginHeaders methods:", + methodNames ?? "DailyNotification NOT IN HEADERS", + ); + if (methodNames && !methodNames.includes("scheduleDualNotification")) { + logger.warn( + "[Capacitor] scheduleDualNotification missing from PluginHeaders – native plugin may be stale; try clearing Xcode DerivedData and rebuilding", + ); + } +} else { + logger.warn("[Capacitor] Capacitor.PluginHeaders not present"); +} + const app = initializeApp(); // Initialize API error handling for unhandled promise rejections @@ -432,11 +463,15 @@ if ( if (isActive) { logger.debug("[Main] 📱 App became active, checking for shared image"); await checkForSharedImageAndNavigate(); + // Refresh JWT for background New Activity prefetch (WorkManager cannot run JS; + // short-lived tokens would expire between configure and T−5 fetch without this). + await configureNativeFetcherIfReady(); + onNotificationAuthMayBeReady(); } }); } -// Register deeplink listener after app is mounted +// Register deeplink listener and configure native notification fetcher after app is mounted setTimeout(async () => { try { logger.info( @@ -444,6 +479,10 @@ setTimeout(async () => { ); await registerDeepLinkListener(); logger.info(`[Main] 🎉 Deep link system fully initialized!`); + // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners) + await initializeNativePushAndFirebaseMessaging(); + // Configure native fetcher for API-driven daily notifications (activeDid + JWT) + await configureNativeFetcherIfReady(); } catch (error) { logger.error(`[Main] ❌ Deep link system initialization failed:`, error); } diff --git a/src/plugins/NotificationInspectorPlugin.ts b/src/plugins/NotificationInspectorPlugin.ts new file mode 100644 index 00000000..8acecc37 --- /dev/null +++ b/src/plugins/NotificationInspectorPlugin.ts @@ -0,0 +1,17 @@ +import { registerPlugin } from "@capacitor/core"; + +export type PendingNotificationInfo = { + identifier: string; + nextTriggerDate?: number | null; + triggerType?: string | null; + /** Epoch ms for intended fire time when known (userInfo or API notification id); stable across refresh. */ + wallClockMillis?: number | null; + wallClockSource?: string | null; +}; + +export interface NotificationInspectorPlugin { + getPendingNotifications(): Promise<{ pending: PendingNotificationInfo[] }>; +} + +export const NotificationInspector = + registerPlugin("NotificationInspector"); diff --git a/src/router/index.ts b/src/router/index.ts index ab6502d1..01d1db12 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -10,6 +10,7 @@ import { retrieveAccountDids, generateSaveAndActivateIdentity, } from "../libs/util"; +import { includeDevToolkitRoutes } from "../utils/includeDevToolkitRoutes"; const routes: Array = [ { @@ -295,6 +296,19 @@ const routes: Array = [ name: "user-profile", component: () => import("../views/UserProfileView.vue"), }, + ...(includeDevToolkitRoutes + ? ([ + { + path: "/dev/notifications", + name: "dev-notifications", + component: () => import("../views/dev/NotificationDebugView.vue"), + meta: { + title: "Notification Debug", + requiresAuth: false, + }, + }, + ] satisfies Array) + : []), // Catch-all route for 404 errors - must be last { path: "/:pathMatch(.*)*", @@ -429,6 +443,18 @@ router.beforeEach(async (to, _from, next) => { // sessionStorage may be unavailable } + // Keep diagnostic pages reachable when identity creation fails (e.g. a + // failed migration leaves every DB call throwing), so the user can open + // Profile, then Advanced Settings, then the Test Page or Logs, instead of + // being bounced back to /start forever. + const diagnosticRoutes = ["/account", "/test", "/logs"]; + if (diagnosticRoutes.includes(to.path)) { + logger.info( + `[Router] 🩺 Allowing diagnostic route ${to.path} despite identity creation failure`, + ); + return next(); + } + // Redirect to start page if identity creation fails // This allows users to manually create an identity or troubleshoot logger.info( diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index 860a9d32..418802e0 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -12,7 +12,32 @@ */ import { Capacitor } from "@capacitor/core"; +import type { PushNotificationSchema } from "@capacitor/push-notifications"; +import type { + NotificationRefreshRequest, + NotificationRefreshResponse, +} from "@/interfaces/notifyApi"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { getOrCreateDeviceId } from "./deviceId"; +import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds"; +import { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; +import { + getNotificationApiBaseUrl, + getTestMode, +} from "./NotificationDebugConfig"; +import { + logRefreshFailure, + logRefreshStarted, + logRefreshSuccess, + logScheduleReplacement, +} from "./notificationLog"; +import { + getNotificationApiHeaders, + logSkippingRefreshDueToMissingAuth, + notificationApiFailureMessage, + readNotificationApiBody, +} from "./notificationApiAuth"; +import { logNotification } from "./NotificationDebugEvents"; /** * Extended type for DailyNotification that includes the actual Swift implementation @@ -44,10 +69,10 @@ export class NativeNotificationService implements NotificationServiceInterface { private readonly platformName = "native"; /** - * Stable schedule/reminder ID used for schedule, cancel, and getStatus. - * Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id). + * Stable schedule/reminder ID for the Daily Reminder feature only. + * New Activity uses the dual schedule (scheduleDualNotification) and does not use this ID. */ - private readonly reminderId = "daily_timesafari_reminder"; + private readonly reminderId = REMINDER_ID_DAILY_REMINDER; /** * Ensures only one scheduleDailyNotification runs at a time (no rapid successive plugin calls). @@ -537,3 +562,204 @@ export class NativeNotificationService implements NotificationServiceInterface { return this.platformName; } } + +export type RefreshNotificationsResult = { + ok: boolean; + scheduledCount: number; + status?: number; + errorMessage?: string; +}; + +/** + * Re-applies native API fetcher credentials (JWT pool, active DID) so background + * notification workers can run. No UI; safe from push handlers while backgrounded. + */ +export async function refreshNotificationsWithDiagnostics(options?: { + source?: string; +}): Promise { + const startedAt = performance.now(); + const source = options?.source; + logRefreshStarted(source); + + if (!Capacitor.isNativePlatform()) { + const errorMessage = "not a native platform"; + logRefreshFailure(startedAt, errorMessage, undefined, source); + return { + ok: false, + scheduledCount: 0, + errorMessage, + }; + } + + try { + const auth = await getNotificationApiHeaders("refresh"); + if (!auth.ok) { + logSkippingRefreshDueToMissingAuth(); + logRefreshFailure(startedAt, auth.message, undefined, source); + return { + ok: false, + scheduledCount: 0, + errorMessage: auth.message, + }; + } + + let deviceId: string | undefined; + try { + deviceId = await getOrCreateDeviceId(); + } catch (err) { + logger.warn( + "[NativeNotificationService] Could not obtain deviceId; skipping refresh", + err, + ); + } + if (!deviceId) { + // The service finds the device by deviceId or fcmToken and answers 400 + // without either, so there is no request worth sending. + const errorMessage = "no deviceId (cannot identify this device)"; + logRefreshFailure(startedAt, errorMessage, undefined, source); + return { ok: false, scheduledCount: 0, errorMessage }; + } + + const body: NotificationRefreshRequest = { + deviceId, + platform: Capacitor.getPlatform(), + testMode: getTestMode(), + }; + const baseUrl = getNotificationApiBaseUrl(); + const res = await fetch(`${baseUrl}/notifications/refresh`, { + method: "POST", + headers: auth.headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorMessage = notificationApiFailureMessage( + res.status, + await readNotificationApiBody(res), + ); + logger.warn("[NativeNotificationService] refreshNotifications failed", { + status: res.status, + statusText: res.statusText, + errorMessage, + }); + logRefreshFailure(startedAt, errorMessage, res.status, source); + return { + ok: false, + scheduledCount: 0, + status: res.status, + errorMessage, + }; + } + + const payload = (await res.json()) as NotificationRefreshResponse; + const scheduledCount = Array.isArray(payload?.nextNotifications) + ? payload.nextNotifications.length + : 0; + await applyNotificationRefreshPayload(payload); + logRefreshSuccess(startedAt, scheduledCount, source); + return { ok: true, scheduledCount }; + } catch (err) { + logger.error("[NativeNotificationService] Refresh failed", err); + const message = err instanceof Error ? err.message : String(err); + logRefreshFailure(startedAt, message, undefined, source); + return { ok: false, scheduledCount: 0, errorMessage: message }; + } +} + +export async function refreshNotifications(): Promise { + await refreshNotificationsWithDiagnostics(); +} + +export type NotificationRefreshPayload = { + shouldNotify?: boolean; + nextNotifications?: Array<{ timestamp?: number }>; +}; + +// `handleCapacitorPushNotificationReceived` and `applyNotificationRefreshPayload` are used by +// DEV notification simulation tooling; they must stay production-safe because that tooling +// exercises real flows. (`applyNotificationRefreshPayload` is also used by production refresh.) + +/** + * Apply a "refresh notifications" payload by clearing and scheduling timestamps via the native plugin. + * + * This is the shared implementation used by: + * - production refresh flow (`refreshNotifications` fetching from backend) + * - dev-only debug flows (mock refresh with local payloads) + * + * Important: This function intentionally mirrors production behavior and does not introduce + * any scheduling logic in UI layers. + */ +export async function applyNotificationRefreshPayload( + payload: unknown, +): Promise { + if (!Capacitor.isNativePlatform()) { + return; + } + + const data = payload as NotificationRefreshPayload; + const nextNotifications = data?.nextNotifications; + if (!Array.isArray(nextNotifications)) { + return; + } + + const timestamps = nextNotifications + .map((n) => (n as { timestamp?: unknown })?.timestamp) + .filter((t): t is number => typeof t === "number" && Number.isFinite(t)); + + if (timestamps.length === 0) { + logNotification("Schedule replacement skipped (no valid timestamps)"); + return; + } + + // Keep existing behavior: ensure background worker credentials are current. + await configureNativeFetcherIfReady(); + + logScheduleReplacement(timestamps.length); + + if (typeof DailyNotification.clearApiNotifications !== "function") { + logger.warn( + "[NativeNotificationService] API notification clear unavailable (plugin clearApiNotifications missing); cannot replace schedule", + ); + logNotification( + "Schedule replacement aborted (API notification clear unavailable on plugin)", + ); + return; + } + + logNotification("Clearing API notifications before refresh"); + await DailyNotification.clearApiNotifications(); + logNotification("Cleared API notifications"); + + if (typeof DailyNotification.scheduleApiNotifications !== "function") { + logger.warn( + "[NativeNotificationService] scheduleApiNotifications not available on plugin; cannot apply timestamps", + ); + logNotification( + "Schedule replacement aborted (scheduleApiNotifications unavailable)", + ); + return; + } + + await DailyNotification.scheduleApiNotifications({ timestamps }); + logNotification( + `Schedule replacement applied (${timestamps.length} timestamp(s))`, + ); +} + +/** + * Silent FCM/APNs data push: refresh native notification pipeline when requested by backend. + */ +export async function handleCapacitorPushNotificationReceived( + notification: PushNotificationSchema, +): Promise { + if (notification.data?.type === "WAKEUP_PING") { + logNotification("WAKEUP_PING handler — invoking refresh"); + await refreshNotificationsWithDiagnostics({ source: "WAKEUP_PING" }); + return; + } + const type = + typeof notification.data?.type === "string" + ? notification.data.type + : "(none)"; + logNotification(`push handler ignored type=${type}`); +} diff --git a/src/services/notifications/NotificationDebugConfig.test.ts b/src/services/notifications/NotificationDebugConfig.test.ts new file mode 100644 index 00000000..591f2dcb --- /dev/null +++ b/src/services/notifications/NotificationDebugConfig.test.ts @@ -0,0 +1,50 @@ +jest.mock("@/constants/app", () => ({ + DEFAULT_NOTIFY_API_SERVER: "https://notify-api.timesafari.app", +})); + +import { + getNotificationDebugOverrideHeaders, + NGROK_SKIP_BROWSER_WARNING_HEADER, + NGROK_SKIP_BROWSER_WARNING_VALUE, + setBackendBaseUrl, +} from "./NotificationDebugConfig"; + +const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl"; + +describe("getNotificationDebugOverrideHeaders", () => { + const memory = new Map(); + + beforeEach(() => { + memory.clear(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { + memory.set(key, value); + }, + removeItem: (key: string) => { + memory.delete(key); + }, + }, + }); + }); + + it("adds the ngrok skip header when the debug backend override is set", () => { + setBackendBaseUrl("https://detail-frown-machine.ngrok-free.dev"); + + expect(getNotificationDebugOverrideHeaders()).toEqual({ + [NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE, + }); + expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBe( + "https://detail-frown-machine.ngrok-free.dev", + ); + }); + + it("omits the ngrok skip header for the default/production notification API", () => { + setBackendBaseUrl(""); + + expect(getNotificationDebugOverrideHeaders()).toEqual({}); + expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBeUndefined(); + }); +}); diff --git a/src/services/notifications/NotificationDebugConfig.ts b/src/services/notifications/NotificationDebugConfig.ts new file mode 100644 index 00000000..9d757ae6 --- /dev/null +++ b/src/services/notifications/NotificationDebugConfig.ts @@ -0,0 +1,132 @@ +/** + * Lightweight debug configuration for notification backend testing. + * Persists overrides in localStorage; production defaults apply when unset. + */ + +import { DEFAULT_NOTIFY_API_SERVER } from "@/constants/app"; + +const LOG = "[NotificationDebug]"; +const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl"; +const STORAGE_KEY_TEST_MODE = "notificationDebug.testMode"; +const STORAGE_KEY_BYPASS_AUTH = "notificationDebug.bypassAuth"; + +/** Free-ngrok interstitial bypass; only sent when the debug backend override is set. */ +export const NGROK_SKIP_BROWSER_WARNING_HEADER = "ngrok-skip-browser-warning"; +export const NGROK_SKIP_BROWSER_WARNING_VALUE = "true"; + +/** Trim whitespace, drop trailing slash; empty input becomes null. */ +export function normalizeNotificationBackendUrl(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/\/$/, ""); +} + +function readStorage(key: string): string | null { + if (typeof localStorage === "undefined") { + return null; + } + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string | null): void { + if (typeof localStorage === "undefined") { + return; + } + try { + if (value === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, value); + } + } catch { + // Quota / privacy mode — ignore + } +} + +/** Backend URL override, or null when using the default notify-api server. */ +export function getBackendBaseUrl(): string | null { + const raw = readStorage(STORAGE_KEY_BACKEND_URL); + if (raw === null) { + return null; + } + return normalizeNotificationBackendUrl(raw); +} + +export function setBackendBaseUrl(url: string): void { + const normalized = normalizeNotificationBackendUrl(url); + if (normalized === null) { + writeStorage(STORAGE_KEY_BACKEND_URL, null); + // eslint-disable-next-line no-console + console.log(`${LOG} backend URL cleared (using default)`); + return; + } + writeStorage(STORAGE_KEY_BACKEND_URL, normalized); + // eslint-disable-next-line no-console + console.log(`${LOG} backend URL set to ${normalized}`); +} + +/** + * When never configured via debug UI/console, matches prior hardcoded `testMode: true`. + */ +export function getTestMode(): boolean { + const raw = readStorage(STORAGE_KEY_TEST_MODE); + if (raw === null) { + return true; + } + return raw === "true"; +} + +export function setTestMode(enabled: boolean): void { + writeStorage(STORAGE_KEY_TEST_MODE, enabled ? "true" : "false"); + // eslint-disable-next-line no-console + console.log(`${LOG} test mode ${enabled ? "enabled" : "disabled"}`); +} + +/** When never configured via debug UI/console, auth bypass is off (JWT used). */ +export function getBypassAuth(): boolean { + const raw = readStorage(STORAGE_KEY_BYPASS_AUTH); + if (raw === null) { + return false; + } + return raw === "true"; +} + +export function setBypassAuth(enabled: boolean): void { + writeStorage(STORAGE_KEY_BYPASS_AUTH, enabled ? "true" : "false"); + // eslint-disable-next-line no-console + console.log(`${LOG} auth bypass ${enabled ? "enabled" : "disabled"}`); +} + +/** + * Base URL for notify-api calls (`/notifications/*`, `/notify-sms/*`). + * Uses debug override when set; otherwise `DEFAULT_NOTIFY_API_SERVER`. + */ +export function getNotificationApiBaseUrl(): string { + const override = getBackendBaseUrl(); + if (override) { + return override; + } + return ( + normalizeNotificationBackendUrl(DEFAULT_NOTIFY_API_SERVER) ?? + DEFAULT_NOTIFY_API_SERVER + ); +} + +/** + * Extra headers for notification API calls when the Debug Panel backend URL + * override is set. Production/default hosts do not get this header. + */ +export function getNotificationDebugOverrideHeaders(): Record { + if (!getBackendBaseUrl()) { + return {}; + } + return { + [NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE, + }; +} diff --git a/src/services/notifications/NotificationDebugEvents.ts b/src/services/notifications/NotificationDebugEvents.ts new file mode 100644 index 00000000..a454574f --- /dev/null +++ b/src/services/notifications/NotificationDebugEvents.ts @@ -0,0 +1,74 @@ +/** + * Lightweight in-memory notification debug log + console observability. + * Used by production notification flows and the Notification Debug Panel. + */ + +export const NOTIFICATION_LOG_PREFIX = "[Notifications]"; + +const MAX_ENTRIES = 100; + +type LogListener = (entries: readonly string[]) => void; + +const entries: string[] = []; +const listeners = new Set(); + +function formatTime(d: Date): string { + const hh = d.getHours().toString().padStart(2, "0"); + const mm = d.getMinutes().toString().padStart(2, "0"); + const ss = d.getSeconds().toString().padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +function formatPanelLine(message: string): string { + return `[${formatTime(new Date())}] ${message}`; +} + +function notifyListeners(): void { + const snapshot = [...entries] as readonly string[]; + for (const listener of listeners) { + listener(snapshot); + } +} + +/** Append a timestamped line to the in-memory debug log (panel). */ +export function appendLog(message: string): void { + entries.push(formatPanelLine(message)); + if (entries.length > MAX_ENTRIES) { + entries.splice(0, entries.length - MAX_ENTRIES); + } + notifyListeners(); +} + +export function subscribe(listener: LogListener): () => void { + listeners.add(listener); + listener([...entries]); + return () => { + listeners.delete(listener); + }; +} + +export function clearNotificationDebugLogs(): void { + entries.length = 0; + notifyListeners(); +} + +export function getNotificationDebugLogEntries(): readonly string[] { + return [...entries]; +} + +/** + * Structured console log (`[Notifications] …`) plus debug panel entry. + */ +export function logNotification( + message: string, + detail?: Record, +): void { + if (detail !== undefined) { + // eslint-disable-next-line no-console + console.log(`${NOTIFICATION_LOG_PREFIX} ${message}`, detail); + } else { + // eslint-disable-next-line no-console + console.log(`${NOTIFICATION_LOG_PREFIX} ${message}`); + } + appendLog(message); +} diff --git a/src/services/notifications/NotificationDebugService.ts b/src/services/notifications/NotificationDebugService.ts new file mode 100644 index 00000000..93c514de --- /dev/null +++ b/src/services/notifications/NotificationDebugService.ts @@ -0,0 +1,376 @@ +/** + * DEV-only notification testing utilities. + * + * IMPORTANT: + * This service intentionally routes through the same production notification + * orchestration paths used by refresh flows, wakeup pushes, and replacement. + * Avoid adding duplicate scheduling logic here. + */ + +import { Capacitor } from "@capacitor/core"; +import type { PushNotificationSchema } from "@capacitor/push-notifications"; +import type { + DebugSendWakeupRequest, + DebugSendWakeupResponse, +} from "@/interfaces/notifyApi"; +import { logger } from "@/utils/logger"; +import { getOrCreateDeviceId } from "./deviceId"; +import { + clearNotificationDebugLogs, + logNotification, +} from "./NotificationDebugEvents"; +import { logNotificationClearing } from "./notificationLog"; +import { + getBackendBaseUrl, + getBypassAuth, + getNotificationApiBaseUrl, + getTestMode, + setBackendBaseUrl, + setBypassAuth, + setTestMode, +} from "./NotificationDebugConfig"; +import { + getLastKnownFcmToken, + reregisterFcmTokenNow, +} from "./firebaseMessagingClient"; +import { + getNotificationApiHeaders, + notificationApiFailureMessage, + readNotificationApiBody, +} from "./notificationApiAuth"; +import { + applyNotificationRefreshPayload, + handleCapacitorPushNotificationReceived, + refreshNotificationsWithDiagnostics, + type NotificationRefreshPayload, +} from "./NativeNotificationService"; +import { truncateFcmTokenForLog } from "./notificationLog"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin"; +import { + uploadAlertSearchAuthorization as uploadAlertSearchAuthorizationBatch, + type AlertAuthorizationUploadResult, +} from "./alertAuthorization"; + +type PendingNotificationInfo = { + identifier: string; + nextTriggerDate?: number | null; + triggerType?: string | null; + wallClockMillis?: number | null; + wallClockSource?: string | null; +}; + +export type PendingNotificationsResult = { + pending: PendingNotificationInfo[]; + /** Native layer does not implement inspection on this platform (e.g. Android). */ + inspectorUnavailableMessage?: string; +}; + +export type SendRealWakeupPingResult = + | { ok: true; responseBody?: DebugSendWakeupResponse } + | { + ok: false; + errorMessage: string; + status?: number; + responseBody?: DebugSendWakeupResponse; + }; + +/** The fields of a send-wakeup answer worth a debug log line. */ +function wakeupPingResponseDetail( + body: DebugSendWakeupResponse | undefined, +): Record { + if (!body) { + return {}; + } + return { + success: body.success, + ...(body.failureReason !== undefined + ? { failureReason: body.failureReason } + : {}), + ...(body.fcmTokenSuffix !== undefined + ? { fcmTokenSuffix: body.fcmTokenSuffix } + : {}), + }; +} + +function isUnimplementedError(e: unknown): boolean { + return ( + typeof e === "object" && + e !== null && + "code" in e && + (e as { code?: string }).code === "UNIMPLEMENTED" + ); +} + +const LOG = "[NotificationDebugService]"; + +export const NotificationDebugService = { + clearDebugLogs(): void { + clearNotificationDebugLogs(); + }, + + getActiveBackendUrl(): string { + return getNotificationApiBaseUrl(); + }, + + getBackendUrlOverride(): string | null { + return getBackendBaseUrl(); + }, + + saveBackendBaseUrl(url: string): void { + setBackendBaseUrl(url); + logNotification( + url.trim() + ? `Backend URL saved (${getNotificationApiBaseUrl()})` + : "Backend URL cleared (using default)", + ); + }, + + setTestModeEnabled(enabled: boolean): void { + setTestMode(enabled); + logNotification(`Test mode ${enabled ? "enabled" : "disabled"}`); + }, + + isTestModeEnabled(): boolean { + return getTestMode(); + }, + + setBypassAuthEnabled(enabled: boolean): void { + setBypassAuth(enabled); + logNotification( + `Auth bypass ${enabled ? "enabled" : "disabled"} (local dev only)`, + ); + }, + + isBypassAuthEnabled(): boolean { + return getBypassAuth(); + }, + + getFcmToken(): string | null { + return getLastKnownFcmToken(); + }, + + async registerTokenNow(): Promise { + logNotification("Register token now (debug panel)"); + await reregisterFcmTokenNow(); + }, + + async uploadAlertSearchAuthorization(): Promise { + logNotification("AlertSearch authorization upload requested"); + const result = await uploadAlertSearchAuthorizationBatch(); + if (result.ok) { + logNotification("AlertSearch authorization upload succeeded", { + batchId: result.batchId, + timezone: result.timezone, + jwtCount: result.jwtCount, + firstDay: result.firstDay, + lastDay: result.lastDay, + status: result.status, + }); + } else { + logNotification( + `AlertSearch authorization upload failed: ${result.errorMessage}`, + { + ...(result.status != null ? { status: result.status } : {}), + ...(result.errorCode ? { errorCode: result.errorCode } : {}), + }, + ); + } + return result; + }, + + async triggerBackendRefresh(): Promise { + await refreshNotificationsWithDiagnostics({ source: "debug panel" }); + }, + + /** Local simulation: same API call as a WAKEUP_PING handler (no push payload). */ + async simulateWakeupViaRefresh(): Promise { + logNotification("WAKEUP_PING simulation (local refresh API only)"); + await refreshNotificationsWithDiagnostics({ + source: "WAKEUP_PING simulation", + }); + }, + + /** Full pipeline: backend `/debug/send-wakeup` → FCM → native WAKEUP_PING handler. */ + async sendRealWakeupPing(): Promise { + logNotification("Real WAKEUP_PING requested"); + + const fcmToken = getLastKnownFcmToken()?.trim() ?? ""; + if (!fcmToken) { + const errorMessage = "no FCM token (register first)"; + logNotification(`Real WAKEUP_PING failed: ${errorMessage}`); + return { ok: false, errorMessage }; + } + + try { + const auth = await getNotificationApiHeaders(); + if (!auth.ok) { + logNotification(`Real WAKEUP_PING failed: ${auth.message}`); + return { ok: false, errorMessage: auth.message }; + } + + const deviceId = await getOrCreateDeviceId(); + const baseUrl = getNotificationApiBaseUrl(); + const body: DebugSendWakeupRequest = { + deviceId, + fcmToken, + platform: Capacitor.getPlatform(), + testMode: getTestMode(), + }; + const res = await fetch(`${baseUrl}/debug/send-wakeup`, { + method: "POST", + headers: auth.headers, + body: JSON.stringify(body), + }); + + const parsed = await readNotificationApiBody(res); + const responseBody = + typeof parsed === "object" && parsed !== null + ? (parsed as DebugSendWakeupResponse) + : undefined; + + // A 200 still reports a skipped or failed push as `success: false`. + if (!res.ok || responseBody?.success === false) { + const errorMessage = notificationApiFailureMessage( + res.status, + responseBody, + ); + logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, { + status: res.status, + token: truncateFcmTokenForLog(fcmToken), + ...wakeupPingResponseDetail(responseBody), + }); + return { + ok: false, + errorMessage, + status: res.status, + responseBody, + }; + } + + logNotification("Real WAKEUP_PING success", { + token: truncateFcmTokenForLog(fcmToken), + deviceId, + ...wakeupPingResponseDetail(responseBody), + }); + return { ok: true, responseBody }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, { + token: truncateFcmTokenForLog(fcmToken), + }); + logger.warn(`${LOG} sendRealWakeupPing failed`, err); + return { ok: false, errorMessage }; + } + }, + + generateMockNotifications( + intervalMs: number = 60_000, + ): NotificationRefreshPayload { + const now = Date.now(); + const future1 = now + intervalMs; + const future2 = now + intervalMs * 2; + + return { + shouldNotify: true, + nextNotifications: [{ timestamp: future1 }, { timestamp: future2 }], + }; + }, + + async triggerMockRefresh(intervalMs?: number): Promise { + logNotification("Mock refresh requested"); + + const payload = this.generateMockNotifications(intervalMs); + const timestamps = payload.nextNotifications?.map((n) => n.timestamp) ?? []; + logNotification(`Mock payload generated (${timestamps.length} timestamps)`); + + if (!Capacitor.isNativePlatform()) { + logNotification("Mock refresh skipped: not running on native platform"); + return; + } + + await applyNotificationRefreshPayload(payload); + logNotification("Mock refresh applied"); + }, + + async simulateWakeupPing(): Promise { + logNotification("Simulating WAKEUP_PING (production push handler)"); + + if (!Capacitor.isNativePlatform()) { + logNotification("WAKEUP_PING simulation skipped: not native platform"); + return; + } + + const notification = { + title: "WAKEUP_PING", + body: "", + id: "dev_wakeup_ping", + data: { type: "WAKEUP_PING" }, + } as unknown as PushNotificationSchema; + + await handleCapacitorPushNotificationReceived(notification); + }, + + async runFloodTest(intervalMs?: number): Promise { + logNotification("Flood test started (20 sequential refreshes)"); + for (let i = 0; i < 20; i++) { + logNotification(`Flood iteration ${i + 1}/20`); + await this.triggerMockRefresh(intervalMs); + } + logNotification("Flood test completed"); + }, + + async clearNotifications(): Promise { + logNotification("Clear notifications (debug panel)"); + + if (!Capacitor.isNativePlatform()) { + logNotification("Clear skipped: not running on native platform"); + return; + } + + const plugin = DailyNotification as unknown as { + clearAllNotifications?: () => Promise; + cancelAllNotifications?: () => Promise; + }; + + if (typeof plugin.clearAllNotifications === "function") { + logNotificationClearing("clearAllNotifications"); + await plugin.clearAllNotifications(); + } else if (typeof plugin.cancelAllNotifications === "function") { + logNotificationClearing("cancelAllNotifications"); + await plugin.cancelAllNotifications(); + } else { + logNotification("Clear not available (plugin method missing)"); + return; + } + + logNotification("Notifications cleared"); + }, + + async getPendingNotifications(): Promise { + logNotification("Fetching pending notifications"); + + if (!Capacitor.isNativePlatform()) { + logNotification("Pending fetch skipped: not running on native platform"); + return { pending: [] }; + } + + try { + const res = await NotificationInspector.getPendingNotifications(); + const items = (res?.pending ?? []) as PendingNotificationInfo[]; + logNotification(`Pending fetched (${items.length})`); + return { pending: items }; + } catch (e: unknown) { + if (isUnimplementedError(e)) { + return { + pending: [], + inspectorUnavailableMessage: + "Pending notification inspection is currently supported on iOS only.", + }; + } + logNotification("Pending fetch failed"); + logger.warn(`${LOG} getPendingNotifications failed`, e); + return { pending: [] }; + } + }, +}; diff --git a/src/services/notifications/NotificationService.ts b/src/services/notifications/NotificationService.ts index fa9d25f4..95436334 100644 --- a/src/services/notifications/NotificationService.ts +++ b/src/services/notifications/NotificationService.ts @@ -14,9 +14,72 @@ */ import { Capacitor } from "@capacitor/core"; +import type { NotificationRegisterRequest } from "@/interfaces/notifyApi"; +import { logger } from "@/utils/logger"; +import { getOrCreateDeviceId } from "./deviceId"; +import { + getNotificationApiBaseUrl, + getTestMode, +} from "./NotificationDebugConfig"; +import { + getNotificationApiHeaders, + logNotificationAuthFailure, + notificationApiFailureMessage, + readNotificationApiBody, +} from "./notificationApiAuth"; +import { + logTokenRegistrationFailure, + logTokenRegistrationStarted, + logTokenRegistrationSuccess, +} from "./notificationLog"; import { NativeNotificationService } from "./NativeNotificationService"; import { WebPushNotificationService } from "./WebPushNotificationService"; +/** + * Registers an FCM device token with the app backend (native Capacitor token or web getToken). + */ +export async function registerToken(fcmToken: string): Promise { + logTokenRegistrationStarted(fcmToken); + const deviceId = await getOrCreateDeviceId(); + const baseUrl = getNotificationApiBaseUrl(); + try { + const auth = await getNotificationApiHeaders("register"); + if (!auth.ok) { + logNotificationAuthFailure("register", auth.message); + throw new Error(`registerToken auth unavailable: ${auth.message}`); + } + + const body: NotificationRegisterRequest = { + deviceId, + fcmToken, + platform: Capacitor.getPlatform(), + testMode: getTestMode(), + }; + const res = await fetch(`${baseUrl}/notifications/register`, { + method: "POST", + headers: auth.headers, + body: JSON.stringify(body), + }); + // Success is a bare 200 with a plain-text body; only a refusal is read. + if (!res.ok) { + const detail = notificationApiFailureMessage( + res.status, + await readNotificationApiBody(res), + ); + logger.warn("[NotificationService] registerToken failed", { + status: res.status, + statusText: res.statusText, + detail, + }); + throw new Error(`registerToken failed: ${detail}`); + } + logTokenRegistrationSuccess(fcmToken); + } catch (err) { + logTokenRegistrationFailure(fcmToken, err); + throw err; + } +} + /** * Options for scheduling a daily notification */ diff --git a/src/services/notifications/alertAuthorization.test.ts b/src/services/notifications/alertAuthorization.test.ts new file mode 100644 index 00000000..73ff3b8c --- /dev/null +++ b/src/services/notifications/alertAuthorization.test.ts @@ -0,0 +1,240 @@ +import type { AlertAuthorizationBatch } from "./alertAuthorizationBatch"; +import { + type AlertAuthorizationDependencies, + uploadAlertSearchAuthorization, +} from "./alertAuthorization"; +import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; + +jest.mock("@/libs/crypto/vc", () => ({ + ETHR_DID_PREFIX: "did:ethr:", +})); +jest.mock("./alertAuthorizationBatch", () => ({ + ALERT_AUTHORIZATION_BATCH_DAYS: 100, + mintAlertAuthorizationBatch: jest.fn(), +})); +jest.mock("./NotificationDebugConfig", () => ({ + getNotificationApiBaseUrl: jest.fn(), +})); +jest.mock("./notificationApiAuth", () => ({ + getActiveNotificationDid: jest.fn(), + getNotificationApiHeaders: jest.fn(), + httpAuthErrorMessage: (status: number) => `HTTP ${status}`, +})); +jest.mock("./notificationApiDebugMode", () => ({ + shouldBypassNotificationAuth: jest.fn(), +})); + +const BATCH_DAYS = 100; +const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`; +const TIME_ZONE = "America/Denver"; +const BATCH_ID = "batch-fixture"; + +function createMintedBatch(): AlertAuthorizationBatch { + return { + batchId: BATCH_ID, + jwts: Array.from({ length: BATCH_DAYS }, (_, index) => ({ + sequence: index, + day: new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10), + nbf: 1_788_220_800 + index * 86_400, + exp: 1_788_307_200 + index * 86_400, + jwt: `delegated-token-${index}`, + })), + }; +} + +function createResponse( + status: number, + body: Record, +): Response { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + } as unknown as Response; +} + +function createDependencies(overrides?: { + did?: string | null; + bypassAuth?: boolean; + timeZone?: string; + response?: Response; +}): { + dependencies: AlertAuthorizationDependencies; + fetchMock: jest.Mock; + mintMock: jest.Mock; + authMock: jest.Mock; +} { + const fetchMock = jest + .fn() + .mockResolvedValue(overrides?.response ?? createResponse(200, {})); + const mintMock = jest.fn().mockResolvedValue(createMintedBatch()); + const authMock = jest.fn().mockResolvedValue({ + ok: true, + authenticated: true, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer access-token-fixture", + }, + }); + + return { + dependencies: { + getActiveDid: jest + .fn() + .mockResolvedValue( + overrides && "did" in overrides ? overrides.did : ACTIVE_DID, + ), + isAuthBypassEnabled: jest + .fn() + .mockReturnValue(overrides?.bypassAuth ?? false), + mintBatch: mintMock, + getTimeZone: () => overrides?.timeZone ?? TIME_ZONE, + getAuthHeaders: authMock, + getBaseUrl: () => "https://notification-backend.invalid", + fetch: fetchMock, + }, + fetchMock, + mintMock, + authMock, + }; +} + +describe("uploadAlertSearchAuthorization", () => { + it("uploads the minted batch with authenticated PUT semantics", async () => { + const { dependencies, fetchMock, mintMock, authMock } = + createDependencies(); + const minted = createMintedBatch(); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result).toEqual({ + ok: true, + status: 200, + batchId: BATCH_ID, + timezone: TIME_ZONE, + jwtCount: BATCH_DAYS, + firstDay: minted.jwts[0]!.day, + lastDay: minted.jwts[BATCH_DAYS - 1]!.day, + }); + expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID); + expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://notification-backend.invalid/notifications/alert-authorization", + ); + expect(init.method).toBe("PUT"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer access-token-fixture", + }); + + const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE); + expect(JSON.parse(String(init.body))).toEqual({ + batchId: BATCH_ID, + notifyHourUtc: expectedNotify.notifyHourUtc, + notifyMinuteUtc: expectedNotify.notifyMinuteUtc, + timezone: TIME_ZONE, + jwts: minted.jwts, + }); + }); + + it("does not log delegated JWT contents", async () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + const { dependencies } = createDependencies(); + + await uploadAlertSearchAuthorization(dependencies); + + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("rejects unsupported identities before minting or network submission", async () => { + const { dependencies, fetchMock, mintMock, authMock } = createDependencies({ + did: "did:peer:unsupported", + }); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result).toEqual({ + ok: false, + errorMessage: + "AlertSearch authorization requires an active did:ethr identity.", + }); + expect(mintMock).not.toHaveBeenCalled(); + expect(authMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects notification auth bypass before minting or submission", async () => { + const { dependencies, fetchMock, mintMock } = createDependencies({ + bypassAuth: true, + }); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result.ok).toBe(false); + expect(mintMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not submit when the device zone cannot be resolved", async () => { + const { dependencies, fetchMock } = createDependencies({ + timeZone: "Not/AZone", + }); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.errorMessage).toContain("Not/AZone"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("surfaces a coded refusal using only its code and message", async () => { + const { dependencies } = createDependencies({ + response: createResponse(400, { + success: false, + error: "ALERT_AUTHORIZATION_INVALID_BATCH", + message: + "Delegated notification-JWT batch was rejected. Nothing was stored.", + details: ["jwts[0].jwt failed verification"], + jwt: "must-not-be-returned", + }), + }); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result).toEqual({ + ok: false, + status: 400, + errorCode: "ALERT_AUTHORIZATION_INVALID_BATCH", + errorMessage: + "Delegated notification-JWT batch was rejected. Nothing was stored.", + }); + expect(JSON.stringify(result)).not.toContain("must-not-be-returned"); + }); + + it("surfaces an uncoded auth refusal without an error code", async () => { + const { dependencies } = createDependencies({ + response: createResponse(401, { + success: false, + message: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z", + }), + }); + + const result = await uploadAlertSearchAuthorization(dependencies); + + expect(result).toEqual({ + ok: false, + status: 401, + errorMessage: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z", + }); + }); +}); diff --git a/src/services/notifications/alertAuthorization.ts b/src/services/notifications/alertAuthorization.ts new file mode 100644 index 00000000..9c10485b --- /dev/null +++ b/src/services/notifications/alertAuthorization.ts @@ -0,0 +1,205 @@ +/** + * Manual upload of a push-channel AlertSearch authorization, for the + * notification debug panel: mint a delegated JWT batch for the active identity + * and `PUT /notifications/alert-authorization`, with the send time fixed at + * 09:00 in the device's zone. + * + * The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and + * both mint with `mintAlertAuthorizationBatch`. The wire shapes are in + * `@/interfaces/notifyApi`. + */ + +import type { AlertAuthorizationRequestBody } from "@/interfaces/notifyApi"; +import { ETHR_DID_PREFIX } from "@/libs/crypto/vc"; +import { + ALERT_AUTHORIZATION_BATCH_DAYS, + type AlertAuthorizationBatch, + mintAlertAuthorizationBatch, +} from "./alertAuthorizationBatch"; +import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; +import { getNotificationApiBaseUrl } from "./NotificationDebugConfig"; +import { + getActiveNotificationDid, + getNotificationApiHeaders, + httpAuthErrorMessage, +} from "./notificationApiAuth"; +import { shouldBypassNotificationAuth } from "./notificationApiDebugMode"; + +export type AlertAuthorizationUploadResult = + | { + ok: true; + status: number; + batchId: string; + timezone: string; + jwtCount: number; + firstDay: string; + lastDay: string; + } + | { + ok: false; + errorMessage: string; + status?: number; + errorCode?: string; + }; + +type FetchNotificationApi = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +export interface AlertAuthorizationDependencies { + getActiveDid: () => Promise; + isAuthBypassEnabled: () => boolean; + mintBatch: (did: string) => Promise; + /** IANA zone whose 09:00 becomes the batch's UTC send time. */ + getTimeZone: () => string; + getAuthHeaders: typeof getNotificationApiHeaders; + getBaseUrl: () => string; + fetch: FetchNotificationApi; +} + +const defaultDependencies: AlertAuthorizationDependencies = { + getActiveDid: getActiveNotificationDid, + isAuthBypassEnabled: shouldBypassNotificationAuth, + mintBatch: (did) => mintAlertAuthorizationBatch(did), + getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone, + getAuthHeaders: getNotificationApiHeaders, + getBaseUrl: getNotificationApiBaseUrl, + fetch: (input, init) => globalThis.fetch(input, init), +}; + +function buildRequestBody( + batch: AlertAuthorizationBatch, + timeZone: string, +): AlertAuthorizationRequestBody { + if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) { + throw new Error( + `Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`, + ); + } + + const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone); + + return { + batchId: batch.batchId, + notifyHourUtc: notifyTime.notifyHourUtc, + notifyMinuteUtc: notifyTime.notifyMinuteUtc, + timezone: timeZone, + jwts: batch.jwts, + }; +} + +/** + * The code and message of a refusal, and nothing else from its body. The + * service sends an `AlertAuthorizationFailure`: the code, when there is one, + * under `error`, and the sentence under `message`. + */ +async function readRefusal( + response: Response, +): Promise<{ message?: string; errorCode?: string }> { + let body: unknown; + try { + body = await response.json(); + } catch { + return {}; + } + if (typeof body !== "object" || body === null) { + return {}; + } + + const { error, message } = body as { error?: unknown; message?: unknown }; + return { + ...(typeof message === "string" && message.trim() + ? { message: message.trim() } + : {}), + ...(typeof error === "string" && error.trim() + ? { errorCode: error.trim() } + : {}), + }; +} + +/** + * Mint and manually upload a delegated AlertSearch authorization batch. + * This function never retries and refuses notification auth bypass mode. + */ +export async function uploadAlertSearchAuthorization( + dependencies: AlertAuthorizationDependencies = defaultDependencies, +): Promise { + try { + const did = await dependencies.getActiveDid(); + if (!did) { + return { ok: false, errorMessage: "No active identity is available." }; + } + if (!did.startsWith(ETHR_DID_PREFIX)) { + return { + ok: false, + errorMessage: + "AlertSearch authorization requires an active did:ethr identity.", + }; + } + if (dependencies.isAuthBypassEnabled()) { + return { + ok: false, + errorMessage: + "AlertSearch authorization cannot be uploaded while JWT authentication is skipped.", + }; + } + + const batch = await dependencies.mintBatch(did); + const timeZone = dependencies.getTimeZone(); + const body = buildRequestBody(batch, timeZone); + + const auth = await dependencies.getAuthHeaders(undefined, did); + if (!auth.ok) { + return { + ok: false, + errorMessage: `Authentication unavailable: ${auth.message}`, + }; + } + if (!auth.authenticated) { + return { + ok: false, + errorMessage: + "AlertSearch authorization requires authenticated notification API headers.", + }; + } + + const response = await dependencies.fetch( + `${dependencies.getBaseUrl()}/notifications/alert-authorization`, + { + method: "PUT", + headers: auth.headers, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + const refusal = await readRefusal(response); + const fallback = + response.status === 401 || response.status === 403 + ? httpAuthErrorMessage(response.status) + : `HTTP ${response.status}`; + return { + ok: false, + status: response.status, + errorMessage: refusal.message || fallback, + ...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}), + }; + } + + return { + ok: true, + status: response.status, + batchId: body.batchId, + timezone: timeZone, + jwtCount: body.jwts.length, + firstDay: body.jwts[0]!.day, + lastDay: body.jwts[body.jwts.length - 1]!.day, + }; + } catch (error: unknown) { + return { + ok: false, + errorMessage: error instanceof Error ? error.message : "Upload failed.", + }; + } +} diff --git a/src/services/notifications/alertAuthorizationBatch.ts b/src/services/notifications/alertAuthorizationBatch.ts new file mode 100644 index 00000000..d3b44dec --- /dev/null +++ b/src/services/notifications/alertAuthorizationBatch.ts @@ -0,0 +1,211 @@ +/** + * Delegated alertSearch JWT batches for the notify-api. + * + * The notify-api runs a user's daily alertSearch on their behalf, so it needs a + * credential it can present to Endorser and Partner without the app being + * awake. The app mints a batch of {@link ALERT_AUTHORIZATION_BATCH_DAYS} + * single-day JWTs up front and uploads them; the service spends one per UTC day + * and stops when the inventory runs out. + * + * Both delivery channels take the same batch: push through + * `PUT /notifications/alert-authorization` (see `alertAuthorization.ts`) + * and SMS through `POST /notify-sms/alert-authorization` (see + * `smsNotificationApi.ts`). The service validates both with one rule and keeps + * a separate inventory per channel, so this module is the only minter for + * either and each upload carries a batch of its own. The native background + * prefetch pool (`mintBackgroundJwtTokenPool`) is a different credential. The + * wire shapes are in `@/interfaces/notifyApi`. + * + * Each JWT must cover the whole of the UTC day it names -- `nbf` at or before + * that day's opening midnight and `exp` at or after its closing midnight -- + * because the daily run may fire at any moment inside the day, catch-up runs + * included. The frame is UTC rather than the device's zone: a window from local + * midnight to local midnight misses part of the UTC day it is filed under in + * every zone but UTC, and the service rejects the whole batch. + * + * Passkey (`did:peer`) identities cannot mint these: signing goes through a + * WebAuthn prompt with a fixed one-minute lifetime, so a day-long window is not + * expressible. The service rejects such batches with + * `DELEGATED_JWT_UNSUPPORTED_IDENTITY`; {@link mintAlertAuthorizationBatch} + * throws {@link UnsupportedIdentityError} before spending a round trip. + */ + +import { KeyMetaWithPrivate } from "@/interfaces/common"; +import type { + AlertAuthorizationRequestBody, + DelegatedAlertJwt, + DelegatedAlertJwtPayload, +} from "@/interfaces/notifyApi"; +import { retrieveFullyDecryptedAccount } from "@/libs/util"; +import { createEndorserJwtForKey } from "@/libs/crypto/vc"; + +/** + * JWTs per batch. The service expects consecutive sequence numbers covering + * distinct days, so this is also the number of days an upload lasts. + */ +export const ALERT_AUTHORIZATION_BATCH_DAYS = 100; + +const SECONDS_PER_DAY = 24 * 60 * 60; +const MS_PER_DAY = SECONDS_PER_DAY * 1000; + +/** + * Padding on each end of a day's validity window, for clock skew between this + * device, the notify-api, and Endorser. The service asks for `nbf` at or before + * the opening midnight and `exp` at or after the closing one, so widening is + * allowed and narrowing is not. + */ +const WINDOW_SLACK_SECONDS = 5 * 60; + +export interface AlertAuthorizationBatch { + batchId: string; + jwts: DelegatedAlertJwt[]; +} + +/** Thrown for identities whose keys cannot sign a day-long delegated JWT. */ +export class UnsupportedIdentityError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedIdentityError"; + } +} + +function generateBatchId(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +/** UTC calendar day (`YYYY-MM-DD`) for an epoch-milliseconds instant. */ +function utcDayString(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, 10); +} + +/** + * Mint a full batch of delegated alertSearch JWTs starting with the UTC day + * containing `startingAt`. + * + * The account is decrypted once and reused for all + * {@link ALERT_AUTHORIZATION_BATCH_DAYS} signatures; decrypting per JWT costs + * seconds on a phone. + * + * @param did identity the batch is issued by and for + * @param startingAt instant whose UTC day is sequence 0 (defaults to now) + * @throws UnsupportedIdentityError for passkey identities + */ +export async function mintAlertAuthorizationBatch( + did: string, + startingAt: Date = new Date(), +): Promise { + const account = await retrieveFullyDecryptedAccount(did); + if (!account) { + throw new Error(`No account found for ${did}`); + } + if (!account.identity && account.passkeyCredIdHex) { + throw new UnsupportedIdentityError( + "Passkey identities cannot authorize background notifications. " + + "Switch to a seed-phrase identity to turn this on.", + ); + } + if (!account.identity) { + throw new Error(`No signing key found for ${did}`); + } + + const firstDayStartMs = Date.UTC( + startingAt.getUTCFullYear(), + startingAt.getUTCMonth(), + startingAt.getUTCDate(), + ); + + const jwts: DelegatedAlertJwt[] = []; + for ( + let sequence = 0; + sequence < ALERT_AUTHORIZATION_BATCH_DAYS; + sequence++ + ) { + const dayStartMs = firstDayStartMs + sequence * MS_PER_DAY; + const dayStartSec = Math.floor(dayStartMs / 1000); + const nbf = dayStartSec - WINDOW_SLACK_SECONDS; + const exp = dayStartSec + SECONDS_PER_DAY + WINDOW_SLACK_SECONDS; + // `iat` and `iss` are filled in by the signer; nbf/exp are the day window. + const payload: DelegatedAlertJwtPayload = { nbf, exp }; + const jwt = await createEndorserJwtForKey( + account as KeyMetaWithPrivate, + payload, + ); + jwts.push({ + sequence, + day: utcDayString(dayStartMs), + nbf, + exp, + jwt, + }); + } + + return { batchId: generateBatchId(), jwts }; +} + +/** + * Mint a fresh batch and wrap it in the upload body for either channel. + * + * Mint once per upload: each channel spends its own inventory, and one batch + * shared by both would put the same JWT in front of Endorser twice a day. + * + * @param notifyHourUtc UTC hour, 0-23 + * @param notifyMinuteUtc UTC minute, 0-59 + * @throws UnsupportedIdentityError for passkey identities + */ +export async function buildAlertAuthorizationBody( + did: string, + notifyHourUtc: number, + notifyMinuteUtc: number, +): Promise { + const batch = await mintAlertAuthorizationBatch(did); + // Recorded for whatever later re-derives the hour across a daylight-saving + // change; no scheduling decision reads it. + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return { + batchId: batch.batchId, + notifyHourUtc, + notifyMinuteUtc, + ...(timezone ? { timezone } : {}), + jwts: batch.jwts, + }; +} + +/** + * Split a 12-hour clock reading such as `"6:30 PM"` -- the format the time + * picker hands back -- into the UTC hour and minute the notify-api schedules + * on. The reading is interpreted in the device's current local time, so the + * result follows the device's present UTC offset and does not track later + * daylight-saving changes. + * + * @returns the UTC pair, or null when the text is not a time + */ +export function localTimeTextToUtc( + timeText: string, +): { notifyHourUtc: number; notifyMinuteUtc: number } | null { + const match = (timeText || "").match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (!match) { + return null; + } + const rawHour = parseInt(match[1], 10); + const minute = parseInt(match[2], 10); + if (rawHour < 1 || rawHour > 12 || minute > 59) { + return null; + } + const isAm = match[3].toUpperCase() === "AM"; + let hour24 = rawHour % 12; // 12 AM -> 0, 12 PM -> 12 after the PM shift + if (!isAm) { + hour24 += 12; + } + + // Resolve through a real local date so the offset used is the device's own, + // including half-hour and 45-minute zones that an hour-based shift mangles. + const local = new Date(); + local.setHours(hour24, minute, 0, 0); + return { + notifyHourUtc: local.getUTCHours(), + notifyMinuteUtc: local.getUTCMinutes(), + }; +} diff --git a/src/services/notifications/alertSearchNotifyTime.test.ts b/src/services/notifications/alertSearchNotifyTime.test.ts new file mode 100644 index 00000000..37776a05 --- /dev/null +++ b/src/services/notifications/alertSearchNotifyTime.test.ts @@ -0,0 +1,25 @@ +import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime"; + +describe("alertSearchNotifyTimeUtcFromLocalNineAm", () => { + it("converts 09:00 Asia/Manila to 01:00 UTC", () => { + const result = alertSearchNotifyTimeUtcFromLocalNineAm( + "Asia/Manila", + new Date("2026-09-10T16:00:00.000Z"), + ); + expect(result).toEqual({ notifyHourUtc: 1, notifyMinuteUtc: 0 }); + }); + + it("returns integers in the UTC hour/minute ranges", () => { + const result = alertSearchNotifyTimeUtcFromLocalNineAm( + "America/Denver", + new Date("2026-01-15T12:00:00.000Z"), + ); + expect(Number.isInteger(result.notifyHourUtc)).toBe(true); + expect(Number.isInteger(result.notifyMinuteUtc)).toBe(true); + expect(result.notifyHourUtc).toBeGreaterThanOrEqual(0); + expect(result.notifyHourUtc).toBeLessThanOrEqual(23); + expect(result.notifyMinuteUtc).toBeGreaterThanOrEqual(0); + expect(result.notifyMinuteUtc).toBeLessThanOrEqual(59); + expect(result).toEqual({ notifyHourUtc: 16, notifyMinuteUtc: 0 }); + }); +}); diff --git a/src/services/notifications/alertSearchNotifyTime.ts b/src/services/notifications/alertSearchNotifyTime.ts new file mode 100644 index 00000000..4bcc42fc --- /dev/null +++ b/src/services/notifications/alertSearchNotifyTime.ts @@ -0,0 +1,47 @@ +/** + * Debug/E2E AlertSearch notify-time conversion only. + * Not a user-facing setting; does not read Daily Reminder or New Activity times. + */ + +import { DateTime } from "luxon"; + +/** Temporary default wall-clock time for AlertSearch authorization uploads. */ +export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR = 9; +export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE = 0; + +function requireIanaTimeZone(timeZone: string): string { + const probe = DateTime.now().setZone(timeZone); + if (!timeZone || !probe.isValid) { + throw new Error( + "Invalid IANA timezone for AlertSearch notify time: " + timeZone, + ); + } + return timeZone; +} + +/** + * Convert 09:00 in `timeZone` (IANA) on the calendar day of `now` to UTC hour/minute. + */ +export function alertSearchNotifyTimeUtcFromLocalNineAm( + timeZone: string, + now: Date = new Date(), +): { notifyHourUtc: number; notifyMinuteUtc: number } { + const zone = requireIanaTimeZone(timeZone); + const localNine = DateTime.fromJSDate(now, { zone }).set({ + hour: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR, + minute: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE, + second: 0, + millisecond: 0, + }); + if (!localNine.isValid) { + throw new Error( + "Could not convert AlertSearch 09:00 local notify time to UTC for zone " + + zone, + ); + } + const utc = localNine.toUTC(); + return { + notifyHourUtc: utc.hour, + notifyMinuteUtc: utc.minute, + }; +} diff --git a/src/services/notifications/deviceId.ts b/src/services/notifications/deviceId.ts new file mode 100644 index 00000000..ffe17c39 --- /dev/null +++ b/src/services/notifications/deviceId.ts @@ -0,0 +1,38 @@ +import { Preferences } from "@capacitor/preferences"; + +const DEVICE_ID_KEY = "stable_device_id"; + +function generateDeviceId(): string { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // eslint-disable-next-line no-console + console.warn( + "[DeviceId] crypto.randomUUID unavailable, using fallback generator", + ); + + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export async function getOrCreateDeviceId(): Promise { + const existing = await Preferences.get({ key: DEVICE_ID_KEY }); + + if (existing.value) { + // eslint-disable-next-line no-console + console.log("[DeviceId] Loaded existing deviceId"); + return existing.value; + } + + const newId = generateDeviceId(); + + await Preferences.set({ + key: DEVICE_ID_KEY, + value: newId, + }); + + // eslint-disable-next-line no-console + console.log("[DeviceId] Generated new deviceId"); + + return newId; +} diff --git a/src/services/notifications/dualScheduleConfig.ts b/src/services/notifications/dualScheduleConfig.ts new file mode 100644 index 00000000..290e9d44 --- /dev/null +++ b/src/services/notifications/dualScheduleConfig.ts @@ -0,0 +1,84 @@ +/** + * Builds DualScheduleConfiguration for the Daily Notification plugin. + * Used for API-driven "New Activity" notifications (prefetch + notify). + */ + +import type { DualScheduleConfiguration } from "@timesafari/daily-notification-plugin"; + +/** Matches `plugins.DailyNotification.networkConfig` in capacitor.config.ts */ +const CONTENT_FETCH_NETWORK = { + timeout: 30_000, + retryAttempts: 3, + retryDelay: 1_000, +} as const; + +/** + * Convert "HH:mm" (24h) to cron expression "minute hour * * *" (daily at that time). + */ +export function timeToCron(timeHHmm: string): string { + const [h, m] = timeHHmm.split(":").map(Number); + const hour = Math.max(0, Math.min(23, h ?? 0)); + const minute = Math.max(0, Math.min(59, m ?? 0)); + return `${minute} ${hour} * * *`; +} + +/** + * Cron for 5 minutes before the given "HH:mm" (so prefetch runs before the notification). + */ +export function timeToCronFiveMinutesBefore(timeHHmm: string): string { + const [h, m] = timeHHmm.split(":").map(Number); + let hour = Math.max(0, Math.min(23, h ?? 0)); + let minute = Math.max(0, Math.min(59, m ?? 0)); + minute -= 5; + if (minute < 0) { + minute += 60; + hour -= 1; + if (hour < 0) hour += 24; + } + return `${minute} ${hour} * * *`; +} + +export interface DualScheduleConfigInput { + /** Time in HH:mm (24h) for the user notification */ + notifyTime: string; + /** Optional title; default "New Activity" */ + title?: string; + /** Optional body; default describes API-driven content */ + body?: string; +} + +/** + * Build plugin DualScheduleConfiguration for scheduleDualNotification(). + * contentFetch runs 5 minutes before notifyTime; userNotification at notifyTime. + */ +export function buildDualScheduleConfig( + input: DualScheduleConfigInput, +): DualScheduleConfiguration { + const notifyTime = input.notifyTime || "09:00"; + const fetchCron = timeToCronFiveMinutesBefore(notifyTime); + const notifyCron = timeToCron(notifyTime); + return { + contentFetch: { + enabled: true, + schedule: fetchCron, + timeout: CONTENT_FETCH_NETWORK.timeout, + retryAttempts: CONTENT_FETCH_NETWORK.retryAttempts, + retryDelay: CONTENT_FETCH_NETWORK.retryDelay, + callbacks: {}, + }, + userNotification: { + enabled: true, + schedule: notifyCron, + title: input.title ?? "New Activity", + body: input.body ?? "Check your starred projects and offers for updates.", + sound: true, + vibration: true, + priority: "normal", + }, + relationship: { + autoLink: true, + contentTimeout: 5 * 60 * 1000, // 5 minutes + fallbackBehavior: "skip", // was "show_default" + }, + }; +} diff --git a/src/services/notifications/firebaseMessagingClient.ts b/src/services/notifications/firebaseMessagingClient.ts new file mode 100644 index 00000000..a618c610 --- /dev/null +++ b/src/services/notifications/firebaseMessagingClient.ts @@ -0,0 +1,314 @@ +/** + * Firebase Cloud Messaging (JS SDK) + Capacitor Push Notifications (native bridge). + * + * Initializes the Firebase web app when VITE_FIREBASE_* env vars are set, wires + * Capacitor push listeners, requests permission before registration/token flow, + * and attaches Firebase messaging when the browser/WebView reports support. + */ + +import { Capacitor } from "@capacitor/core"; +import { PushNotifications } from "@capacitor/push-notifications"; +import { + type FirebaseApp, + type FirebaseOptions, + getApps, + initializeApp, +} from "firebase/app"; +import { + getMessaging, + getToken, + isSupported, + onMessage, +} from "firebase/messaging"; +import { logger } from "@/utils/logger"; +import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService"; +import { getNotificationApiHeaders } from "./notificationApiAuth"; +import { deferFcmRegistration } from "./notificationAuthLifecycle"; +import { registerToken } from "./NotificationService"; +import { + logPushNotificationActionPerformed, + logPushNotificationReceived, + logTokenRegistrationSkippedDuplicate, +} from "./notificationLog"; + +const LOG = "[FirebaseMessaging]"; + +let firebaseAppSingleton: FirebaseApp | null = null; +let nativeInitPromise: Promise | null = null; +/** Avoid duplicate POSTs when the same token is delivered more than once. */ +let lastRegisteredFcmToken: string | null = null; +/** Last token received from Capacitor/Firebase (may match registered). */ +let lastSeenFcmToken: string | null = null; + +async function registerRetrievedToken( + token: string, + options?: { force?: boolean }, +): Promise { + const trimmed = token.trim(); + if (!trimmed) { + return; + } + lastSeenFcmToken = trimmed; + if (!options?.force && trimmed === lastRegisteredFcmToken) { + logTokenRegistrationSkippedDuplicate(trimmed); + return; + } + + const auth = await getNotificationApiHeaders("register"); + if (!auth.ok) { + if (options?.force) { + throw new Error(`FCM registration auth unavailable: ${auth.message}`); + } + deferFcmRegistration(trimmed); + return; + } + + await registerToken(trimmed); + lastRegisteredFcmToken = trimmed; +} + +/** Most recent FCM token from native/web push registration (for debug UI). */ +export function getLastKnownFcmToken(): string | null { + return lastSeenFcmToken ?? lastRegisteredFcmToken; +} + +/** + * Re-runs token registration immediately (debug). Bypasses duplicate-token skip. + */ +export async function reregisterFcmTokenNow(): Promise { + if (!Capacitor.isNativePlatform()) { + throw new Error("FCM registration is only available on native platforms"); + } + + lastRegisteredFcmToken = null; + + const cached = lastSeenFcmToken?.trim(); + if (cached) { + await registerRetrievedToken(cached, { force: true }); + return cached; + } + + const app = ensureFirebaseApp(); + if (app && (await isSupported())) { + const messaging = getMessaging(app); + const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as + | string + | undefined; + const token = await getToken( + messaging, + vapidKey ? { vapidKey } : undefined, + ); + if (!token?.trim()) { + throw new Error("Firebase getToken returned an empty token"); + } + await registerRetrievedToken(token, { force: true }); + return token.trim(); + } + + return new Promise((resolve, reject) => { + const timeoutMs = 15_000; + const timeoutId = window.setTimeout(() => { + void listenerPromise.then((h) => h.remove()); + reject(new Error("Timed out waiting for push registration token")); + }, timeoutMs); + + const listenerPromise = PushNotifications.addListener( + "registration", + (token) => { + window.clearTimeout(timeoutId); + void listenerPromise.then((h) => h.remove()); + const value = token.value?.trim() ?? ""; + if (!value) { + reject(new Error("Capacitor registration returned an empty token")); + return; + } + void registerRetrievedToken(value, { force: true }) + .then(() => resolve(value)) + .catch(reject); + }, + ); + + void PushNotifications.register().catch((err) => { + window.clearTimeout(timeoutId); + void listenerPromise.then((h) => h.remove()); + reject(err); + }); + }); +} + +function readFirebaseOptions(): FirebaseOptions | null { + const env = import.meta.env; + const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined; + const projectId = env.VITE_FIREBASE_PROJECT_ID as string | undefined; + const appId = env.VITE_FIREBASE_APP_ID as string | undefined; + const messagingSenderId = env.VITE_FIREBASE_MESSAGING_SENDER_ID as + | string + | undefined; + + if (!apiKey || !projectId || !appId || !messagingSenderId) { + logger.debug( + `${LOG} Missing one or more VITE_FIREBASE_* keys; Firebase app not initialized`, + ); + return null; + } + + const authDomain = + (env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined) || + `${projectId}.firebaseapp.com`; + const storageBucket = + (env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined) || + `${projectId}.appspot.com`; + + const opts: FirebaseOptions = { + apiKey, + authDomain, + projectId, + storageBucket, + messagingSenderId, + appId, + }; + + const measurementId = env.VITE_FIREBASE_MEASUREMENT_ID as string | undefined; + if (measurementId) { + opts.measurementId = measurementId; + } + + return opts; +} + +/** + * Ensures a single Firebase app instance for the client when config is present. + */ +export function ensureFirebaseApp(): FirebaseApp | null { + if (firebaseAppSingleton) { + return firebaseAppSingleton; + } + const options = readFirebaseOptions(); + if (!options) { + return null; + } + firebaseAppSingleton = + getApps().length > 0 ? getApps()[0]! : initializeApp(options); + logger.info(`${LOG} Firebase app initialized`); + return firebaseAppSingleton; +} + +async function attachFirebaseMessagingIfSupported( + app: FirebaseApp, +): Promise { + if (!(await isSupported())) { + logger.debug( + `${LOG} firebase/messaging not supported in this context; skipping getMessaging`, + ); + return; + } + + const messaging = getMessaging(app); + const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as + | string + | undefined; + + try { + const token = await getToken( + messaging, + vapidKey ? { vapidKey } : undefined, + ); + logger.info(`${LOG} Firebase getToken completed`, { + tokenPrefix: token ? `${token.slice(0, 12)}…` : "(empty)", + }); + await registerRetrievedToken(token); + } catch (err) { + logger.warn( + `${LOG} Firebase getToken failed (common on native WebView without SW)`, + err, + ); + } + + onMessage(messaging, (payload) => { + logger.debug(`${LOG} onMessage (foreground)`, payload); + }); +} + +/** + * Native: register Capacitor push listeners, request permissions, register for push, + * then initialize Firebase Messaging when env config and platform support allow. + */ +async function initializeNativePushAndFirebaseMessagingImpl(): Promise { + if (!Capacitor.isNativePlatform()) { + return; + } + + try { + const app = ensureFirebaseApp(); + + await PushNotifications.addListener("registration", (token) => { + if (token.value?.trim()) { + lastSeenFcmToken = token.value.trim(); + } + logger.info(`${LOG} Capacitor registration token`, { + valuePrefix: token.value ? `${token.value.slice(0, 12)}…` : "(empty)", + }); + void registerRetrievedToken(token.value).catch((err) => { + logger.warn( + `${LOG} registerToken after Capacitor registration failed`, + err, + ); + }); + }); + + await PushNotifications.addListener("registrationError", (err) => { + logger.error(`${LOG} registrationError`, err); + }); + + await PushNotifications.addListener( + "pushNotificationReceived", + (notification) => { + logger.debug(`${LOG} pushNotificationReceived`, notification); + logPushNotificationReceived(notification); + void handleCapacitorPushNotificationReceived(notification).catch( + (err) => { + logger.warn( + `${LOG} handleCapacitorPushNotificationReceived failed`, + err, + ); + }, + ); + }, + ); + + await PushNotifications.addListener( + "pushNotificationActionPerformed", + (action) => { + logger.debug(`${LOG} pushNotificationActionPerformed`, action); + logPushNotificationActionPerformed(action); + }, + ); + + const perm = await PushNotifications.requestPermissions(); + if (perm.receive !== "granted") { + logger.warn(`${LOG} Push permission not granted`, perm); + return; + } + + await PushNotifications.register(); + + if (app) { + await attachFirebaseMessagingIfSupported(app); + } + } catch (err) { + logger.error(`${LOG} Native push / Firebase messaging init failed`, err); + } +} + +/** + * Idempotent startup hook for Capacitor iOS/Android. + */ +export function initializeNativePushAndFirebaseMessaging(): Promise { + if (!Capacitor.isNativePlatform()) { + return Promise.resolve(); + } + if (!nativeInitPromise) { + nativeInitPromise = initializeNativePushAndFirebaseMessagingImpl(); + } + return nativeInitPromise; +} diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index 8f1283e4..df73cdfe 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -13,9 +13,76 @@ * ``` */ -export { NotificationService } from "./NotificationService"; +export { + getBackendBaseUrl, + getBypassAuth, + getNotificationApiBaseUrl, + getNotificationDebugOverrideHeaders, + getTestMode, + NGROK_SKIP_BROWSER_WARNING_HEADER, + NGROK_SKIP_BROWSER_WARNING_VALUE, + normalizeNotificationBackendUrl, + setBackendBaseUrl, + setBypassAuth, + setTestMode, +} from "./NotificationDebugConfig"; +export { shouldBypassNotificationAuth } from "./notificationApiDebugMode"; +export { + appendLog, + clearNotificationDebugLogs, + getNotificationDebugLogEntries, + logNotification, + NOTIFICATION_LOG_PREFIX, + subscribe, +} from "./NotificationDebugEvents"; +export { NotificationService, registerToken } from "./NotificationService"; export { NativeNotificationService } from "./NativeNotificationService"; export { WebPushNotificationService } from "./WebPushNotificationService"; +export { uploadAlertSearchAuthorization } from "./alertAuthorization"; +export type { AlertAuthorizationUploadResult } from "./alertAuthorization"; + +export { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; +export { + deferFcmRegistration, + flushDeferredFcmRegistration, + onNotificationAuthMayBeReady, +} from "./notificationAuthLifecycle"; +export { + ensureFirebaseApp, + initializeNativePushAndFirebaseMessaging, +} from "./firebaseMessagingClient"; +export { syncStarredPlansToNativePlugin } from "./syncStarredPlansToNativePlugin"; +export { + buildDualScheduleConfig, + timeToCron, + timeToCronFiveMinutesBefore, +} from "./dualScheduleConfig"; +export { + ALERT_AUTHORIZATION_BATCH_DAYS, + buildAlertAuthorizationBody, + localTimeTextToUtc, + mintAlertAuthorizationBatch, + UnsupportedIdentityError, +} from "./alertAuthorizationBatch"; +export type { AlertAuthorizationBatch } from "./alertAuthorizationBatch"; + +export { + authorizeSmsAlertSearch, + deleteSmsPhone, + listSmsPhones, + normalizePhoneNumber, + registerSmsPhone, + revokeSmsAlertSearch, + SmsApiError, + smsErrorMessage, + verifySmsPhone, +} from "./smsNotificationApi"; +export type { DualScheduleConfigInput } from "./dualScheduleConfig"; + +export { + REMINDER_ID_DAILY_REMINDER, + REMINDER_ID_NEW_ACTIVITY, +} from "./reminderIds"; export type { NotificationServiceInterface, diff --git a/src/services/notifications/nativeFetcherConfig.ts b/src/services/notifications/nativeFetcherConfig.ts new file mode 100644 index 00000000..aeaffb4d --- /dev/null +++ b/src/services/notifications/nativeFetcherConfig.ts @@ -0,0 +1,185 @@ +/** + * Native fetcher configuration for API-driven daily notifications. + * Calls the Daily Notification plugin's configureNativeFetcher with + * apiBaseUrl, activeDid, and a JWT so background workers can call the Endorser API. + * + * @see daily-notification-plugin docs/integration/INTEGRATION_GUIDE.md + */ + +import { Capacitor } from "@capacitor/core"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { + BackgroundJwtUnsupportedIdentityError, + mintBackgroundJwtTokenPool, +} from "@/libs/crypto/backgroundJwtPool"; +import { retrieveFullyDecryptedAccount } from "@/libs/util"; +import type { KeyMetaWithPrivate } from "@/interfaces/common"; +import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; +import { logger } from "@/utils/logger"; +import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; +import { BACKGROUND_JWT_SECONDS_PER_DAY } from "@/constants/backgroundJwt"; +import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle"; + +/** + * Configure the native notification content fetcher with API credentials. + * Call when the app has an active identity (e.g. after login, app startup, or identity change). + * No-op on web; requires native platform and an active DID. + * + * @param activeDid - Optional. If not provided, reads from active_identity table. + * @param apiServer - Optional. If not provided, reads from settings for the active DID. + * @returns true if configuration was attempted and succeeded, false otherwise. + */ +export async function configureNativeFetcherIfReady( + activeDid?: string, + apiServer?: string, +): Promise { + if (!Capacitor.isNativePlatform()) { + return false; + } + + const platform = Capacitor.getPlatform(); + if (platform !== "ios" && platform !== "android") { + return false; + } + + try { + const service = PlatformServiceFactory.getInstance(); + let did = activeDid; + let apiBaseUrl = apiServer; + + if (!did) { + const row = await service.dbGetOneRow( + "SELECT activeDid FROM active_identity WHERE id = 1", + ); + if (!row || !row[0]) { + logger.debug( + "[nativeFetcherConfig] No active DID; skipping native fetcher config", + ); + return false; + } + did = String(row[0]); + } + + if (!apiBaseUrl) { + const settingsRow = await service.dbGetOneRow( + "SELECT apiServer FROM settings WHERE id = 1 OR accountDid = ? LIMIT 1", + [did], + ); + apiBaseUrl = settingsRow?.[0] + ? String(settingsRow[0]) + : DEFAULT_ENDORSER_API_SERVER; + } + + // Decrypt once here rather than once per token inside the minter. + const account = await retrieveFullyDecryptedAccount(did); + if (!account) { + logger.debug( + "[nativeFetcherConfig] No account for active DID; skipping native fetcher config", + ); + return false; + } + + let jwtTokens: string[]; + try { + jwtTokens = await mintBackgroundJwtTokenPool( + account as KeyMetaWithPrivate, + ); + } catch (error) { + if (error instanceof BackgroundJwtUnsupportedIdentityError) { + // Passkey identities: minting would raise one WebAuthn prompt per token + // and yield one-minute tokens. Leave prefetch unconfigured instead. + logger.debug( + "[nativeFetcherConfig] Identity cannot mint a background pool; " + + "skipping native fetcher config", + ); + return false; + } + throw error; + } + // Pool slots are keyed by `epochDay % size`, not by distance from the mint, + // so the standalone `jwtToken` a plugin without pool support falls back to + // has to be read at today's slot rather than at index 0. + const todayEpochDay = Math.floor( + Date.now() / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY, + ); + const jwtToken = jwtTokens[todayEpochDay % jwtTokens.length] ?? ""; + if (!jwtToken) { + logger.warn( + "[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail", + ); + } + + if (!DailyNotification?.configureNativeFetcher) { + logger.warn( + "[nativeFetcherConfig] Plugin configureNativeFetcher not available", + ); + return false; + } + + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: + apiBaseUrl?.trim().replace(/\/$/, "") ?? DEFAULT_ENDORSER_API_SERVER, + activeDid: did, + jwtToken, + jwtTokens, + }); + logger.info( + "[nativeFetcherConfig] Native fetcher configured (JWT pool size=" + + jwtTokens.length + + ")", + ); + onNotificationAuthMayBeReady(); + return true; + } catch (error) { + logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error); + return false; + } +} + +/** + * Drop the JWT pool the native fetcher holds, so a signed-out or switched-away + * identity leaves no usable credential on the device. + * + * This is custody, not revocation: the tokens stay cryptographically valid + * until their windows close, and Endorser offers nothing that would invalidate + * a copy taken before this call. What it bounds is the ordinary case -- account + * switch, sign-out, a shared or lost handset -- where no copy was taken and the + * only remaining risk is the device's own store. A cleared pool leaves each + * selector with an empty bearer, which both refuse to send. + * + * Call before the active identity changes; `configureNativeFetcherIfReady` + * mints a fresh pool for whichever identity takes over. + * + * @returns true when the pool was cleared or there was nothing to clear + */ +export async function clearNativeFetcherPool(): Promise { + if (!Capacitor.isNativePlatform()) { + return false; + } + if (!DailyNotification?.configureNativeFetcher) { + logger.warn( + "[nativeFetcherConfig] Plugin configureNativeFetcher not available; " + + "cannot clear the native JWT pool", + ); + return false; + } + try { + await DailyNotification.configureNativeFetcher({ + apiBaseUrl: DEFAULT_ENDORSER_API_SERVER, + activeDid: "", + jwtToken: "", + jwtTokens: [], + }); + logger.info("[nativeFetcherConfig] Native fetcher JWT pool cleared"); + return true; + } catch (error) { + // A plugin build that rejects empty credentials leaves the old pool in + // place; say so rather than reporting a clear that did not happen. + logger.error( + "[nativeFetcherConfig] Could not clear the native JWT pool; " + + "the previous identity's tokens remain on the device:", + error, + ); + return false; + } +} diff --git a/src/services/notifications/notificationApiAuth.ts b/src/services/notifications/notificationApiAuth.ts new file mode 100644 index 00000000..5c01ca59 --- /dev/null +++ b/src/services/notifications/notificationApiAuth.ts @@ -0,0 +1,206 @@ +/** + * Authenticated headers for notification backend API calls (`/notifications/*`). + * Uses the same `getHeaders` + active DID flow as the rest of the app. + * Debug/local config can bypass auth for ngrok and panel testing. + */ + +import { getHeaders } from "@/libs/endorserServer"; +import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; +import { logger } from "@/utils/logger"; +import { getNotificationDebugOverrideHeaders } from "./NotificationDebugConfig"; +import { shouldBypassNotificationAuth } from "./notificationApiDebugMode"; +import { logNotification } from "./NotificationDebugEvents"; + +export type NotificationRequestKind = "register" | "refresh"; + +export type NotificationApiHeadersResult = + | { + ok: true; + authenticated: boolean; + headers: Record; + } + | { + ok: false; + reason: "no_active_did" | "missing_token" | "identity_changed"; + message: string; + }; + +const DEBUG_HEADERS: Record = { + "Content-Type": "application/json", +}; + +export async function getActiveNotificationDid(): Promise { + try { + const service = PlatformServiceFactory.getInstance(); + const row = await service.dbGetOneRow( + "SELECT activeDid FROM active_identity WHERE id = 1", + ); + if (!row?.[0]) { + return null; + } + const did = String(row[0]).trim(); + return did || null; + } catch (err) { + logger.warn("[notificationApiAuth] Failed to read active DID", err); + return null; + } +} + +function hasBearerToken(headers: { + Authorization?: string; +}): headers is { Authorization: string } { + const auth = headers.Authorization; + return ( + typeof auth === "string" && + auth.startsWith("Bearer ") && + auth.length > "Bearer ".length + ); +} + +function logAuthBypassEnabled(): void { + logNotification("Auth bypass enabled for debug/testing"); +} + +function logAuthenticatedNotificationRequest(): void { + logNotification("Using authenticated notification request"); +} + +function logDebugUnauthenticatedNotificationRequest(): void { + logNotification("Using debug unauthenticated notification request"); +} + +/** + * Resolve headers for notification API requests. + * @param kind Optional request kind for structured logs. + * @param expectedDid Reject if the active identity changed before token minting. + */ +export async function getNotificationApiHeaders( + kind?: NotificationRequestKind, + expectedDid?: string, +): Promise { + if (shouldBypassNotificationAuth()) { + logAuthBypassEnabled(); + if (kind) { + logDebugUnauthenticatedNotificationRequest(); + } + return { + ok: true, + authenticated: false, + headers: { + ...DEBUG_HEADERS, + ...getNotificationDebugOverrideHeaders(), + }, + }; + } + + const did = await getActiveNotificationDid(); + if (!did) { + return { + ok: false, + reason: "no_active_did", + message: "no active identity (cannot authenticate)", + }; + } + if (expectedDid && did !== expectedDid) { + return { + ok: false, + reason: "identity_changed", + message: "active identity changed before authentication", + }; + } + + const headers = await getHeaders(did); + if (!hasBearerToken(headers)) { + return { + ok: false, + reason: "missing_token", + message: "missing or empty Authorization token", + }; + } + + if (kind) { + logAuthenticatedNotificationRequest(); + } + + return { + ok: true, + authenticated: true, + headers: { + "Content-Type": headers["Content-Type"], + Authorization: headers.Authorization, + ...getNotificationDebugOverrideHeaders(), + }, + }; +} + +export function logNotificationRequestAuthenticated( + kind: NotificationRequestKind, +): void { + logNotification( + kind === "register" + ? "Register request authenticated" + : "Refresh request authenticated", + ); +} + +export function logNotificationAuthFailure( + kind: NotificationRequestKind, + message: string, +): void { + const verb = kind === "register" ? "Register" : "Refresh"; + logNotification(`${verb} auth unavailable: ${message}`); +} + +export function logWaitingForAuthBeforeRegistration(): void { + logNotification("Waiting for auth before registration"); +} + +export function logSkippingRefreshDueToMissingAuth(): void { + logNotification("Skipping refresh due to missing auth"); +} + +export function httpAuthErrorMessage(status: number): string { + if (status === 401) { + return "unauthorized (expired or invalid auth)"; + } + if (status === 403) { + return "forbidden (not authorized)"; + } + return `HTTP ${status}`; +} + +/** A response body parsed as JSON, or undefined when it is empty or not JSON. */ +export async function readNotificationApiBody(res: Response): Promise { + try { + return await res.json(); + } catch { + return undefined; + } +} + +/** + * The best sentence for a failed device or debug route call: the service's own + * reason when the body carries one, otherwise a description of the status. + * + * Reads the uncoded refusal shapes in `@/interfaces/notifyApi`, in order: + * `error` (`NotificationDeviceRouteFailure`), `failureReason` + * (`DebugSendWakeupResponse`), and `message` (`NotifyApiUncodedFailure`). + */ +export function notificationApiFailureMessage( + status: number, + body: unknown, +): string { + if (typeof body === "object" && body !== null) { + const { error, failureReason, message } = body as { + error?: unknown; + failureReason?: unknown; + message?: unknown; + }; + for (const value of [error, failureReason, message]) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + } + return httpAuthErrorMessage(status); +} diff --git a/src/services/notifications/notificationApiDebugMode.ts b/src/services/notifications/notificationApiDebugMode.ts new file mode 100644 index 00000000..6259ef6e --- /dev/null +++ b/src/services/notifications/notificationApiDebugMode.ts @@ -0,0 +1,11 @@ +/** + * Debug/local notification API auth bypass (ngrok, Notification Debug Panel). + * Controlled explicitly via `notificationDebug.bypassAuth`; off by default. + */ + +import { getBypassAuth } from "./NotificationDebugConfig"; + +/** True when debug panel explicitly allows unauthenticated API calls. */ +export function shouldBypassNotificationAuth(): boolean { + return getBypassAuth(); +} diff --git a/src/services/notifications/notificationAuthLifecycle.ts b/src/services/notifications/notificationAuthLifecycle.ts new file mode 100644 index 00000000..c35276c6 --- /dev/null +++ b/src/services/notifications/notificationAuthLifecycle.ts @@ -0,0 +1,115 @@ +/** + * Defers notification register/refresh until app auth (active DID + Bearer) is available. + * Bounded retries avoid racing startup and prevent infinite loops. + */ + +import { logger } from "@/utils/logger"; +import { + getNotificationApiHeaders, + logWaitingForAuthBeforeRegistration, +} from "./notificationApiAuth"; +import { registerToken } from "./NotificationService"; + +const MAX_REGISTER_RETRY_ATTEMPTS = 6; +const REGISTER_RETRY_BASE_MS = 2_000; +const REGISTER_RETRY_MAX_MS = 60_000; + +let pendingFcmToken: string | null = null; +let registerRetryAttempt = 0; +let registerRetryTimer: ReturnType | null = null; +let registerFlushInFlight: Promise | null = null; + +function clearRegisterRetryTimer(): void { + if (registerRetryTimer != null) { + clearTimeout(registerRetryTimer); + registerRetryTimer = null; + } +} + +function scheduleDeferredRegistrationRetry(): void { + if (!pendingFcmToken || registerRetryTimer != null) { + return; + } + if (registerRetryAttempt >= MAX_REGISTER_RETRY_ATTEMPTS) { + logger.warn( + "[notificationAuthLifecycle] Stopped retrying deferred FCM registration (max attempts)", + ); + return; + } + + const delay = Math.min( + REGISTER_RETRY_BASE_MS * 2 ** registerRetryAttempt, + REGISTER_RETRY_MAX_MS, + ); + registerRetryAttempt += 1; + registerRetryTimer = setTimeout(() => { + registerRetryTimer = null; + void flushDeferredFcmRegistration("scheduled retry"); + }, delay); +} + +/** + * Queue FCM token registration until Bearer auth is available; retries with backoff. + */ +export function deferFcmRegistration(token: string): void { + const trimmed = token.trim(); + if (!trimmed) { + return; + } + pendingFcmToken = trimmed; + logWaitingForAuthBeforeRegistration(); + scheduleDeferredRegistrationRetry(); +} + +/** Attempt pending FCM registration when auth may now be ready (identity, resume, fetcher config). */ +export async function flushDeferredFcmRegistration( + reason?: string, +): Promise { + if (!pendingFcmToken) { + return; + } + if (registerFlushInFlight) { + return registerFlushInFlight; + } + + registerFlushInFlight = (async () => { + const token = pendingFcmToken; + if (!token) { + return; + } + + const auth = await getNotificationApiHeaders("register"); + if (!auth.ok) { + scheduleDeferredRegistrationRetry(); + return; + } + + clearRegisterRetryTimer(); + registerRetryAttempt = 0; + pendingFcmToken = null; + + try { + await registerToken(token); + } catch (err) { + pendingFcmToken = token; + logger.warn( + `[notificationAuthLifecycle] Deferred FCM registration failed${reason ? ` (${reason})` : ""}`, + err, + ); + scheduleDeferredRegistrationRetry(); + } + })().finally(() => { + registerFlushInFlight = null; + }); + + return registerFlushInFlight; +} + +/** + * Call when active identity or session may have become available (additive hooks only). + */ +export function onNotificationAuthMayBeReady(): void { + registerRetryAttempt = 0; + clearRegisterRetryTimer(); + void flushDeferredFcmRegistration("auth may be ready"); +} diff --git a/src/services/notifications/notificationLog.ts b/src/services/notifications/notificationLog.ts new file mode 100644 index 00000000..d8dc1298 --- /dev/null +++ b/src/services/notifications/notificationLog.ts @@ -0,0 +1,117 @@ +/** + * Shared observability helpers for notification flows (console + debug panel). + */ + +import { logNotification } from "./NotificationDebugEvents"; + +export function truncateFcmTokenForLog(token: string): string { + const t = token.trim(); + if (t.length <= 24) { + return t; + } + return `${t.slice(0, 12)}…${t.slice(-8)}`; +} + +export function logPushNotificationReceived(notification: { + title?: string; + data?: Record; +}): void { + const type = + typeof notification.data?.type === "string" + ? notification.data.type + : "(none)"; + logNotification(`pushNotificationReceived type=${type}`, { + title: notification.title, + dataType: type, + }); + if (type === "WAKEUP_PING") { + logNotification("WAKEUP_PING received — will trigger refresh"); + } +} + +export function logPushNotificationActionPerformed(action: { + actionId?: string; + notification?: { title?: string; data?: Record }; +}): void { + const type = + typeof action.notification?.data?.type === "string" + ? action.notification.data.type + : "(none)"; + logNotification( + `pushNotificationActionPerformed actionId=${action.actionId ?? "(none)"} type=${type}`, + { + actionId: action.actionId, + dataType: type, + }, + ); +} + +export function logTokenRegistrationStarted(token: string): void { + logNotification("Token registration started", { + token: truncateFcmTokenForLog(token), + }); +} + +export function logTokenRegistrationSuccess(token: string): void { + logNotification("Token registration success", { + token: truncateFcmTokenForLog(token), + }); +} + +export function logTokenRegistrationSkippedDuplicate(token: string): void { + logNotification("Token registration skipped (duplicate token)", { + token: truncateFcmTokenForLog(token), + }); +} + +export function logTokenRegistrationFailure( + token: string, + error: unknown, +): void { + const message = error instanceof Error ? error.message : String(error); + logNotification(`Token registration failure: ${message}`, { + token: truncateFcmTokenForLog(token), + }); +} + +export function logRefreshStarted(source?: string): void { + logNotification(source ? `Refresh started (${source})` : "Refresh started"); +} + +function elapsedMsSince(startedAt: number): number { + return performance.now() - startedAt; +} + +export function logRefreshSuccess( + startedAt: number, + scheduledCount: number, + source?: string, +): void { + const elapsedMs = Math.round(elapsedMsSince(startedAt)); + const message = source + ? `Refresh completed (${source}) in ${elapsedMs}ms (scheduled ${scheduledCount})` + : `Refresh completed in ${elapsedMs}ms (scheduled ${scheduledCount})`; + logNotification(message); +} + +export function logRefreshFailure( + startedAt: number, + errorMessage: string, + status?: number, + source?: string, +): void { + const statusPart = status != null ? ` HTTP ${status}` : ""; + const elapsedMs = Math.round(elapsedMsSince(startedAt)); + const message = source + ? `Refresh failed (${source}) in ${elapsedMs}ms: ${errorMessage}${statusPart}` + : `Refresh failed in ${elapsedMs}ms: ${errorMessage}${statusPart}`; + logNotification(message); +} + +export function logNotificationClearing(method: string): void { + logNotification(`Clearing notifications via ${method}`); +} + +export function logScheduleReplacement(count: number): void { + logNotification(`Schedule replacement: ${count} notification(s)`); +} diff --git a/src/services/notifications/reminderIds.ts b/src/services/notifications/reminderIds.ts new file mode 100644 index 00000000..7e510d4d --- /dev/null +++ b/src/services/notifications/reminderIds.ts @@ -0,0 +1,13 @@ +/** + * Stable reminder/schedule IDs for native daily notifications. + * Keeps Daily Reminder and New Activity distinct so we can support both on + * and cancel only one. New Activity uses the dual schedule (scheduleDualNotification) + * only; this ID is for reference/future use (e.g. if we ever add a single-reminder + * fallback for New Activity). + */ + +/** ID for the single daily reminder (Daily Reminder feature). Used by NativeNotificationService. */ +export const REMINDER_ID_DAILY_REMINDER = "daily_timesafari_reminder"; + +/** ID for New Activity. Not used for scheduling (we use dual schedule only); kept for clarity and future use. */ +export const REMINDER_ID_NEW_ACTIVITY = "new_activity_timesafari"; diff --git a/src/services/notifications/smsNotificationApi.ts b/src/services/notifications/smsNotificationApi.ts new file mode 100644 index 00000000..e100796b --- /dev/null +++ b/src/services/notifications/smsNotificationApi.ts @@ -0,0 +1,453 @@ +/** + * Client for the notify-api's `/notify-sms` surface. + * + * The SMS channel texts the daily alertSearch digest. Setting it up is two + * independent steps: prove possession of a handset (POST then PUT `/phone`), + * and authorize the service to run the daily search (POST + * `/alert-authorization`). Stopping is the mirror image, and the two stops are + * different promises: DELETE `/alert-authorization` silences the texts and + * keeps the number verified, while DELETE `/phone` forgets the number and + * costs a fresh code next time. + * + * Every call carries a Bearer JWT that both authenticates the caller and names + * the single action it may perform, on the single number it applies to. The + * service records the token's hash before running the handler, so a token buys + * exactly one call -- each function here mints its own and none may be reused. + * + * The wire shapes are in `@/interfaces/notifyApi`; the service's own account of + * these routes is `notification-wakeup-service/README.md`. + */ + +import type { + AlertAuthorizationRequestBody, + AlertAuthorizationResponse, + AlertAuthorizationRevokeResponse, + NotifySmsFailure, + SmsActionJwtPayload, + SmsActionTarget, + SmsNotificationActionClaim, + SmsPhoneDeleteRequest, + SmsPhoneDeleteResponse, + SmsPhoneListQuery, + SmsPhoneListResponse, + SmsPhoneRegisterRequest, + SmsPhoneRegisterResponse, + SmsPhoneVerifyRequest, + SmsPhoneVerifyResponse, +} from "@/interfaces/notifyApi"; +import { createEndorserJwtForDid } from "@/libs/endorserServer"; +import { logger } from "@/utils/logger"; +import { buildAlertAuthorizationBody } from "./alertAuthorizationBatch"; +import { getNotificationApiBaseUrl } from "./NotificationDebugConfig"; + +/** + * Claim namespace, shared with the FCM setup claim; `@type` is what separates + * the two. Not to be confused with `https://giftopia.me`, the app link that + * appears in the texts themselves. + */ +const SMS_CLAIM_CONTEXT = "https://giftopia.tech"; +const SMS_CLAIM_TYPE = "SmsNotificationAction"; + +/** + * Lifetime of an action JWT. Long enough to survive a slow round trip and + * ordinary clock skew, short enough to stay inside the service's + * `SMS_ACTION_JWT_MAX_AGE_SEC` staleness window. + */ +const ACTION_JWT_EXPIRY_SECONDS = 300; + +/** A `/notify-sms` call that did not succeed. */ +export class SmsApiError extends Error { + /** + * The service's code, such as `SMS_CODE_MISMATCH`; `NO_ACTIVE_IDENTITY` when + * no request was sent; "" when the refusal carried no code, as from the auth + * stages or from an answer that was not the service's JSON. + */ + readonly code: string; + /** HTTP status, or 0 when no request was sent. */ + readonly status: number; + /** + * The coded refusal as sent, for the fields specific codes carry. A code the + * service adds later arrives here too, outside the union. + */ + readonly response?: NotifySmsFailure; + + constructor( + code: string, + status: number, + message: string, + response?: NotifySmsFailure, + ) { + super(message); + this.name = "SmsApiError"; + this.code = code; + this.status = status; + this.response = response; + } +} + +/** + * Normalize a typed number toward E.164 so the same string goes into the claim + * and the body, and so a number stored from one call still matches on the next. + * Ten digits are assumed US, matching the service's own rule; a leading `+` is + * taken at its word. + * + * @returns the normalized number, or "" when it cannot be one + */ +export function normalizePhoneNumber(raw: string): string { + const trimmed = (raw || "").trim(); + if (!trimmed) { + return ""; + } + const digits = trimmed.replace(/\D/g, ""); + if (!digits) { + return ""; + } + if (trimmed.startsWith("+")) { + return `+${digits}`; + } + if (digits.length === 10) { + return `+1${digits}`; + } + if (digits.length === 11 && digits.startsWith("1")) { + return `+${digits}`; + } + // Everything else keeps its digits and gains the `+` the service expects; + // whether the country code is real is Twilio's judgment, not ours. + return `+${digits}`; +} + +/** + * Mint the one-shot Bearer token for a single `/notify-sms` call. + * + * {@link SmsActionTarget} pairs each action with the number it binds to: the + * per-handset actions require one and the inventory-wide ones take none. An + * empty number is left out of the claim. + */ +async function mintSmsActionJwt( + did: string, + target: SmsActionTarget, +): Promise { + // Copied field by field so request options passed along as `target` stay out + // of the claim; the parameter type has already checked the pairing. + const claim = { + "@context": SMS_CLAIM_CONTEXT, + "@type": SMS_CLAIM_TYPE, + action: target.action, + ...(target.phoneNumber ? { phoneNumber: target.phoneNumber } : {}), + } as SmsNotificationActionClaim; + const payload: SmsActionJwtPayload = { claim }; + return createEndorserJwtForDid(did, payload, ACTION_JWT_EXPIRY_SECONDS); +} + +/** + * Read a refusal: a coded `/notify-sms` refusal, or a message without a code + * from the auth stages. Anything else keeps only its status. + */ +function parseRefusal( + body: unknown, + status: number, +): { code: string; message: string; response?: NotifySmsFailure } { + if (!body || typeof body !== "object") { + return { code: "", message: `HTTP ${status}` }; + } + const { error, message } = body as { error?: unknown; message?: unknown }; + const text = + typeof message === "string" && message ? message : `HTTP ${status}`; + if (typeof error === "string" && error) { + return { code: error, message: text, response: body as NotifySmsFailure }; + } + return { code: "", message: text }; +} + +/** + * One `/notify-sms` call: what its action claim authorizes, plus the request. + * `phoneNumber` here goes into the claim only; a route that reads the number + * from the query or body still needs it there. + */ +type SmsRequestOptions = SmsActionTarget & { + method: "GET" | "POST" | "PUT" | "DELETE"; + path: string; + query?: SmsPhoneListQuery | SmsPhoneDeleteRequest; + body?: + | SmsPhoneRegisterRequest + | SmsPhoneVerifyRequest + | SmsPhoneDeleteRequest + | AlertAuthorizationRequestBody; +}; + +async function smsRequest( + did: string, + options: SmsRequestOptions, +): Promise { + if (!did) { + throw new SmsApiError( + "NO_ACTIVE_IDENTITY", + 0, + "No active identity to authorize the request", + ); + } + + const jwt = await mintSmsActionJwt(did, options); + const url = new URL(`${getNotificationApiBaseUrl()}${options.path}`); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (typeof value === "string") { + url.searchParams.set(key, value); + } + } + + const response = await fetch(url.toString(), { + method: options.method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${jwt}`, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + let parsed: unknown = null; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + + if (!response.ok) { + const refusal = parseRefusal(parsed, response.status); + logger.warn("[smsNotificationApi] request failed", { + action: options.action, + status: response.status, + code: refusal.code, + }); + throw new SmsApiError( + refusal.code, + response.status, + refusal.message, + refusal.response, + ); + } + + return (parsed ?? {}) as T; +} + +/** + * List this DID's registrations, with full numbers. Pass a number to also learn + * which DIDs have verified it -- allowed only to a caller who has verified it + * too. + */ +export async function listSmsPhones( + did: string, + phoneNumber?: string, +): Promise { + const normalized = phoneNumber ? normalizePhoneNumber(phoneNumber) : ""; + return smsRequest(did, { + method: "GET", + path: "/notify-sms/phone", + action: "list-phones", + // The claim binds to a number only when the query parameter is present. + phoneNumber: normalized || undefined, + query: normalized ? { phoneNumber: normalized } : undefined, + }); +} + +/** + * Record the number for this DID and have the service text a 6-digit code. + * + * A number this DID has already verified comes back with `verified: true` and + * no text is sent, so a caller can treat that as an immediate success. + * + * The `phoneNumber` in the response is masked. Keep + * `normalizePhoneNumber(phoneNumber)` instead: only the full number matches + * what {@link listSmsPhones} returns and what the later calls must send. + */ +export async function registerSmsPhone( + did: string, + phoneNumber: string, +): Promise { + const normalized = normalizePhoneNumber(phoneNumber); + return smsRequest(did, { + method: "POST", + path: "/notify-sms/phone", + action: "register-phone", + phoneNumber: normalized, + body: { phoneNumber: normalized }, + }); +} + +/** + * Match the texted code and mark the registration verified. The response's + * `phoneNumber` is masked, as with {@link registerSmsPhone}. + */ +export async function verifySmsPhone( + did: string, + phoneNumber: string, + code: string, +): Promise { + const normalized = normalizePhoneNumber(phoneNumber); + return smsRequest(did, { + method: "PUT", + path: "/notify-sms/phone", + action: "verify-phone", + phoneNumber: normalized, + body: { phoneNumber: normalized, code: code.trim() }, + }); +} + +/** + * Forget the number entirely. Removing one that is not registered is a success + * with `deleted: false`, not an error, so this is safe to call blind. + * + * The number goes in both the body and the query string because proxies drop + * bodies on DELETE and the service reads either. + */ +export async function deleteSmsPhone( + did: string, + phoneNumber: string, +): Promise { + const normalized = normalizePhoneNumber(phoneNumber); + return smsRequest(did, { + method: "DELETE", + path: "/notify-sms/phone", + action: "delete-phone", + phoneNumber: normalized, + query: { phoneNumber: normalized }, + body: { phoneNumber: normalized }, + }); +} + +/** + * Hand the service a fresh inventory of delegated JWTs and the UTC time of day + * to text. This is what turns the daily digest on; it replaces any batch + * already stored, so it is also how the send time is changed. + * + * Requires a verified phone for the DID -- without one the service answers + * `SMS_NO_VERIFIED_PHONE`. + * + * @param notifyHourUtc UTC hour, 0-23 + * @param notifyMinuteUtc UTC minute, 0-59 + */ +export async function authorizeSmsAlertSearch( + did: string, + notifyHourUtc: number, + notifyMinuteUtc: number, +): Promise { + const body = await buildAlertAuthorizationBody( + did, + notifyHourUtc, + notifyMinuteUtc, + ); + return smsRequest(did, { + method: "POST", + path: "/notify-sms/alert-authorization", + action: "authorize-alert-search", + body, + }); +} + +/** + * Turn the texts off: every SMS batch and JWT for this DID is removed and the + * scheduler stops listing the identity. Registered numbers survive, so turning + * the digest back on does not cost another verification code. + */ +export async function revokeSmsAlertSearch( + did: string, +): Promise { + return smsRequest(did, { + method: "DELETE", + path: "/notify-sms/alert-authorization", + action: "revoke-alert-search", + }); +} + +/** + * Turn a failure into something worth showing a user. + * + * Codes the service documents get their own wording; an unrecognized code + * falls back to the message the service sent, since a new code is more likely + * to be informative than a generic apology. A refusal with no code gets + * wording by status, because the auth stages' messages point at server logs. + */ +export function smsErrorMessage(error: unknown, fallback: string): string { + if (!(error instanceof SmsApiError)) { + return fallback; + } + const response = error.response; + switch (error.code) { + case "SMS_DISABLED": + return "Text notifications are not available on this server yet."; + case "SMS_NOT_CONFIGURED": + return "This server cannot send texts right now. Please try again later."; + case "SMS_PHONE_BLOCKED": + return ( + "This number has opted out of texts from us. Text START to the " + + "number that messaged you, then try again." + ); + case "SMS_RECIPIENT_NOT_ALLOWED": + return "This server is not allowed to text your identity."; + case "SMS_CODE_MISMATCH": { + const remaining = + response?.error === "SMS_CODE_MISMATCH" + ? response.attemptsRemaining + : undefined; + return typeof remaining === "number" + ? `That code is not right. ${remaining} ${ + remaining === 1 ? "try" : "tries" + } left.` + : "That code is not right."; + } + case "SMS_CODE_ATTEMPTS_EXHAUSTED": + return "Too many wrong codes. Ask for a new one to start over."; + case "SMS_CODE_EXPIRED": + return "That code has expired. Ask for a new one."; + case "SMS_CODE_RATE_LIMITED": + return "Too many codes sent to that number. Please wait a while."; + case "SMS_CODE_SEND_FAILED": + return "The code could not be texted to that number. Please try again."; + case "SMS_PHONE_INVALID": + return "That does not look like a mobile number we can text."; + case "SMS_PHONE_DID_LIMIT": { + const limit = + response?.error === "SMS_PHONE_DID_LIMIT" ? response.limit : undefined; + return typeof limit === "number" + ? `This number already has ${limit} identities on it, which is the limit.` + : "This number already has as many identities on it as we allow."; + } + case "SMS_PHONE_NOT_VERIFIED_BY_CALLER": + return "Verify this number first to see who else is using it."; + case "SMS_NO_VERIFIED_PHONE": + return "Verify a phone number before turning on text notifications."; + case "SMS_ACTION_JWT_STALE": + case "SMS_ACTION_JWT_EXPIRED": + return "That request took too long to reach the server. Please try again."; + case "SMS_ACTION_JWT_REPLAYED": + return "That request was already used. Please try again."; + case "SMS_ACTION_JWT_MISSING_CLAIM": + case "SMS_ACTION_JWT_WRONG_ACTION": + case "SMS_ACTION_JWT_PHONE_MISMATCH": + case "ALERT_AUTHORIZATION_INVALID_BATCH": + return "This app and the notification server disagree about that request."; + case "SMS_ALERT_AUTHORIZATION_FAILED": + case "SMS_ALERT_AUTHORIZATION_DELETE_FAILED": + case "SMS_ACTION_JWT_NOT_AUTHENTICATED": + return "The notification server had a problem. Please try again later."; + case "DELEGATED_JWT_UNSUPPORTED_IDENTITY": + return ( + "Passkey identities cannot authorize background notifications. " + + "Switch to a seed-phrase identity to turn this on." + ); + case "NO_ACTIVE_IDENTITY": + return "Choose an identity before setting up text notifications."; + case "": + if (error.status === 401) { + return "The notification server could not confirm your identity. Please try again."; + } + if (error.status === 503) { + return "The notification server cannot confirm identities right now. Please try again later."; + } + return fallback; + default: + return error.message || fallback; + } +} diff --git a/src/services/notifications/syncStarredPlansToNativePlugin.ts b/src/services/notifications/syncStarredPlansToNativePlugin.ts new file mode 100644 index 00000000..5edf8c76 --- /dev/null +++ b/src/services/notifications/syncStarredPlansToNativePlugin.ts @@ -0,0 +1,30 @@ +import { Capacitor } from "@capacitor/core"; + +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { logger } from "@/utils/logger"; + +/** + * Pushes starred plan handle IDs to the native Daily Notification plugin so + * Android TimeSafariNativeFetcher uses the current list for prefetch + * (plansLastUpdatedBetween planIds). + * + * No-op on web. Ignores UNIMPLEMENTED when the plugin omits the method on some builds. + */ +export async function syncStarredPlansToNativePlugin( + planIds: string[], +): Promise { + if (!Capacitor.isNativePlatform()) { + return; + } + try { + await DailyNotification.updateStarredPlans({ planIds }); + } catch (e: unknown) { + if ((e as { code?: string })?.code === "UNIMPLEMENTED") { + return; + } + logger.warn( + "[syncStarredPlansToNativePlugin] updateStarredPlans failed", + e, + ); + } +} diff --git a/src/services/platforms/BaseDatabaseService.ts b/src/services/platforms/BaseDatabaseService.ts index 9f995c13..7a537e19 100644 --- a/src/services/platforms/BaseDatabaseService.ts +++ b/src/services/platforms/BaseDatabaseService.ts @@ -139,6 +139,12 @@ export abstract class BaseDatabaseService { "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", [did], ); + if (did?.trim()) { + const { onNotificationAuthMayBeReady } = await import( + "@/services/notifications/notificationAuthLifecycle" + ); + onNotificationAuthMayBeReady(); + } } /** diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 784ee69d..d54f43b7 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -590,16 +590,22 @@ export class CapacitorPlatformService await this.db!.run(sql, params); } else { // For multi-statement SQL (like migrations), use executeSet method - // This handles multiple statements properly - if ( - sql.includes(";") && - sql.split(";").filter((s) => s.trim()).length > 1 - ) { + // This handles multiple statements properly. + // Strip "--" line comments before splitting: a semicolon inside a + // comment would otherwise split mid-comment, yielding a comment-only + // statement (SQLITE_MISUSE, "error code 21: not an error") and a + // statement that starts with stray comment text. + // (No migration uses "--" or ";" inside a string literal.) + const statements = sql + .replace(/--[^\n]*/g, "") + .split(";") + .map((s) => s.trim()) + .filter((s) => s); + if (statements.length > 1) { // Multi-statement SQL - use executeSet for proper handling - const statements = sql.split(";").filter((s) => s.trim()); await this.db!.executeSet( statements.map((stmt) => ({ - statement: stmt.trim(), + statement: stmt, values: [], // Empty values array for non-parameterized statements })), ); diff --git a/src/test/alertAuthorizationBatch.test.ts b/src/test/alertAuthorizationBatch.test.ts new file mode 100644 index 00000000..6c8fc0bb --- /dev/null +++ b/src/test/alertAuthorizationBatch.test.ts @@ -0,0 +1,183 @@ +import type { DelegatedAlertJwt } from "@/interfaces/notifyApi"; +import { + ALERT_AUTHORIZATION_BATCH_DAYS, + buildAlertAuthorizationBody, + mintAlertAuthorizationBatch, + UnsupportedIdentityError, +} from "@/services/notifications/alertAuthorizationBatch"; +import { retrieveFullyDecryptedAccount } from "@/libs/util"; +import { createEndorserJwtForKey } from "@/libs/crypto/vc"; + +jest.mock("@/libs/util", () => ({ retrieveFullyDecryptedAccount: jest.fn() })); +jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() })); + +const mockedRetrieve = retrieveFullyDecryptedAccount as unknown as jest.Mock; +const mockedSign = createEndorserJwtForKey as unknown as jest.Mock; + +const DID = "did:ethr:0x0000000000000000000000000000000000000001"; +const SECONDS_PER_DAY = 86400; + +/** + * The notify-api's acceptance rule for one entry, as enforced by + * `validateAlertAuthorizationBatch` in notification-wakeup-service: the JWT is + * valid for the whole UTC day it names. + */ +function coversWholeUtcDay(entry: DelegatedAlertJwt): boolean { + const dayStart = Date.parse(`${entry.day}T00:00:00Z`) / 1000; + return ( + entry.nbf < entry.exp && + entry.nbf <= dayStart && + entry.exp >= dayStart + SECONDS_PER_DAY + ); +} + +/** + * `Date` methods whose answers depend on the device's zone. Jest cannot switch + * the process zone inside a test file, so rather than minting under several + * zones the test makes these throw: a minter that never calls them produces + * the same batch in every zone. + */ +const ZONE_DEPENDENT_DATE_METHODS = [ + "getDate", + "getDay", + "getFullYear", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getSeconds", + "getTimezoneOffset", + "setDate", + "setFullYear", + "setHours", + "setMilliseconds", + "setMinutes", + "setMonth", + "setSeconds", + "toDateString", + "toLocaleDateString", + "toLocaleString", + "toLocaleTimeString", + "toString", + "toTimeString", +] as const; + +async function mintWithoutZoneDependentDates(startingAt: Date) { + const spies = ZONE_DEPENDENT_DATE_METHODS.map((method) => + jest.spyOn(Date.prototype, method).mockImplementation(() => { + throw new Error(`Date.prototype.${method} reads the device's zone`); + }), + ); + try { + return await mintAlertAuthorizationBatch(DID, startingAt); + } finally { + spies.forEach((spy) => spy.mockRestore()); + } +} + +// Just after and just before a UTC midnight, where the device's calendar day +// differs from the UTC one in most zones, and starts whose 100 days cross a US +// daylight-saving change. +const INSTANTS = [ + "2026-09-13T00:10:00Z", + "2026-09-13T23:50:00Z", + "2026-02-20T12:00:00Z", + "2026-10-15T07:30:00Z", +]; + +describe("mintAlertAuthorizationBatch", () => { + beforeEach(() => { + mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" }); + mockedSign + .mockReset() + .mockImplementation( + async (_account: unknown, payload: object) => + `jwt.${JSON.stringify(payload)}`, + ); + }); + + it.each(INSTANTS)( + "starting at %s, mints a batch the notify-api accepts in any zone", + async (instant) => { + const batch = await mintWithoutZoneDependentDates(new Date(instant)); + + expect(batch.batchId).toEqual(expect.any(String)); + expect(batch.batchId.length).toBeGreaterThan(0); + expect(batch.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS); + + // Sequence 0 is the UTC day containing the start. + expect(batch.jwts[0].day).toBe(instant.slice(0, 10)); + + const days = new Set(batch.jwts.map((entry) => entry.day)); + expect(days.size).toBe(ALERT_AUTHORIZATION_BATCH_DAYS); + + batch.jwts.forEach((entry, index) => { + expect(entry.sequence).toBe(index); + expect(coversWholeUtcDay(entry)).toBe(true); + if (index > 0) { + const previousStart = Date.parse( + `${batch.jwts[index - 1].day}T00:00:00Z`, + ); + expect(Date.parse(`${entry.day}T00:00:00Z`) - previousStart).toBe( + SECONDS_PER_DAY * 1000, + ); + } + }); + + // The service checks the signed claims against the listed window. + expect(mockedSign).toHaveBeenCalledTimes(ALERT_AUTHORIZATION_BATCH_DAYS); + mockedSign.mock.calls.forEach(([, payload], index) => { + expect(payload).toEqual({ + nbf: batch.jwts[index].nbf, + exp: batch.jwts[index].exp, + }); + }); + + // One decryption serves every signature. + expect(mockedRetrieve).toHaveBeenCalledTimes(1); + }, + ); + + it("refuses passkey identities without signing", async () => { + mockedRetrieve.mockResolvedValue({ + did: "did:peer:0zExample", + passkeyCredIdHex: "abcd", + }); + await expect(mintAlertAuthorizationBatch(DID)).rejects.toBeInstanceOf( + UnsupportedIdentityError, + ); + expect(mockedSign).not.toHaveBeenCalled(); + }); + + it("refuses an unknown DID", async () => { + mockedRetrieve.mockResolvedValue(undefined); + await expect(mintAlertAuthorizationBatch(DID)).rejects.toThrow( + /No account found/, + ); + }); +}); + +describe("buildAlertAuthorizationBody", () => { + beforeEach(() => { + mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" }); + mockedSign.mockReset().mockResolvedValue("jwt"); + }); + + it("carries the fields both alert-authorization routes require", async () => { + const body = await buildAlertAuthorizationBody(DID, 0, 30); + + expect(body.batchId).toEqual(expect.any(String)); + expect(body.notifyHourUtc).toBe(0); + expect(body.notifyMinuteUtc).toBe(30); + expect(body.timezone).toBe( + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + expect(body.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS); + }); + + it("mints a distinct batch on every call", async () => { + const first = await buildAlertAuthorizationBody(DID, 18, 0); + const second = await buildAlertAuthorizationBody(DID, 18, 0); + expect(first.batchId).not.toBe(second.batchId); + }); +}); diff --git a/src/test/backgroundJwtPool.test.ts b/src/test/backgroundJwtPool.test.ts new file mode 100644 index 00000000..5d187c7f --- /dev/null +++ b/src/test/backgroundJwtPool.test.ts @@ -0,0 +1,144 @@ +import { + BACKGROUND_JWT_POOL_SIZE, + BACKGROUND_JWT_SECONDS_PER_DAY, +} from "@/constants/backgroundJwt"; +import { + BackgroundJwtUnsupportedIdentityError, + mintBackgroundJwtTokenPool, +} from "@/libs/crypto/backgroundJwtPool"; +import { createEndorserJwtForKey } from "@/libs/crypto/vc"; +import type { KeyMetaWithPrivate } from "@/interfaces/common"; + +jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() })); + +const mockedSign = createEndorserJwtForKey as unknown as jest.Mock; + +const DID = "did:ethr:0x0000000000000000000000000000000000000001"; + +const seedAccount = { + did: DID, + identity: JSON.stringify({ keys: [{ privateKeyHex: "ab".repeat(32) }] }), +} as unknown as KeyMetaWithPrivate; + +const passkeyAccount = { + did: "did:peer:0zAbC", + passkeyCredIdHex: "beef", +} as unknown as KeyMetaWithPrivate; + +interface DayWindow { + nbf: number; + exp: number; +} + +/** + * The signer is mocked, so each slot carries back the window it was minted with + * instead of a JWT. Distinctness of the real strings rests on these windows + * differing: ES256K signing is deterministic, so equal payloads sign to equal + * bytes. + */ +function windowsFromMint(tokens: string[]): DayWindow[] { + return tokens.map((t) => JSON.parse(t) as DayWindow); +} + +/** The selector both native fetchers run, in Java and Swift alike. */ +function selectBearerTokenForRequest( + pool: string[], + atEpochMs: number, +): string { + const epochDay = Math.floor(atEpochMs / (24 * 60 * 60 * 1000)); + return pool[epochDay % pool.length]; +} + +beforeEach(() => { + mockedSign.mockReset(); + mockedSign.mockImplementation(async (_account, payload) => + JSON.stringify(payload), + ); +}); + +describe("mintBackgroundJwtTokenPool", () => { + // 23:47 UTC: a mint late in the day must still file today's token in today's + // slot, and the pool must not run a day short at the far end. + const MINTED_AT = Date.UTC(2026, 8, 14, 23, 47, 3); + + function mintAt(epochMs: number): Promise { + jest.spyOn(Date, "now").mockReturnValue(epochMs); + return mintBackgroundJwtTokenPool(seedAccount); + } + + afterEach(() => jest.restoreAllMocks()); + + it("fills every slot exactly once", async () => { + const pool = await mintAt(MINTED_AT); + expect(pool).toHaveLength(BACKGROUND_JWT_POOL_SIZE); + expect(pool.filter(Boolean)).toHaveLength(BACKGROUND_JWT_POOL_SIZE); + }); + + it("gives every slot a distinct day window", async () => { + const pool = await mintAt(MINTED_AT); + expect(new Set(pool).size).toBe(BACKGROUND_JWT_POOL_SIZE); + }); + + it("scopes each token to one day rather than the whole grant", async () => { + const windows = windowsFromMint(await mintAt(MINTED_AT)); + for (const w of windows) { + const span = w.exp - w.nbf; + expect(span).toBeLessThan(2 * BACKGROUND_JWT_SECONDS_PER_DAY); + expect(span).toBeGreaterThan(BACKGROUND_JWT_SECONDS_PER_DAY); + } + }); + + /** + * The invariant the native selectors depend on. Both read + * `pool[epochDay % size]` and hold no mint date, so the token filed in a slot + * has to be the one whose window covers the day that lands on it. A pool + * ordered by distance-from-mint passes every other test here and fails this + * one -- silently, in production, as rejected prefetches. + */ + it("files each day's token where the native selector looks for it", async () => { + const pool = await mintAt(MINTED_AT); + const firstDayStart = + Math.floor(MINTED_AT / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY) * + BACKGROUND_JWT_SECONDS_PER_DAY; + + for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) { + // midday on each covered day, well inside any clock-skew slack + const atSec = firstDayStart + offset * BACKGROUND_JWT_SECONDS_PER_DAY; + for (const probe of [atSec, atSec + BACKGROUND_JWT_SECONDS_PER_DAY - 1]) { + const picked = selectBearerTokenForRequest(pool, probe * 1000); + const w = JSON.parse(picked) as DayWindow; + expect(probe).toBeGreaterThanOrEqual(w.nbf); + expect(probe).toBeLessThanOrEqual(w.exp); + } + } + }); + + it("holds the slot mapping whatever day minting starts on", async () => { + // Every residue mod the pool size, so no start day is a lucky one. + for (const dayOffset of [0, 1, 37, BACKGROUND_JWT_POOL_SIZE - 1]) { + const at = MINTED_AT + dayOffset * 24 * 60 * 60 * 1000; + const pool = await mintAt(at); + const picked = selectBearerTokenForRequest(pool, at); + const w = JSON.parse(picked) as DayWindow; + const nowSec = Math.floor(at / 1000); + expect(nowSec).toBeGreaterThanOrEqual(w.nbf); + expect(nowSec).toBeLessThanOrEqual(w.exp); + jest.restoreAllMocks(); + } + }); + + it("refuses passkey identities instead of prompting once per token", async () => { + await expect(mintBackgroundJwtTokenPool(passkeyAccount)).rejects.toThrow( + BackgroundJwtUnsupportedIdentityError, + ); + expect(mockedSign).not.toHaveBeenCalled(); + }); + + it("decrypts the identity once, not once per token", async () => { + const pool = await mintAt(MINTED_AT); + expect(mockedSign).toHaveBeenCalledTimes(pool.length); + for (const call of mockedSign.mock.calls) { + expect(call[0]).toBe(seedAccount); + } + }); +}); diff --git a/src/test/notificationApiAuth.test.ts b/src/test/notificationApiAuth.test.ts new file mode 100644 index 00000000..b091b711 --- /dev/null +++ b/src/test/notificationApiAuth.test.ts @@ -0,0 +1,67 @@ +import { + notificationApiFailureMessage, + readNotificationApiBody, +} from "@/services/notifications/notificationApiAuth"; + +jest.mock("@/libs/endorserServer", () => ({ getHeaders: jest.fn() })); +jest.mock("@/services/PlatformServiceFactory", () => ({ + PlatformServiceFactory: { getInstance: jest.fn() }, +})); +jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } })); +jest.mock("@/services/notifications/notificationApiDebugMode", () => ({ + shouldBypassNotificationAuth: () => false, +})); +jest.mock("@/services/notifications/NotificationDebugEvents", () => ({ + logNotification: jest.fn(), +})); +// The real module reads `@/constants/app`, whose `import.meta.env` Jest cannot parse. +jest.mock("@/services/notifications/NotificationDebugConfig", () => ({ + getNotificationDebugOverrideHeaders: () => ({}), +})); + +describe("notificationApiFailureMessage", () => { + it("reads a device route's `error` sentence", () => { + expect( + notificationApiFailureMessage(404, { error: "Device not found" }), + ).toBe("Device not found"); + }); + + it("reads a send-wakeup `failureReason`", () => { + expect( + notificationApiFailureMessage(200, { + success: false, + failureReason: "FCM send failed", + fcmTokenSuffix: "abc123", + }), + ).toBe("FCM send failed"); + }); + + it("reads an auth stage's `message`", () => { + expect( + notificationApiFailureMessage(401, { + success: false, + message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z", + }), + ).toBe("Unauthorized. See server logs at 2026-09-13T12:00:00.000Z"); + }); + + it("describes the status when the body says nothing", () => { + expect(notificationApiFailureMessage(401, undefined)).toBe( + "unauthorized (expired or invalid auth)", + ); + expect(notificationApiFailureMessage(500, { error: " " })).toBe( + "HTTP 500", + ); + }); +}); + +describe("readNotificationApiBody", () => { + it("returns undefined for a body that is not JSON", async () => { + const res = { + json: async () => { + throw new SyntaxError("Unexpected token O"); + }, + } as unknown as Response; + await expect(readNotificationApiBody(res)).resolves.toBeUndefined(); + }); +}); diff --git a/src/test/smsNotificationApi.test.ts b/src/test/smsNotificationApi.test.ts new file mode 100644 index 00000000..88904ef9 --- /dev/null +++ b/src/test/smsNotificationApi.test.ts @@ -0,0 +1,182 @@ +import { + listSmsPhones, + registerSmsPhone, + SmsApiError, + smsErrorMessage, +} from "@/services/notifications/smsNotificationApi"; +import { createEndorserJwtForDid } from "@/libs/endorserServer"; + +jest.mock("@/libs/endorserServer", () => ({ + createEndorserJwtForDid: jest.fn(), +})); +jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } })); +jest.mock("@/services/notifications/NotificationDebugConfig", () => ({ + getNotificationApiBaseUrl: () => "https://notify.example", +})); +jest.mock("@/services/notifications/alertAuthorizationBatch", () => ({ + buildAlertAuthorizationBody: jest.fn(), +})); + +const mockedSign = createEndorserJwtForDid as unknown as jest.Mock; +const mockedFetch = jest.fn(); +global.fetch = mockedFetch as unknown as typeof fetch; + +const DID = "did:ethr:0x0000000000000000000000000000000000000001"; +const FALLBACK = "Something went wrong."; + +function answer(status: number, body: unknown) { + mockedFetch.mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), + }); +} + +async function refusal(status: number, body: unknown): Promise { + answer(status, body); + try { + await registerSmsPhone(DID, "555-555-0123"); + } catch (error) { + if (error instanceof SmsApiError) { + return error; + } + throw error; + } + throw new Error("expected the call to be refused"); +} + +beforeEach(() => { + mockedFetch.mockReset(); + mockedSign.mockReset().mockResolvedValue("signed"); +}); + +describe("listSmsPhones", () => { + it("returns the service's body and signs a claim without a number", async () => { + const body = { + success: true, + phones: [ + { + phoneNumber: "+15555550123", + verified: true, + verifiedAt: "2026-09-01T00:00:00.000Z", + createdAt: "2026-08-31T00:00:00.000Z", + }, + { + phoneNumber: "+15555550199", + verified: false, + verifiedAt: null, + createdAt: "2026-09-02T00:00:00.000Z", + }, + ], + }; + answer(200, body); + + await expect(listSmsPhones(DID)).resolves.toEqual(body); + expect(mockedSign).toHaveBeenCalledWith( + DID, + { + claim: { + "@context": "https://giftopia.tech", + "@type": "SmsNotificationAction", + action: "list-phones", + }, + }, + 300, + ); + expect(mockedFetch.mock.calls[0][0]).toBe( + "https://notify.example/notify-sms/phone", + ); + }); + + it("binds the claim and the query to a queried number", async () => { + answer(200, { success: true, phones: [], phoneNumber: "+15555550123" }); + + await listSmsPhones(DID, "(555) 555-0123"); + expect(mockedSign.mock.calls[0][1].claim.phoneNumber).toBe("+15555550123"); + expect(mockedFetch.mock.calls[0][0]).toBe( + "https://notify.example/notify-sms/phone?phoneNumber=%2B15555550123", + ); + }); +}); + +describe("refusals", () => { + it("keeps a coded refusal's extra fields", async () => { + const error = await refusal(400, { + success: false, + error: "SMS_CODE_MISMATCH", + message: "That code does not match.", + attemptsRemaining: 2, + }); + + expect(error.code).toBe("SMS_CODE_MISMATCH"); + expect(error.status).toBe(400); + expect(error.message).toBe("That code does not match."); + expect(smsErrorMessage(error, FALLBACK)).toBe( + "That code is not right. 2 tries left.", + ); + }); + + it("reads the identity limit from the refusal", async () => { + const error = await refusal(409, { + success: false, + error: "SMS_PHONE_DID_LIMIT", + message: "This number already carries the maximum number of identities.", + limit: 5, + verifiedCount: 5, + }); + + expect(smsErrorMessage(error, FALLBACK)).toBe( + "This number already has 5 identities on it, which is the limit.", + ); + }); + + it("does not show an auth stage's server-log message", async () => { + const error = await refusal(401, { + success: false, + message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z", + }); + + expect(error.code).toBe(""); + expect(error.response).toBeUndefined(); + expect(smsErrorMessage(error, FALLBACK)).toBe( + "The notification server could not confirm your identity. Please try again.", + ); + }); + + it("names an unreachable Endorser for an uncoded 503", async () => { + const error = await refusal(503, { + success: false, + message: "Authentication service unavailable. See server logs at now", + }); + + expect(smsErrorMessage(error, FALLBACK)).toBe( + "The notification server cannot confirm identities right now. Please try again later.", + ); + }); + + it("falls back for an answer that is not the service's JSON", async () => { + const error = await refusal(502, "Bad Gateway"); + + expect(error.code).toBe(""); + expect(error.message).toBe("HTTP 502"); + expect(smsErrorMessage(error, FALLBACK)).toBe(FALLBACK); + }); + + it("shows the service's message for a code this app does not know", async () => { + const error = await refusal(418, { + success: false, + error: "SMS_SOMETHING_NEW", + message: "A reason the app has no wording for.", + }); + + expect(smsErrorMessage(error, FALLBACK)).toBe( + "A reason the app has no wording for.", + ); + }); + + it("uses the fallback for errors that are not SmsApiError", () => { + expect(smsErrorMessage(new TypeError("Failed to fetch"), FALLBACK)).toBe( + FALLBACK, + ); + }); +}); diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 9985a961..b23e69bf 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -231,6 +231,12 @@ export const PlatformServiceMixin = { logger.debug( `[PlatformServiceMixin] ActiveDid updated in active_identity table: ${newDid}`, ); + if (newDid) { + const { onNotificationAuthMayBeReady } = await import( + "@/services/notifications/notificationAuthLifecycle" + ); + onNotificationAuthMayBeReady(); + } } catch (error) { logger.error( `[PlatformServiceMixin] Error updating activeDid in active_identity table ${newDid}:`, @@ -296,6 +302,7 @@ export const PlatformServiceMixin = { column === "warnIfProdServer" || column === "warnIfTestServer" || column === "reminderFastRolloverForTesting" || + column === "smsNotificationsEnabled" || // contacts column === "hideTheirContent" || column === "registered" || @@ -775,6 +782,23 @@ export const PlatformServiceMixin = { * Required for smart deletion pattern */ async $setActiveDid(did: string | null): Promise { + // The outgoing identity's background-prefetch JWTs cover days ahead and + // stay valid on their own; drop them here so they do not outlive the + // identity that authorized them. Imported lazily to keep the native + // notification stack out of the bundle for every component using this + // mixin. A failure to clear is logged inside and must not block the + // switch, which is why this is not awaited into the caller's failure path. + try { + const { clearNativeFetcherPool } = await import( + "@/services/notifications/nativeFetcherConfig" + ); + await clearNativeFetcherPool(); + } catch (error) { + logger.warn( + "[PlatformServiceMixin] Native JWT pool clear failed", + error, + ); + } await this.$dbExec( "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", [did], diff --git a/src/utils/includeDevToolkitRoutes.ts b/src/utils/includeDevToolkitRoutes.ts new file mode 100644 index 00000000..14308ce0 --- /dev/null +++ b/src/utils/includeDevToolkitRoutes.ts @@ -0,0 +1,6 @@ +/** + * True for `vite dev` and for `vite build` when mode is not `production` + * (e.g. `--mode capacitor`, `--mode test`). Use for dev-only routes and UI. + */ +export const includeDevToolkitRoutes = + import.meta.env.DEV || import.meta.env.MODE !== "production"; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 07e1566b..f0f4977e 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -164,10 +164,10 @@ async function logToDatabase( try { const platform = PlatformServiceFactory.getInstance(); - const todayKey = new Date().toDateString(); + const timestamp = new Date().toISOString(); await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [ - todayKey, + timestamp, `[${level.toUpperCase()}] ${message}`, ]); } catch (error) { diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index eb617ab7..1824a4a4 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -139,49 +139,212 @@ class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md" @click="editReminderNotification" > - Edit Notification Details… + Edit Daily Reminder… -

+ + +
+
+ New Activity Notification + +
+

+ Get told about new relevant activity for you. Choose a delivery + channel below -- you can turn on either one, or both. +

+
+ + +
- New Activity Notification
- -
-
-
- {{ notifyingNewActivityTime.replace(" ", " ") }} -
-
+
- Troubleshoot your notifications… + Troubleshoot your in-app notifications…
+
+
+
+ Time: {{ notifyingNewActivityTime.replace(" ", " ") }} +
+
+
+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+
+
+
+ +
+ Texting: {{ notifyingNewActivitySmsPhone }} +
+ +
+
+ +
+
+
+ New Activity Text + +
+ +
+ +
+
+
+
+
+
+
+ Time: + {{ notifyingNewActivitySmsTime.replace(" ", " ") }} +
+
+
+ +
+
+
+ +
-
-

Claim Server

-
-

- Claim Server Configuration -

- - - - -
+

+ Claim Server Configuration +

+ + + + +
+ + + +
+
+ + + + +
+ + + +
+

+ Notification Push Server +

+
+ +
+ + When that setting is blank, this app will use the default web push + server URL: + {{ DEFAULT_PUSH_SERVER }} +
- - - -
- - -

- Notification Push Server + Partner Server URL

- - When that setting is blank, this app will use the default web push + + When that setting is blank, this app will use the default partner server URL: - {{ DEFAULT_PUSH_SERVER }} + {{ DEFAULT_PARTNER_API_SERVER }} -
-

Partner Server URL

-
- - - - - -
- - When that setting is blank, this app will use the default partner server - URL: - {{ DEFAULT_PARTNER_API_SERVER }} - +
+ Image Server URL +   + {{ DEFAULT_IMAGE_API_SERVER }} +
-
- Image Server URL -   - {{ DEFAULT_IMAGE_API_SERVER }} +
+ Notify Server URL +   + {{ notifyApiServer }} + +
diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index 22a6f9da..a2027c27 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -647,6 +647,7 @@ import * as serverUtil from "../libs/endorserServer"; import { retrieveAccountDids } from "../libs/util"; import { copyToClipboard } from "../services/ClipboardService"; import { logger } from "../utils/logger"; +import { syncStarredPlansToNativePlugin } from "@/services/notifications"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; @@ -1545,6 +1546,9 @@ export default class ProjectViewView extends Vue { ); if (result) { this.isStarred = true; + if (settings.notifyingNewActivityTime) { + void syncStarredPlansToNativePlugin(newStarredIds); + } } else { // eslint-disable-next-line no-console logger.error("Got a bad result from SQL update to star a project."); @@ -1567,6 +1571,9 @@ export default class ProjectViewView extends Vue { ); if (result) { this.isStarred = false; + if (settings.notifyingNewActivityTime) { + void syncStarredPlansToNativePlugin(updatedIds); + } } else { // eslint-disable-next-line no-console logger.error("Got a bad result from SQL update to unstar a project."); diff --git a/src/views/dev/NotificationDebugView.vue b/src/views/dev/NotificationDebugView.vue new file mode 100644 index 00000000..d210afbf --- /dev/null +++ b/src/views/dev/NotificationDebugView.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 99e26006..8fe969ee 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,35 @@ /// +/* + * The triple-slash line above loads Vite's client types + * (node_modules/vite/client.d.ts, which references types/importMeta.d.ts). + * That file declares its own ImportMetaEnv, and TypeScript merges it with the + * interface below, so import.meta.env also has these fields that Vite fills in + * at dev and build time: + * + * BASE_URL: string the `base` config option + * MODE: string the --mode value; defaults to "development" for + * `vite dev` and "production" for `vite build` + * (this repo also uses "test" and "capacitor") + * DEV: boolean process.env.NODE_ENV !== "production" + * PROD: boolean process.env.NODE_ENV === "production" + * SSR: boolean true when running server-side rendering + * + * More about modes: https://vite.dev/guide/env-and-mode + * + * DEV and PROD follow NODE_ENV, not MODE. Vite sets NODE_ENV to + * "development" for `vite dev` and "production" for `vite build`, but only + * when the shell has not already set it. scripts/build-web.sh exports + * NODE_ENV=test or development for non-production web builds, so DEV is true + * in those builds. The Android, iOS, and Electron scripts leave NODE_ENV + * unset, so DEV is false in every one of their builds, including dev builds. + * + * Vite's ImportMetaEnv also has an index signature `[key: string]: any`, so + * any VITE_* variable type-checks without being declared here. Declaring one + * below only narrows its type from `any`. + * + * src/env.d.ts repeats the same vite/client reference. + */ interface ImportMetaEnv { readonly VITE_APP_TITLE: string; // more env variables... diff --git a/test-playwright/00-noid-tests.spec.ts b/test-playwright/00-noid-tests.spec.ts index 61b52eae..44604515 100644 --- a/test-playwright/00-noid-tests.spec.ts +++ b/test-playwright/00-noid-tests.spec.ts @@ -208,6 +208,7 @@ test('Confirm test API setting (may fail if you are running your own Time Safari // Load account view await page.goto('./account'); await page.getByTestId('advancedSettings').click(); + await page.getByTestId('serverUrlsToggle').click(); // look into the config file: if it starts Time Safari, it might say which server it should set by default const webServer = testInfo.config.webServer; diff --git a/test-playwright/50-record-offer.spec.ts b/test-playwright/50-record-offer.spec.ts index 895f947e..f8463ceb 100644 --- a/test-playwright/50-record-offer.spec.ts +++ b/test-playwright/50-record-offer.spec.ts @@ -85,7 +85,8 @@ test('Record an offer', async ({ page }) => { // click on the number of new offers to go to the list page await offerNumElem.click(); - await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible(); + // "Offer" for exactly one new offer, "Offers" otherwise + await expect(page.getByText(/^New Offers? To Your Projects$/)).toBeVisible(); // get the icon child of the showOffersToUserProjects await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click(); await expect(page.getByText(description)).toBeVisible();