make an attempt at new notifications using an API (fires always, can't turn off)

This commit is contained in:
2026-03-15 19:34:30 -06:00
parent c0678385df
commit 8ac6dd6ce0
9 changed files with 583 additions and 31 deletions

View File

@@ -71,6 +71,10 @@ public class MainActivity extends BridgeActivity {
// Plugin is written in Kotlin but compiles to Java-compatible bytecode // Plugin is written in Kotlin but compiles to Java-compatible bytecode
registerPlugin(org.timesafari.dailynotification.DailyNotificationPlugin.class); 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 // Initialize SQLite
//registerPlugin(SQLite.class); //registerPlugin(SQLite.class);

View File

@@ -1,31 +1,63 @@
package app.timesafari; package app.timesafari;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; 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.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher; import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent; 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.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture; 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 { public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
private static final String TAG = "TimeSafariNativeFetcher"; 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 apiBaseUrl;
private volatile String activeDid; private volatile String activeDid;
private volatile String jwtToken; private volatile String jwtToken;
public TimeSafariNativeFetcher(Context context) { public TimeSafariNativeFetcher(Context context) {
this.context = context; this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
} }
@Override @Override
@@ -33,41 +65,187 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
this.apiBaseUrl = apiBaseUrl; this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid; this.activeDid = activeDid;
this.jwtToken = jwtToken; this.jwtToken = jwtToken;
Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid); Log.i(TAG, "Configured with API: " + apiBaseUrl);
} }
@NonNull @NonNull
@Override @Override
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) { public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger); Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
return fetchContentWithRetry(fetchContext, 0);
}
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
try { try {
// TODO: Implement actual content fetching for TimeSafari if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
// This should query the TimeSafari API for notification content Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first.");
// using the configured apiBaseUrl, activeDid, and jwtToken return Collections.emptyList();
}
// For now, return a placeholder notification String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
long scheduledTime = fetchContext.scheduledTime != null URL url = new URL(urlString);
? fetchContext.scheduledTime HttpURLConnection connection = (HttpURLConnection) url.openConnection();
: System.currentTimeMillis() + 60000; // 1 minute from now 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( Map<String, Object> requestBody = new HashMap<>();
"TimeSafari Update", requestBody.put("planIds", getStarredPlanIds());
"Check your starred projects for updates!", String afterId = getLastAcknowledgedJwtId();
scheduledTime if (afterId == null || afterId.isEmpty()) {
); afterId = "0";
}
requestBody.put("afterId", afterId);
List<NotificationContent> results = new ArrayList<>(); String jsonBody = gson.toJson(requestBody);
results.add(content); 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)"); int responseCode = connection.getResponseCode();
return results; 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<NotificationContent> 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) { } catch (Exception e) {
Log.e(TAG, "Fetch failed", 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(); return Collections.emptyList();
} }
}); });
} }
private List<String> 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<String> 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<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
List<NotificationContent> 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;
}
} }

View File

@@ -0,0 +1,108 @@
# New Activity Notification (API-Driven Daily Message)
**Purpose:** Integrate the daily-notification-plugins 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 users 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 plugins 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 plugins 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 Androids `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` |

View File

