diff --git a/android/app/src/main/java/app/timesafari/MainActivity.java b/android/app/src/main/java/app/timesafari/MainActivity.java index e02a3b78..d5261913 100644 --- a/android/app/src/main/java/app/timesafari/MainActivity.java +++ b/android/app/src/main/java/app/timesafari/MainActivity.java @@ -70,6 +70,10 @@ public class MainActivity extends BridgeActivity { // 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 2c2614d9..9b105b20 100644 --- a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java +++ b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java @@ -1,31 +1,63 @@ package app.timesafari; import android.content.Context; +import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; +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.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; + 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; public TimeSafariNativeFetcher(Context context) { - this.context = context; + this.appContext = context.getApplicationContext(); + this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } @Override @@ -33,41 +65,187 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher this.apiBaseUrl = apiBaseUrl; this.activeDid = activeDid; this.jwtToken = jwtToken; - Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid); + Log.i(TAG, "Configured with API: " + apiBaseUrl); } @NonNull @Override public CompletableFuture> fetchContent(@NonNull FetchContext fetchContext) { 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 + if (apiBaseUrl == null || activeDid == null || jwtToken == null) { + Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first."); + return Collections.emptyList(); + } - // For now, return a placeholder notification - long scheduledTime = fetchContext.scheduledTime != null - ? fetchContext.scheduledTime - : System.currentTimeMillis() + 60000; // 1 minute from now + 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 " + jwtToken); + connection.setDoOutput(true); - NotificationContent content = new NotificationContent( - "TimeSafari Update", - "Check your starred projects for updates!", - scheduledTime - ); + Map requestBody = new HashMap<>(); + requestBody.put("planIds", getStarredPlanIds()); + String afterId = getLastAcknowledgedJwtId(); + if (afterId == null || afterId.isEmpty()) { + afterId = "0"; + } + requestBody.put("afterId", afterId); - List results = new ArrayList<>(); - results.add(content); + String jsonBody = gson.toJson(requestBody); + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } - Log.d(TAG, "Returning " + results.size() + " notification(s)"); - return results; + int responseCode = connection.getResponseCode(); + Log.d(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(); + 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); + Log.w(TAG, "Retryable error " + responseCode + ", retrying in " + delayMs + "ms"); + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Collections.emptyList(); + } + return fetchContentWithRetry(context, retryCount + 1).join(); + } + + Log.e(TAG, "API error " + responseCode); + 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(); } }); } + + 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); + } + } + + 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) { + for (int i = 0; i < dataArray.size(); i++) { + JsonObject item = dataArray.get(i).getAsJsonObject(); + NotificationContent content = new NotificationContent(); + String planId = null; + String jwtId = null; + if (item.has("plan")) { + JsonObject plan = item.getAsJsonObject("plan"); + if (plan.has("handleId")) planId = plan.get("handleId").getAsString(); + if (plan.has("jwtId")) jwtId = plan.get("jwtId").getAsString(); + } + if (planId == null && item.has("planId")) planId = item.get("planId").getAsString(); + if (jwtId == null && item.has("jwtId")) jwtId = item.get("jwtId").getAsString(); + + content.setId("endorser_" + (jwtId != null ? jwtId : (System.currentTimeMillis() + "_" + i))); + content.setTitle(planId != null ? "Update: " + planId.substring(Math.max(0, planId.length() - 8)) : "Project Update"); + content.setBody(planId != null ? "Plan " + planId.substring(Math.max(0, planId.length() - 12)) + " has been updated." : "A project you follow has been updated."); + content.setScheduledTime(context.scheduledTime != null ? context.scheduledTime : (System.currentTimeMillis() + 3600000)); + content.setPriority("default"); + content.setSound(true); + contents.add(content); + } + } + if (contents.isEmpty()) { + NotificationContent defaultContent = new NotificationContent(); + defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis()); + defaultContent.setTitle("No Project Updates"); + defaultContent.setBody("No updates in your starred projects."); + defaultContent.setScheduledTime(context.scheduledTime != null ? context.scheduledTime : (System.currentTimeMillis() + 3600000)); + defaultContent.setPriority("default"); + defaultContent.setSound(true); + contents.add(defaultContent); + } + } catch (Exception e) { + Log.e(TAG, "Error parsing API response", e); + } + return contents; + } } diff --git a/doc/notification-from-api-call.md b/doc/notification-from-api-call.md new file mode 100644 index 00000000..227530b9 --- /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 + +- [x] **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)`. + +- [x] **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`; parses response into `NotificationContent` list; updates `last_acked_jwt_id` for pagination. + - Registered in `MainActivity.onCreate()` via `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`. + +- [x] **Sync starred plan IDs** + - When user enables New Activity, `scheduleNewActivityDualNotification()` calls `DailyNotification.updateStarredPlans({ planIds: settings.starredPlanHandleIds ?? [] })`. + - When Account view loads and New Activity is on, `initializeState()` calls `updateStarredPlans(settings.starredPlanHandleIds)` so the plugin has the latest list. + +- [x] **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()`. + +- [x] **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`. + +- [x] **Exports** + - `src/services/notifications/index.ts` exports `configureNativeFetcherIfReady`, `buildDualScheduleConfig`, `timeToCron`, `timeToCronFiveMinutesBefore`, and `DualScheduleConfigInput`. + +--- + +## Checklist of Remaining Tasks + +### iOS + +- [ ] **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 and sensible fallback (e.g. “No updates in your starred projects” or similar). + +- [ ] **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. + +- [ ] **Sync starred plans on star/unstar** + When the user stars or unstars a project elsewhere in the app, call `updateStarredPlans` so the plugin always has the current list without requiring a visit to Account. + +- [ ] **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` | +| 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` | +| 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` | diff --git a/src/interfaces/accountView.ts b/src/interfaces/accountView.ts index b4a336dc..4af6d6c9 100644 --- a/src/interfaces/accountView.ts +++ b/src/interfaces/accountView.ts @@ -33,6 +33,7 @@ export interface AccountSettings { notifyingNewActivityTime?: string; notifyingReminderMessage?: string; notifyingReminderTime?: string; + starredPlanHandleIds?: string[]; reminderFastRolloverForTesting?: boolean; partnerApiServer?: string; profileImageUrl?: string; diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index c8bcfa41..df726e72 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -43,6 +43,7 @@ import "./utils/safeAreaInset"; // Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery) import "@timesafari/daily-notification-plugin"; +import { configureNativeFetcherIfReady } from "@/services/notifications"; logger.log("[Capacitor] 🚀 Starting initialization"); logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); @@ -436,7 +437,7 @@ if ( }); } -// 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 +445,8 @@ setTimeout(async () => { ); await registerDeepLinkListener(); logger.info(`[Main] 🎉 Deep link system fully initialized!`); + // 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/services/notifications/dualScheduleConfig.ts b/src/services/notifications/dualScheduleConfig.ts new file mode 100644 index 00000000..7c4f4017 --- /dev/null +++ b/src/services/notifications/dualScheduleConfig.ts @@ -0,0 +1,88 @@ +/** + * Builds DualScheduleConfiguration for the Daily Notification plugin. + * Used for API-driven "New Activity" notifications (prefetch + notify). + */ + +/** + * 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): { + contentFetch: { + enabled: boolean; + schedule: string; + callbacks: Record; + }; + userNotification: { + enabled: boolean; + schedule: string; + title: string; + body: string; + sound: boolean; + priority: "high" | "normal" | "low"; + }; + relationship?: { + autoLink: boolean; + contentTimeout: number; + fallbackBehavior: "skip" | "show_default" | "retry"; + }; +} { + const notifyTime = input.notifyTime || "09:00"; + const fetchCron = timeToCronFiveMinutesBefore(notifyTime); + const notifyCron = timeToCron(notifyTime); + return { + contentFetch: { + enabled: true, + schedule: fetchCron, + callbacks: {}, + }, + userNotification: { + enabled: true, + schedule: notifyCron, + title: input.title ?? "New Activity", + body: input.body ?? "Check your starred projects and offers for updates.", + sound: true, + priority: "normal", + }, + relationship: { + autoLink: true, + contentTimeout: 5 * 60 * 1000, // 5 minutes + fallbackBehavior: "show_default", + }, + }; +} diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index 8f1283e4..1eabd1a0 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -17,6 +17,14 @@ export { NotificationService } from "./NotificationService"; export { NativeNotificationService } from "./NativeNotificationService"; export { WebPushNotificationService } from "./WebPushNotificationService"; +export { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; +export { + buildDualScheduleConfig, + timeToCron, + timeToCronFiveMinutesBefore, +} from "./dualScheduleConfig"; +export type { DualScheduleConfigInput } from "./dualScheduleConfig"; + export type { NotificationServiceInterface, DailyNotificationOptions, diff --git a/src/services/notifications/nativeFetcherConfig.ts b/src/services/notifications/nativeFetcherConfig.ts new file mode 100644 index 00000000..ebefe800 --- /dev/null +++ b/src/services/notifications/nativeFetcherConfig.ts @@ -0,0 +1,99 @@ +/** + * 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 { getHeaders } from "@/libs/endorserServer"; +import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; +import { logger } from "@/utils/logger"; +import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; + +/** + * 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; + } + + const headers = await getHeaders(did); + const auth = headers?.Authorization; + const jwtToken = + typeof auth === "string" && auth.startsWith("Bearer ") + ? auth.slice(7) + : ""; + 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, + }); + logger.info( + "[nativeFetcherConfig] Native fetcher configured for API-driven notifications", + ); + return true; + } catch (error) { + logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error); + return false; + } +} diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 34b8326f..882b9f8c 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -143,30 +143,36 @@ -
+
New Activity Notification - + > +
- -
-
@@ -807,7 +813,12 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { AccountSettings, isApiError } from "@/interfaces/accountView"; -import { NotificationService } from "@/services/notifications"; +import { + NotificationService, + configureNativeFetcherIfReady, + buildDualScheduleConfig, +} from "@/services/notifications"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; // Profile data interface (inlined from ProfileService) interface ProfileData { description: string; @@ -1100,6 +1111,14 @@ export default class AccountViewView extends Vue { this.warnIfTestServer = !!settings.warnIfTestServer; this.webPushServer = settings.webPushServer || this.webPushServer; this.webPushServerInput = settings.webPushServer || this.webPushServerInput; + + if (Capacitor.isNativePlatform() && this.activeDid) { + void configureNativeFetcherIfReady(this.activeDid); + if (this.notifyingNewActivity && DailyNotification?.updateStarredPlans) { + const planIds = settings?.starredPlanHandleIds ?? []; + void DailyNotification.updateStarredPlans({ planIds }); + } + } } // call fn, copy text to the clipboard, then redo fn after 2 seconds @@ -1193,11 +1212,24 @@ export default class AccountViewView extends Vue { }); this.notifyingNewActivity = true; this.notifyingNewActivityTime = timeText; + if (Capacitor.isNativePlatform()) { + await this.scheduleNewActivityDualNotification(timeText); + } } }); } else { this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => { if (success) { + if ( + Capacitor.isNativePlatform() && + DailyNotification?.cancelDualSchedule + ) { + try { + await DailyNotification.cancelDualSchedule(); + } catch (e) { + logger.error("[AccountViewView] cancelDualSchedule failed:", e); + } + } await this.$saveSettings({ notifyingNewActivityTime: "", }); @@ -1208,6 +1240,37 @@ export default class AccountViewView extends Vue { } } + /** + * Configure native fetcher, sync starred plans, and schedule API-driven dual notification. + */ + async scheduleNewActivityDualNotification(notifyTime: string): Promise { + try { + await configureNativeFetcherIfReady(this.activeDid); + const settings = await this.$accountSettings(); + const planIds = settings?.starredPlanHandleIds ?? []; + if (DailyNotification?.updateStarredPlans) { + await DailyNotification.updateStarredPlans({ planIds }); + } + const config = buildDualScheduleConfig({ notifyTime }); + await ( + DailyNotification as unknown as { + scheduleDualNotification: (opts: { + config: unknown; + }) => Promise; + } + ).scheduleDualNotification({ config }); + } catch (error) { + logger.error( + "[AccountViewView] scheduleNewActivityDualNotification failed:", + error, + ); + this.notify.error( + "Could not schedule New Activity notification. Please try again.", + TIMEOUTS.STANDARD, + ); + } + } + async showReminderNotificationInfo(): Promise { this.notify.confirm( ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,