forked from trent_larson/crowd-funder-for-time-pwa
make an attempt at new notifications using an API (fires always, can't turn off)
This commit is contained in:
@@ -70,6 +70,10 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// Register DailyNotification plugin
|
// Register DailyNotification plugin
|
||||||
// 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
doc/notification-from-api-call.md
Normal file
108
doc/notification-from-api-call.md
Normal file
@@ -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` |
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/services/notifications/dualScheduleConfig.ts
Normal file
88
src/services/notifications/dualScheduleConfig.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
99
src/services/notifications/nativeFetcherConfig.ts
Normal file
99
src/services/notifications/nativeFetcherConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user