@@ -33,6 +33,7 @@ export interface AccountSettings {
notifyingNewActivityTime?: string; notifyingNewActivityTime?: string;
notifyingReminderMessage?: string; notifyingReminderMessage?: string;
notifyingReminderTime?: string; notifyingReminderTime?: string;
starredPlanHandleIds?: string[];
reminderFastRolloverForTesting?: boolean; reminderFastRolloverForTesting?: boolean;
partnerApiServer?: string; partnerApiServer?: string;
profileImageUrl?: string; profileImageUrl?: string;

View File

@@ -43,6 +43,7 @@ import "./utils/safeAreaInset";
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery) // Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
import "@timesafari/daily-notification-plugin"; import "@timesafari/daily-notification-plugin";
import { configureNativeFetcherIfReady } from "@/services/notifications";
logger.log("[Capacitor] 🚀 Starting initialization"); logger.log("[Capacitor] 🚀 Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); 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 () => { setTimeout(async () => {
try { try {
logger.info( logger.info(
@@ -444,6 +445,8 @@ setTimeout(async () => {
); );
await registerDeepLinkListener(); await registerDeepLinkListener();
logger.info(`[Main] 🎉 Deep link system fully initialized!`); logger.info(`[Main] 🎉 Deep link system fully initialized!`);
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
await configureNativeFetcherIfReady();
} catch (error) { } catch (error) {
logger.error(`[Main] ❌ Deep link system initialization failed:`, error); logger.error(`[Main] ❌ Deep link system initialization failed:`, error);
} }

View File

@@ -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<string, unknown>;
};
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",
},
};
}

View File

@@ -17,6 +17,14 @@ export { NotificationService } from "./NotificationService";
export { NativeNotificationService } from "./NativeNotificationService"; export { NativeNotificationService } from "./NativeNotificationService";
export { WebPushNotificationService } from "./WebPushNotificationService"; export { WebPushNotificationService } from "./WebPushNotificationService";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export {
buildDualScheduleConfig,
timeToCron,
timeToCronFiveMinutesBefore,
} from "./dualScheduleConfig";
export type { DualScheduleConfigInput } from "./dualScheduleConfig";
export type { export type {
NotificationServiceInterface, NotificationServiceInterface,
DailyNotificationOptions, DailyNotificationOptions,

View File

@@ -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<boolean> {
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;
}
}

View File

@@ -143,30 +143,36 @@
</button> </button>
</div> </div>
</div> </div>
<div v-if="false" class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<!-- label --> <!-- label -->
<div> <div>
New Activity Notification New Activity Notification
<font-awesome <button
icon="question-circle"
class="text-slate-400 fa-fw cursor-pointer" class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo" @click.stop="showNewActivityNotificationInfo"
/> >
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
</div> </div>
<!-- toggle --> <!-- toggle -->
<div <div
class="relative ml-2 cursor-pointer" class="relative ml-2 cursor-pointer"
@click="showNewActivityNotificationChoice()" role="switch"
:aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications"
tabindex="0"
@click.stop.prevent="showNewActivityNotificationChoice()"
> >
<!-- input -->
<input <input
v-model="notifyingNewActivity" :checked="notifyingNewActivity"
type="checkbox" type="checkbox"
class="sr-only" class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/> />
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div> ></div>
@@ -807,7 +813,12 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { AccountSettings, isApiError } from "@/interfaces/accountView"; 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) // Profile data interface (inlined from ProfileService)
interface ProfileData { interface ProfileData {
description: string; description: string;
@@ -1100,6 +1111,14 @@ export default class AccountViewView extends Vue {
this.warnIfTestServer = !!settings.warnIfTestServer; this.warnIfTestServer = !!settings.warnIfTestServer;
this.webPushServer = settings.webPushServer || this.webPushServer; this.webPushServer = settings.webPushServer || this.webPushServer;
this.webPushServerInput = settings.webPushServer || this.webPushServerInput; 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 // 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.notifyingNewActivity = true;
this.notifyingNewActivityTime = timeText; this.notifyingNewActivityTime = timeText;
if (Capacitor.isNativePlatform()) {
await this.scheduleNewActivityDualNotification(timeText);
}
} }
}); });
} else { } else {
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => { this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
if (success) { if (success) {
if (
Capacitor.isNativePlatform() &&
DailyNotification?.cancelDualSchedule
) {
try {
await DailyNotification.cancelDualSchedule();
} catch (e) {
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
}
}
await this.$saveSettings({ await this.$saveSettings({
notifyingNewActivityTime: "", 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<void> {
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<void>;
}
).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<void> { async showReminderNotificationInfo(): Promise<void> {
this.notify.confirm( this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO, ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,