Compare commits

..

19 Commits

Author SHA1 Message Date
Jose Olarte III
ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
ea662f4430 bump to v 1.3.13 (for a web release) 2026-04-05 19:58:36 -06:00
81647e1f3c make terms & conditions into a separate page 2026-04-05 19:21:43 -06:00
bf1ee78025 allow a custom error message to stay on the screen indefinitely 2026-03-29 19:11:49 -06:00
Jose Olarte III
66b7d0f46e docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-03-26 19:41:03 +08:00
Jose Olarte III
63dcf44125 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-03-26 19:40:07 +08:00
cf1ecdfb4c add registration for new contacts that are unregistered 2026-03-22 20:20:33 -06:00
e9ad61b780 don't delete a gift image on an edit unless they hit 'save' 2026-03-22 20:07:59 -06:00
ad8df3eb93 fix problem where canceling an edit deletes an image 2026-03-22 20:06:58 -06:00
05d346edce add project selection for one that this 'fulfills' 2026-03-22 17:58:46 -06:00
e259e60fa7 bump version and add "-beta" 2026-03-22 17:39:46 -06:00
821de3f006 do not toggle off the 'advanced' section in account view with the 'general' toggle is disabled 2026-03-22 09:53:56 -06:00
43f83031d4 rename app from "Gifties" to "Giftopia" 2026-03-21 16:27:21 -06:00
688a48a332 bump to version 1.3.12 build 67 2026-03-21 16:22:14 -06:00
8938c242ee change more files to name the app "Gifties" 2026-03-20 19:33:04 -06:00
358af42afd rename from "Gift Economies" to "Gifties" 2026-03-19 21:18:11 -06:00
59c00241b8 add the nearest-neighbor feature to the claim screen 2026-03-19 20:24:09 -06:00
33ec90e571 move the 'discover' page 'starred' word to be on the same level 2026-03-18 19:44:25 -06:00
69 changed files with 1571 additions and 3872 deletions

View File

@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
- If there are DB changes: before updating the test server, open browser(s) with
current version to test DB migrations.
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
`npm install`.
- Run a build to make sure package-lock version is updated, linting works, etc:
`npm install && npm run build:web`
- Run a build to make sure linting works, etc:
`npm run build:web`
- Commit everything (since the commit hash is used the app).
@@ -346,7 +346,7 @@ current version to test DB migrations.
- Tag with the new version,
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
`git tag 1.0.2 && git push origin 1.0.2`.
`git tag 1.3.13 && git push origin 1.3.13`.
- For test, build the app:
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 67 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.12;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1419,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 66/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.4.1"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 67/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.3.12"/g' android/app/build.gradle
```
##### 2. Build

View File

@@ -6,9 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.8] - 2026
## [1.3.13] - 2026.04.05
### Added
- Ability to select project that the current one fulfills
- Separate Terms & Conditions page (required for SMS campaigns)
### Fixed
- Edits to a 'give' would delete the image
## [1.3.12] - 2026.03.21
### Added
- Device wake-up for notifications
### Changed
- Rename to "Gifties"
## [1.3.7]

View File

@@ -37,8 +37,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 66
versionName "1.4.1"
versionCode 67
versionName "1.3.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -70,10 +70,6 @@ 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);

View File

@@ -1,395 +1,73 @@
package app.timesafari;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.timesafari.dailynotification.FetchContext;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* Native content fetcher for API-driven daily notifications.
* Calls Endorser.ch plansLastUpdatedBetween with configured credentials and
* starred plan IDs (from plugin's updateStarredPlans), then returns notification content.
*/
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
private static final String TAG = "TimeSafariNativeFetcher";
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 15000;
private static final int MAX_RETRIES = 3;
/** Max chars of response body logged at DEBUG (avoids huge log lines). */
private static final int MAX_RESPONSE_BODY_LOG_CHARS = 4096;
private static final int RETRY_DELAY_MS = 1000;
// Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager)
private static final String PREFS_NAME = "daily_notification_timesafari";
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds";
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
private final Gson gson = new Gson();
private final Context appContext;
private final SharedPreferences prefs;
private final Context context;
// Configuration from TypeScript (set via configure())
private volatile String apiBaseUrl;
private volatile String activeDid;
private volatile String jwtToken;
/** Distinct JWTs from configureNativeFetcher `jwtTokens`; null = use jwtToken only. */
@Nullable
private List<String> jwtTokenPool;
public TimeSafariNativeFetcher(Context context) {
this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
this.context = context;
}
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
configure(apiBaseUrl, activeDid, jwtToken, null);
}
@Override
public void configure(
String apiBaseUrl,
String activeDid,
String jwtToken,
@Nullable List<String> jwtTokenPool) {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
this.jwtTokenPool =
jwtTokenPool != null && !jwtTokenPool.isEmpty()
? new ArrayList<>(jwtTokenPool)
: null;
int starredCount = getStarredPlanIds().size();
Log.i(
TAG,
"Configured with API: "
+ apiBaseUrl
+ ", starredPlanIds count="
+ starredCount
+ (this.jwtTokenPool != null
? ", jwtPoolSize=" + this.jwtTokenPool.size()
: ""));
}
/** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
private String selectBearerTokenForRequest() {
List<String> pool = jwtTokenPool;
if (pool == null || pool.isEmpty()) {
return jwtToken;
}
long epochDay = System.currentTimeMillis() / (24L * 60 * 60 * 1000);
int idx = (int) (epochDay % pool.size());
String t = pool.get(idx);
if (t == null || t.isEmpty()) {
return jwtToken;
}
Log.i(TAG, "Bearer from JWT pool: index=" + idx + " of " + pool.size());
return t;
Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl + ", DID: " + activeDid);
}
@NonNull
@Override
public CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext fetchContext) {
Long scheduled = fetchContext.scheduledTime;
Log.i(
TAG,
"fetchContent START trigger="
+ fetchContext.trigger
+ " scheduledTime="
+ (scheduled != null ? scheduled : "null")
+ " callerThread="
+ Thread.currentThread().getName());
Log.d(TAG, "Fetching notification content, trigger: " + fetchContext.trigger);
return fetchContentWithRetry(fetchContext, 0);
}
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> {
try {
Log.i(TAG, "fetchContent worker thread=" + Thread.currentThread().getName());
String bearer = selectBearerTokenForRequest();
if (apiBaseUrl == null || activeDid == null || bearer == null || bearer.isEmpty()) {
Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList();
}
// TODO: Implement actual content fetching for TimeSafari
// This should query the TimeSafari API for notification content
// using the configured apiBaseUrl, activeDid, and jwtToken
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + bearer);
connection.setDoOutput(true);
// For now, return a placeholder notification
long scheduledTime = fetchContext.scheduledTime != null
? fetchContext.scheduledTime
: System.currentTimeMillis() + 60000; // 1 minute from now
Map<String, Object> requestBody = new HashMap<>();
List<String> planIds = getStarredPlanIds();
requestBody.put("planIds", planIds);
String afterId = getLastAcknowledgedJwtId();
if (afterId == null || afterId.isEmpty()) {
afterId = "0";
}
requestBody.put("afterId", afterId);
Log.i(
TAG,
"POST "
+ ENDORSER_ENDPOINT
+ " planCount="
+ planIds.size()
+ " afterId="
+ (afterId.length() > 12 ? afterId.substring(0, 12) + "" : afterId));
NotificationContent content = new NotificationContent(
"TimeSafari Update",
"Check your starred projects for updates!",
scheduledTime
);
String jsonBody = gson.toJson(requestBody);
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
List<NotificationContent> results = new ArrayList<>();
results.add(content);
int responseCode = connection.getResponseCode();
Log.i(TAG, "HTTP response code: " + responseCode);
Log.d(TAG, "Returning " + results.size() + " notification(s)");
return results;
if (responseCode == 200) {
StringBuilder response = new StringBuilder();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
String responseBody = response.toString();
String snippet =
responseBody.length() <= MAX_RESPONSE_BODY_LOG_CHARS
? responseBody
: responseBody.substring(0, MAX_RESPONSE_BODY_LOG_CHARS) + "";
Log.d(
TAG,
"plansLastUpdatedBetween response len="
+ responseBody.length()
+ " body="
+ snippet);
List<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);
String errBody = readHttpErrorBodySnippet(connection);
Log.w(
TAG,
"Retryable error "
+ responseCode
+ (errBody.isEmpty() ? "" : " body: " + errBody)
+ ", retrying in "
+ delayMs
+ "ms");
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
String errBody = readHttpErrorBodySnippet(connection);
if (errBody.isEmpty()) {
Log.e(TAG, "API error " + responseCode);
} else {
Log.e(TAG, "API error " + responseCode + " body: " + errBody);
}
return Collections.emptyList();
} catch (Exception e) {
Log.e(TAG, "Fetch failed", e);
if (retryCount < MAX_RETRIES) {
try {
Thread.sleep(RETRY_DELAY_MS * (1 << retryCount));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
return Collections.emptyList();
}
});
}
/**
* Reads error response body for logging (HttpURLConnection puts 4xx/5xx bodies on
* {@link HttpURLConnection#getErrorStream()}).
*/
private static String readHttpErrorBodySnippet(HttpURLConnection connection) {
InputStream stream = connection.getErrorStream();
if (stream == null) {
return "";
}
final int maxChars = 4096;
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (sb.length() > 0) {
sb.append('\n');
}
if (sb.length() + line.length() > maxChars) {
sb.append(line, 0, Math.max(0, maxChars - sb.length()));
sb.append("");
break;
}
sb.append(line);
}
return sb.toString().trim();
} catch (IOException e) {
return "(read error body failed: " + e.getMessage() + ")";
}
}
private List<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);
}
}
/**
* Display title for a plansLastUpdatedBetween row; prefers {@code plan.name}, else "Unnamed Project".
*/
private String extractProjectDisplayTitle(JsonObject item) {
if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("name") && !plan.get("name").isJsonNull()) {
String name = plan.get("name").getAsString();
if (name != null && !name.trim().isEmpty()) {
return name.trim();
}
}
}
return "Unnamed Project";
}
@Nullable
private String extractJwtIdFromItem(JsonObject item) {
if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("jwtId") && !plan.get("jwtId").isJsonNull()) {
return plan.get("jwtId").getAsString();
}
}
if (item.has("jwtId") && !item.get("jwtId").isJsonNull()) {
return item.get("jwtId").getAsString();
}
return null;
}
private List<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 || dataArray.size() == 0) {
return contents;
}
JsonObject firstItem = dataArray.get(0).getAsJsonObject();
String firstTitle = extractProjectDisplayTitle(firstItem);
String jwtId = extractJwtIdFromItem(firstItem);
NotificationContent content = new NotificationContent();
content.setId("endorser_" + (jwtId != null ? jwtId : ("batch_" + System.currentTimeMillis())));
int n = dataArray.size();
String quotedFirst = "\u201C" + firstTitle + "\u201D";
if (n == 1) {
content.setTitle("Starred Project Update");
content.setBody(quotedFirst + " has been updated.");
} else {
content.setTitle("Starred Project Updates");
int more = n - 1;
content.setBody(quotedFirst + " + " + more + " more have been updated.");
}
content.setScheduledTime(
context.scheduledTime != null
? context.scheduledTime
: (System.currentTimeMillis() + 3600000));
content.setPriority("default");
content.setSound(true);
contents.add(content);
} catch (Exception e) {
Log.e(TAG, "Error parsing API response", e);
}
return contents;
}
}

View File

@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">TimeSafari</string>
<string name="title_activity_main">TimeSafari</string>
<string name="app_name">Giftopia</string>
<string name="title_activity_main">Giftopia</string>
<string name="package_name">timesafari.app</string>
<string name="custom_url_scheme">timesafari.app</string>
</resources>

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -73,7 +73,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

View File

@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
appName: 'Giftopia',
webDir: 'dist',
server: {
cleartext: true
@@ -36,12 +36,12 @@ const config: CapacitorConfig = {
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
biometricTitle: 'Biometric login for Giftopia'
},
electronIsEncryption: false
},
@@ -100,7 +100,7 @@ const config: CapacitorConfig = {
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
productName: 'Giftopia',
directories: {
output: 'dist-electron-packages'
},

View File

@@ -61,14 +61,16 @@ The app depends on:
"@timesafari/daily-notification-plugin": "git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master"
```
If the fixes were only made in a **local clone** and never pushed to **gitea** `master`, then:
If the fixes were only made in a **different** clone (e.g. `daily-notification-plugin_test`) and never pushed to that gitea `master`, then:
- `npm install` / `npm update` in the app would not pull the fixes.
- The apps `node_modules` would only have the fixes if they were copied/linked from the fixed repo.
**Do this:**
- **Push** the fixed plugin to the official gitea repo (`trent_larson/daily-notification-plugin`), then in this app run `npm update @timesafari/daily-notification-plugin` (or set `package.json` to the branch/tag/commit you need), `npm install`, `npx cap sync android`, clean build and reinstall. The app should always depend on the published git remote, not a local `file:` path.
- If the fixes live in another clone: either **push** the fixed plugin to gitea `master` and run `npm update @timesafari/daily-notification-plugin` (then `npx cap sync android`, then clean build), **or** point the app at the fixed plugin locally, e.g. in **app** `package.json`:
- `"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin"`
(adjust path to your fixed plugin repo), then `npm install`, `npx cap sync android`, clean build and reinstall.
### 3. Fallback text from native fetcher (Bug 2 only)

View File

@@ -1,80 +0,0 @@
# Consuming app handoff: iOS native fetcher + chained dual (mirror)
**Canonical source:** `daily-notification-plugin` repo, `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` (same content as below for offline use).
---
## Implemented in this app
- **`ios/App/App/TimeSafariNativeFetcher.swift`** — Swift `NativeNotificationContentFetcher` mirroring `TimeSafariNativeFetcher.java` (`POST …/plansLastUpdatedBetween`, starred IDs from `daily_notification_timesafari.starredPlanIds`, JWT pool selection, pagination key `daily_notification_timesafari.last_acked_jwt_id`, aggregated copy).
- **`AppDelegate.swift`** — `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any JS `configureNativeFetcher`; foreground handler reads `scheduled_time` as `Int64`, `NSNumber`, or `Int` for `DailyNotificationDelivered`.
## Dependency
- **`@timesafari/daily-notification-plugin`** must be **≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Declare it in `package.json` from the official remote (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`, branch or tag as needed), then `npm install` so `package-lock.json` resolves the published tree.
## Bump / sync (after plugin version is resolved)
1. `npm install`
2. `npx cap sync ios && npx cap sync android`
3. `cd ios/App && pod install`
4. Clean build in Xcode / Android Studio
## QA focus
- iOS: Fetcher registered before `configureNativeFetcher`; `updateStarredPlans` not `UNIMPLEMENTED`.
- Both: New Activity fires **after** prefetch for that cycle where the plugin implements chaining.
- Android: Existing `MainActivity.setNativeFetcher` unchanged; regression-test `cancelDualSchedule` vs Daily Reminder.
---
## Original handoff text (from plugin)
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
- **iOS** `NativeNotificationContentFetcher`style registration (`DailyNotificationPlugin.registerNativeFetcher`)
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; the plugin doc adds **app-side** steps not spelled out there.
### 1. iOS — register native fetcher before `configureNativeFetcher`
The plugin **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
```swift
import TimesafariDailyNotificationPlugin
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
```
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
- Conforms to `NativeNotificationContentFetcher`
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java`
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS).
### 2. iOS — `UNUserNotificationCenterDelegate` / rollover
Chained dual notifications set:
- `notification_id` = `org.timesafari.dailynotification.dual`
- `scheduled_time` = `NSNumber` (fire time in ms)
Ensure **`DailyNotificationDelivered`** forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`**.
### 3. Android — no API change for `setNativeFetcher`
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` keep that flow.
**Behavior change:** the dual **notify** alarm is scheduled when **dual prefetch work finishes**, not at the initial `scheduleDualNotification` only.
### 4. Assumptions
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
- Module import: `TimesafariDailyNotificationPlugin` (Pod `TimesafariDailyNotificationPlugin`).

View File

@@ -6,7 +6,8 @@
2. **Notifications show when the app is in the foreground** (not only background/closed).
3. **Plugin loads at app launch** so recovery runs after reboot without the user opening notification UI.
**Reference:** In the **daily-notification-plugin** repository, the test app lives at `test-apps/daily-notification-test` (same repo as `https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin`).
**Reference:** Test app at
`/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin/test-apps/daily-notification-test`
---

View File

@@ -1,152 +0,0 @@
# Options: expired JWT during background “New Activity” prefetch (mobile)
**Date:** 2026-03-26 17:29 PST
**Audience:** TimeSafari / crowd-funder team; **Endorser server** maintainers (auth + API policy)
**Context:** Android Capacitor app, `POST /api/v2/report/plansLastUpdatedBetween`, native `TimeSafariNativeFetcher` invoked from WorkManager at **T5 minutes** before the daily notification.
---
## Problem (short)
New Activity notifications prefetch Endorser data in **background** (no JavaScript, no WebView). The HTTP client uses a **Bearer JWT** supplied earlier via `configureNativeFetcher` / `getHeaders(activeDid)`.
If the **access tokens `exp`** is **before** prefetch time, the API returns **400** with a body like:
```json
{
"error": {
"message": "JWT failed verification: ... JWT has expired: exp: … < now: …",
"code": "JWT_VERIFY_FAILED"
}
}
```
We **cannot** rely on the user opening the app immediately before prefetch (T5), so **client-only** mitigations (e.g. refresh JWT on app resume) **reduce** failures but **do not guarantee** a valid token for headless background work.
---
## Why this is different from normal in-app API calls
| In-app | Background prefetch |
|--------|----------------------|
| `getHeaders()` runs in JS when needed; user often recently active | WorkManager runs **without** Capacitor / passkey / session refresh |
| Short TTL tokens are refreshed as the user uses the app | Same token may sit in native memory until **T5** (or longer) |
So **server-side** and **architecture** choices matter for this feature.
---
## Options (for decision)
### 1. Increase access token TTL (Endorser / IdP)
**Idea:** Issue access JWTs with a longer `exp` so that **configure time → prefetch time** (often **5+ minutes**, sometimes **24h+** if the user rarely opens the app) usually still falls inside validity.
| Pros | Cons |
|------|------|
| Simple to explain; one policy change | Longer-lived bearer tokens increase risk if exfiltrated; mitigate with scope, rotation, monitoring |
| No client protocol change | May not fit strict security posture without a dedicated scope |
**Endorser owner:** token lifetime, scopes, and whether a **dedicated** lifetime or scope for “mobile background read” is acceptable.
---
### 2. Scoped long-lived token for report reads only (Endorser)
**Idea:** Mint a **separate** access token (or sub-scope) valid only for **read-only report** endpoints (`plansLastUpdatedBetween`, etc.), with a **longer TTL** than the interactive session token.
| Pros | Cons |
|------|------|
| Limits blast radius vs “longer JWT for everything” | Requires auth model + issuance path; client must store/use this token only for prefetch |
**Endorser owner:** feasibility of **narrow scope** + **longer TTL** for this use case.
---
### 3. Refresh token or device grant (Endorser + mobile native)
**Idea:** Client stores a **refresh token** (or OAuth **device** grant) in **Android Keystore / iOS Keychain**. Before `plansLastUpdatedBetween`, **native** code (no JS) exchanges it for a **new access token**.
| Pros | Cons |
|------|------|
| Standard pattern; short TTL for access tokens remains | Endorser must support refresh (or equivalent); secure storage + rotation; **both** client and server work |
| Works when app is backgrounded for days | Implementation cost on mobile |
**Endorser owner:** refresh endpoint, token rotation, revocation.
**Mobile owner:** native fetch path, secure storage, failure handling.
---
### 4. Backend proxy / BFF (TimeSafari backend + Endorser)
**Idea:** Phone calls **your** backend with a **device session** (or FCM registration id); **server** uses **server-to-server** credentials or a **service account** to call Endorser. The device **never** sends an Endorser JWT for this path.
| Pros | Cons |
|------|------|
| No Endorser JWT lifetime problem on device | New service, auth, rate limits, privacy review |
| Central place for logging, abuse control | Operational cost |
**Endorser owner:** partner / S2S auth model for the BFF.
**Product team:** hosting and trust boundaries.
---
### 5. “Cron” or periodic jobs on the device to refresh JWT (JS)
**Idea:** Use something like a **cron** schedule to refresh tokens.
**Reality:** Scheduled **native** jobs can run, but **Capacitor / `getHeaders()` / passkey** do **not** run reliably in that context without waking the **WebView**. So **“cron”** only helps if refresh is **fully native** (see option 3) or you accept **unreliable** wake + JS.
**Not recommended** as the primary fix unless paired with **native refresh** or **server** changes.
---
### 6. Product / UX constraints (no server change)
**Idea:** Accept that **headless** API calls may fail if the session is stale; show **fallback** copy; or require “open app once per day” for best results.
| Pros | Cons |
|------|------|
| No Endorser change | Does not meet “API-driven notification” expectation for inactive users |
---
## Client-side mitigations already in play (not sufficient alone)
- **`configureNativeFetcherIfReady()`** after startup and when **Account** / identity is ready.
- **`appStateChange``isActive`:** refresh native fetcher when the app returns to foreground (reduces staleness when the user **does** open the app).
- **Error logging** of 400 bodies for diagnosis.
These **do not** guarantee a fresh JWT at **T5** if the user never opens the app before prefetch.
---
## Suggested decision order
1. **Align on security posture:** Is a **longer TTL** or **scoped long-lived read token** acceptable for Endorser?
2. If not, is **refresh token in native** (option 3) or **BFF** (option 4) on the roadmap?
3. **Parallel:** UX fallback when API is unavailable (option 6) so the app never silently looks “broken.”
---
## References (this repo)
| Topic | Location |
|--------|----------|
| Native fetcher + JWT from `getHeaders` | `src/services/notifications/nativeFetcherConfig.ts` |
| Android POST + errors | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
| Web `plansLastUpdatedBetween` + `afterId` | `src/libs/endorserServer.ts` (`getStarredProjectsWithChanges`) |
| New Activity / dual schedule | `doc/notification-from-api-call.md`, `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
---
## Open questions for Endorser (server developer)
1. What is the **current access token TTL** and can it be **increased** for mobile clients, or **per-scope**?
2. Is **refresh token** (or similar) available for **non-interactive** renewal?
3. Would a **read-only** scope for `plansLastUpdatedBetween` with a **longer** lifetime be acceptable?
4. Is there an existing **server-to-server** or **partner** path that a **BFF** could use instead of user JWT on device?
---
*This document is for internal planning and decision; update it when the team chooses an approach.*

View File

@@ -1,158 +0,0 @@
# New Activity Notifications: iOS Parity with Android
**Purpose:** Describe what is required for **iOS** to match **Android** for the daily-notification-plugin **API-driven “New Activity”** flow (`scheduleDualNotification` / `cancelDualSchedule`, with prefetch and Endorser-backed content). The canonical product behavior is documented in `doc/notification-from-api-call.md` and `doc/notification-new-activity-lay-of-the-land.md`.
**Plugin source of truth:** The Capacitor package is `@timesafari/daily-notification-plugin`, pulled from the official remote in `package.json` (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`). Plugin development happens in that repository; this app bumps the dependency and runs `npm install` / `npx cap sync` after releases.
---
## 1. What “parity” means here
| Concern | Intended behavior |
|--------|---------------------|
| **Scheduling** | Dual schedule: prefetch job **before** notify time (app uses cron T5 minutes), then user-visible notification at the chosen time. |
| **API content** | Prefetch calls the **same Endorser semantics** as the Android host: **`plansLastUpdatedBetween`** (POST) with **starred plan IDs**, JWT auth, aggregated titles/bodies consistent with `TimeSafariNativeFetcher`. |
| **Starred plans** | `updateStarredPlans({ planIds })` from the app must affect what the native prefetch queries. |
| **Configure** | `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken, … })` supplies credentials the native layer uses for prefetch. |
| **Lifecycle** | `cancelDualSchedule()` removes the dual prefetch + notify schedule without breaking the separate Daily Reminder. |
Platform differences (iOS **BGTaskScheduler** is opportunistic; Android **alarms/WorkManager** can be more exact) mean **timing** may never be identical, but **API behavior and user-visible copy** should align.
---
## 2. Current state: Android (this app)
- **Host native fetcher:** `android/.../TimeSafariNativeFetcher.java` implements the plugins `NativeNotificationContentFetcher` and calls **`POST …/api/v2/report/plansLastUpdatedBetween`** using starred plan IDs (via plugin storage from `updateStarredPlans`).
- **Registration:** `MainActivity` calls `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
- **Plugin (Android) — older notes:** Prior dual-schedule issues (native fetcher / fetch cron) are addressed in **plugin ≥ 3.0.0** (chained dual: notify after prefetch). Historical analysis: `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md`.
---
## 3. Current state: iOS (this app + bundled plugin)
### 3.1 This repository
- **iOS native fetcher:** `ios/App/App/TimeSafariNativeFetcher.swift` implements `NativeNotificationContentFetcher` (Endorser `plansLastUpdatedBetween`, same prefs keys as Java). **`AppDelegate`** calls `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any `configureNativeFetcher` from JS (see plugin `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` and **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
- **JS/TS is already shared:** `nativeFetcherConfig.ts`, `dualScheduleConfig.ts`, `syncStarredPlansToNativePlugin.ts`, and `AccountViewView.vue` call the same APIs on both platforms.
- **Info.plist** already lists `UIBackgroundModes` (fetch, processing) and `BGTaskSchedulerPermittedIdentifiers` for the plugins task IDs. Xcode **Signing & Capabilities** should still enable **Background fetch** and **Background processing** (see `doc/daily-notification-plugin-integration.md`).
- **AppDelegate** posts `DailyNotificationDelivered` for foreground presentation—aligned with plugin rollover behavior.
### 3.2 Bundled plugin (`node_modules/@timesafari/daily-notification-plugin`, iOS)
Requires **plugin ≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Version pinned in `ios/App/Podfile.lock` after `pod install`.
- **`scheduleDualNotification` / `cancelDualSchedule`** — see plugin release notes; clean sync + `pod install` if you see `UNIMPLEMENTED` (`doc/plugin-feedback-ios-scheduleDualNotification.md`).
- **`configureNativeFetcher`** — **requires** `DailyNotificationPlugin.registerNativeFetcher` first; the host Swift fetcher performs **`plansLastUpdatedBetween`** (plugin does not use in-plugin `offers` GET when a fetcher is registered—mirrors Android).
- **`updateStarredPlans`** — implemented on iOS in current plugin; persists **`daily_notification_timesafari.starredPlanIds`** for the host fetcher.
- **Chained dual** — user notification is armed **after** prefetch for that cycle (plugin); iOS remains subject to BG scheduling limits; see **§3.3**.
### 3.3 Prefetch before notify (ordering, not cron)
iOS has no system cron; the app/plugin may still **parse** cron to compute “next run” times. The hard part is **ordering**: if **prefetch** is driven by **`BGTaskScheduler`** (opportunistic) and **notify** by **`UNUserNotificationCenter`** at a fixed time **T**, those are **independent**. The OS can deliver the local notification at **T** while prefetch runs **after** **T** or not at all—so the awkward case (notify first, prefetch later, stale or fallback content) **can** happen. Two peer timers do **not** imply “fetch always completes before **T**.”
To **enforce** prefetch-before-notify as a rule, use **chaining**, not two unrelated schedules:
- After prefetch for that cycle **finishes** (success or explicit timeout policy), **then** schedule or **replace** the pending `UNNotificationRequest` for time **T** with the resolved title/body (or fallback). Until then, do not arm a user-visible notification that claims fresh API content.
- **Tradeoffs:** If prefetch is late, the notification may be **late**; if prefetch never runs before a deadline, use **fallback** copy at **T** or skip—product choice.
- **Parsing cron** remains useful to compute **T** and to decide when to **submit** BG work; **ordering** is a **pipeline** decision (fetch → cache → arm notify), not “BG at T5 and UN at **T** both scheduled up front.”
Plugin work item **§4A.3** should reflect this: document the chosen strategy (chained arm vs best-effort dual timer) and how it interacts with `relationship.contentTimeout` / fallback.
---
## 4. Work breakdown
### 4A. Plugin (`daily-notification-plugin`) — status (v3.x)
Items below were the original gap list; **plugin ≥ 3.0.0** ships **iOS** `updateStarredPlans`, **`registerNativeFetcher`**, **chained dual** on iOS and Android, and Android dual-path fixes. Remaining work is **release coordination** (bump, sync, QA), not greenfield plugin implementation.
1. **`updateStarredPlans` on iOS** — shipped in current plugin.
2. **iOS `plansLastUpdatedBetween` / host fetcher** — shipped: host registers **`TimeSafariNativeFetcher`** (Swift); plugin does not duplicate Endorser logic when a fetcher is registered.
3. **Dual schedule / chaining** — shipped (notify after prefetch; see plugin release notes and **§3.3**).
4. **Android dual path** — chained dual + native fetcher alignment in current plugin (see `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` for historical context).
5. **JWT pool / expiry (Phase B)**
- **App:** Phase B is already implemented: `configureNativeFetcherIfReady()` passes `jwtTokens` from `mintBackgroundJwtTokenPool` on **both** iOS and Android (`src/services/notifications/nativeFetcherConfig.ts`).
- **Android:** `TimeSafariNativeFetcher` selects a bearer from the pool for background requests (`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`).
- **iOS:** The bundled plugins `configureNativeFetcher` **already accepts and persists** `jwtTokens` / `jwtTokenPoolJson`, and the in-plugin fetch path uses a bearer from the primary token or pool. What is **not** yet at parity with Android is **which API** that token is used for (`offers` GET vs `plansLastUpdatedBetween` + starred plans)—that falls under **§4A.2**, not “waiting for Phase B on iOS.”
- **Expiry:** Re-calling `configureNativeFetcherIfReady` on foreground / Account (see `notification-from-api-call.md`) remains relevant on both platforms.
### 4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes
1. **Bump `@timesafari/daily-notification-plugin`** to **≥ 3.0.0** via the git dependency in `package.json`, run `npm install`, `npx cap sync ios`, `cd ios/App && pod install`, clean build (`doc/plugin-feedback-ios-scheduleDualNotification.md`, **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**).
2. **iOS native fetcher****Done:** `TimeSafariNativeFetcher.swift` + `registerNativeFetcher` in `AppDelegate` (see handoff doc).
3. **Re-test** `syncStarredPlansToNativePlugin` on iOS; the helper may still catch `UNIMPLEMENTED` for older plugin binaries.
4. **Xcode:** Confirm Background Modes capabilities match `Info.plist`.
5. **QA:** Full matrix in `doc/notification-from-api-call.md` (enable/disable, empty starred list, JWT expiry, foreground/background); chained dual timing (notify after prefetch).
### 4C. Related product bug (both platforms)
- **`PushNotificationPermission.vue` vs New Activity:** Enabling New Activity can still schedule the **single** daily reminder by mistake; turning New Activity off may not cancel that reminder. See `doc/notification-new-activity-lay-of-the-land.md`. Fixing this is orthogonal to iOS/Android API parity but affects perceived “notifications behavior.”
---
## 5. Reference map (this repo)
| Topic | Document |
|-------|-----------|
| Plugin post-bump handoff (iOS fetcher + chained dual) | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |
| Feature plan & file list | `doc/notification-from-api-call.md` |
| Dual vs Daily Reminder confusion | `doc/notification-new-activity-lay-of-the-land.md` |
| iOS `UNIMPLEMENTED` / PluginHeaders | `doc/plugin-feedback-ios-scheduleDualNotification.md` |
| Android dual schedule + native fetcher | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` |
| Integration & Xcode | `doc/daily-notification-plugin-integration.md` |
| Android host fetcher | `android/.../TimeSafariNativeFetcher.java`, `MainActivity.java` |
---
## 6. Handoff to plugin repo (Cursor / isolated workspace)
Use this section when **daily-notification-plugin** is open **without** the TimeSafari app tree, so implementers do not depend on paths that only exist in crowd-funder-for-time-pwa.
### 6.1 Bring reference material into scope
| Source (this app repo) | Why |
|------------------------|-----|
| `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | **Canonical Endorser behavior** for New Activity: POST body, pagination, aggregation copy, prefs keys for starred IDs and `last_acked_jwt_id`. Copy or open alongside the plugin when implementing iOS fetch or `setNativeFetcher`. |
| `src/services/notifications/dualScheduleConfig.ts` | Shape the app sends to `scheduleDualNotification` (`buildDualScheduleConfig`). |
| `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | Android plugin: dual path must call native fetcher at fetch cron. |
| `doc/plugin-feedback-ios-scheduleDualNotification.md` | iOS `UNIMPLEMENTED` / PluginHeaders troubleshooting. |
In the plugin repo itself, align with **`src/definitions.ts`** (`DualScheduleConfiguration`, `configureNativeFetcher`, `updateStarredPlans`) and **INTEGRATION_GUIDE** if present.
### 6.2 HTTP / storage contract (match `TimeSafariNativeFetcher`)
Implementations on **iOS** (in-plugin Swift or host `NativeNotificationContentFetcher`) should match this **unless** product explicitly changes:
- **Method & path:** `POST` `{apiBaseUrl}/api/v2/report/plansLastUpdatedBetween` (no trailing slash mismatch on `apiBaseUrl`).
- **Headers:** `Content-Type: application/json`, `Authorization: Bearer {token}` (token from `jwtToken` or **JWT pool** selection—see Java `selectBearerTokenForRequest`: UTC day mod pool size).
- **JSON body:** `planIds` (array of strings, possibly empty), `afterId` (string; use `"0"` if none stored).
- **Starred plans:** Android: SharedPreferences **`daily_notification_timesafari`** + key **`starredPlanIds`**. iOS (plugin + host): `UserDefaults.standard` key **`daily_notification_timesafari.starredPlanIds`** (JSON array string).
- **Pagination:** After a successful response with non-empty `data`, update **`last_acked_jwt_id`** from the last rows `jwtId` (item or nested `plan.jwtId`)—see Java `updateLastAckedJwtIdFromResponse`. iOS host (`TimeSafariNativeFetcher.swift`) persists **`daily_notification_timesafari.last_acked_jwt_id`** in `UserDefaults.standard`.
- **Empty `data`:** Return **no** notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns empty `contents` when `data` is absent or empty.
- **Non-empty `data`:** One aggregated `NotificationContent`: titles **Starred Project Update** / **Starred Project Updates**, bodies use typographic quotes around first project name and **has been updated.** / **+ N more have been updated.** (see Java `parseApiResponse`).
### 6.3 Likely plugin touchpoints (maintenance / debugging)
- **iOS:** `ios/Plugin/DailyNotificationPlugin.swift`, `DailyNotificationScheduleHelper.swift`, native fetcher registry, BG / UN paths.
- **Android:** `DailyNotificationPlugin.kt`, fetch workers / `ScheduleHelper`—see dual-schedule feedback doc for history.
### 6.4 Suggested order (plugin shipped ≥ 3.0.0)
1. Tag / publish **`@timesafari/daily-notification-plugin`**.
2. **Consuming app:** bump, `npm install`, `npx cap sync`, `pod install`, QA (`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`).
---
## 7. Acceptance checklist (iOS vs Android product intent)
- [ ] Prefetch uses **plansLastUpdatedBetween** (or host fetcher with identical behavior), not only `offers` GET.
- [ ] **Starred plan IDs** from settings change what is queried (`updateStarredPlans` works on iOS).
- [ ] Notification title/body match the **same rules** as Android for “starred project updates” (including empty updates).
- [ ] `configureNativeFetcher` + JWT refresh story documented; re-config on foreground if needed (`notification-from-api-call.md`).
- [ ] `cancelDualSchedule` clears dual prefetch/notify without leaving orphan schedules.
- [ ] Understand and document **iOS timing** limitations vs Android for support/Help copy.
- [ ] **Prefetch vs notify ordering** on iOS: chosen strategy (chained arm vs independent BG + UN) documented; avoids claiming fresh API content when prefetch has not run yet (**§3.3**).

View File

@@ -1,108 +0,0 @@
# 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
- **Configure native fetcher on startup and identity**
- Added `configureNativeFetcherIfReady()` in `src/services/notifications/nativeFetcherConfig.ts` (reads `activeDid` and `apiServer` from DB, gets JWT via `getHeaders(did)`, calls `DailyNotification.configureNativeFetcher()`).
- Called from `main.capacitor.ts` after the 2s delay (with deep link registration).
- Called from `AccountViewView.initializeState()` when on native and `activeDid` is set; when New Activity is enabled, also calls `updateStarredPlans(settings.starredPlanHandleIds)`.
- **Implement real API calls in Android native fetcher**
- `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher`: POST to `/api/v2/report/plansLastUpdatedBetween` with `planIds` (from SharedPreferences `daily_notification_timesafari` / `starredPlanIds`) and `afterId`; when `data` is non-empty, builds **one** aggregated `NotificationContent` (title **Starred Project Update** or **Starred Project Updates**, body from `plan.name` with typographic quotes, then `has been updated.` or `+ N more have been updated.`); when `data` is empty, returns an empty list (no “no updates” notification); updates `last_acked_jwt_id` for pagination when content is returned.
- Registered in `MainActivity.onCreate()` via `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`.
- **Sync starred plan IDs**
- Shared helper `syncStarredPlansToNativePlugin(planIds)` in `src/services/notifications/syncStarredPlansToNativePlugin.ts` (exported from `src/services/notifications/index.ts`) calls `DailyNotification.updateStarredPlans` on native only; ignores `UNIMPLEMENTED`.
- When user enables New Activity, `scheduleNewActivityDualNotification()` uses the helper with `settings.starredPlanHandleIds ?? []`.
- When Account view loads and New Activity is on, `initializeState()` uses the helper with the same list.
- When the user stars or unstars on a project (`ProjectViewView.toggleStar`), after a successful settings save, the helper runs if `notifyingNewActivityTime` is set so prefetch sees the current list without reopening Account.
- **Dual schedule config and scheduling**
- Added `src/services/notifications/dualScheduleConfig.ts`: `timeToCron()`, `timeToCronFiveMinutesBefore()`, `buildDualScheduleConfig({ notifyTime, title?, body? })` (contentFetch 5 min before, userNotification at chosen time).
- When user enables New Activity and picks a time, app calls `DailyNotification.scheduleDualNotification({ config })` with this config.
- When user disables New Activity, app calls `DailyNotification.cancelDualSchedule()`.
- **UI for New Activity notification**
- Unhid the “New Activity Notification” block in `AccountViewView.vue` (toggle + accessibility).
- Enable flow: time dialog → save settings → on native, `scheduleNewActivityDualNotification(timeText)` (configure fetcher, updateStarredPlans, scheduleDualNotification).
- Disable flow: on native, `cancelDualSchedule()` then save and clear settings.
- Added `starredPlanHandleIds` to `AccountSettings` in `interfaces/accountView.ts`.
- **Exports**
- `src/services/notifications/index.ts` exports `configureNativeFetcherIfReady`, `syncStarredPlansToNativePlugin`, `buildDualScheduleConfig`, `timeToCron`, `timeToCronFiveMinutesBefore`, and `DualScheduleConfigInput`.
---
## Checklist of Remaining Tasks
### iOS
**Parity outline (API, starred plans, plugin vs app work):** See **`doc/new-activity-notifications-ios-android-parity.md`**.
- **Confirm iOS native fetcher / dual schedule**
Plugin exposes `configureNativeFetcher` on iOS. Confirm whether the plugin expects an iOS-specific native fetcher registration (similar to 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; the native fetcher returns no Endorser-derived items when there is nothing to query or no new rows (see `TimeSafariNativeFetcher`).
- **Test JWT expiry**
Ensure behavior when the token passed to `configureNativeFetcher` has expired (e.g. app in background for a long time); document or implement refresh (e.g. re-call `configureNativeFetcherIfReady` on foreground or when opening Account).
### Optional enhancements
- **Offers endpoints**
Extend `TimeSafariNativeFetcher` (and any iOS fetcher) to call offers endpoints (e.g. `offers`, `offersToPlansOwnedByMe`) and merge with project-update content for richer notifications.
- **Documentation**
Add a short “New Activity notifications” section to BUILDING.md or a user-facing help page describing how the feature works and how to troubleshoot (e.g. no notification, wrong content, JWT/API errors).
---
## File Reference
| Area | Files |
| ---------------------- | ----------------------------------------------------------------------- |
| Fetcher config | `src/services/notifications/nativeFetcherConfig.ts` |
| Starred list → plugin | `src/services/notifications/syncStarredPlansToNativePlugin.ts` |
| Dual schedule config | `src/services/notifications/dualScheduleConfig.ts` |
| Notification exports | `src/services/notifications/index.ts` |
| Startup | `src/main.capacitor.ts` |
| Account UI and flow | `src/views/AccountViewView.vue` |
| Project star / unstar | `src/views/ProjectViewView.vue` (`toggleStar`) |
| Settings type | `src/interfaces/accountView.ts` |
| Android native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
| Android registration | `android/app/src/main/java/app/timesafari/MainActivity.java` |
| iOS native fetcher | `ios/App/App/TimeSafariNativeFetcher.swift` |
| iOS registration | `ios/App/App/AppDelegate.swift` (`DailyNotificationPlugin.registerNativeFetcher`) |
| Plugin 3.x handoff | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` |

View File

@@ -1,250 +0,0 @@
# Lay of the Land: API-Driven Daily Message (New Activity) and Web-Push Confusion
**Purpose:** Shareable analysis of the New Activity (API-driven daily message) implementation and the root cause of “always fires / cant be turned off.” For discussion with teammates.
**Related:** `doc/notification-from-api-call.md` (plan and progress), teammate note about web-push confusion and possibly removing that logic.
---
## 1. Two Separate Notification Features
There are **two** distinct native notification flows that both go through the same UI component:
| Feature | Plugin API | Purpose |
|--------|------------|--------|
| **Daily Reminder** | `scheduleDailyNotification` / `cancelDailyReminder` | Single daily alarm, static title/body (users message). |
| **New Activity** (API-driven) | `scheduleDualNotification` / `cancelDualSchedule` | Prefetch from API 5 min before, then notify at chosen time with API or fallback content. |
- **Daily Reminder** is driven from AccountViewViews “Daily Reminder” toggle; on native it uses `NotificationService.getInstance().scheduleDailyNotification()` / `cancelDailyNotification()` (backed by `NativeNotificationService` and a single `reminderId`: `"daily_timesafari_reminder"`).
- **New Activity** is intended to be driven only by `scheduleNewActivityDualNotification()` / `cancelDualSchedule()` in AccountViewView (dual schedule only).
So: one feature = single schedule (reminder), the other = dual schedule (prefetch + notify). They are different plugin APIs and different lifecycle (enable/disable) handling.
---
## 2. Where the Bug Comes From: One Dialog, Two Behaviors
**New Activity** reuses the same dialog as Daily Reminder: **`PushNotificationPermission.vue`**.
- When the user turns **New Activity** on from AccountViewView:
- AccountViewView opens this dialog with `DAILY_CHECK_TITLE` and a callback that, on success, calls `scheduleNewActivityDualNotification(timeText)` on native.
- The dialog does **not** receive `skipSchedule: true` for this flow (only the “edit reminder” flow does).
So when the user clicks “Turn on Daily Reminder” in the dialog for **New Activity**:
1. **PushNotificationPermission** (native path) runs `turnOnNativeNotifications()` and always calls:
- `service.scheduleDailyNotification({ time, title: "Daily Check-In", body: "Time to check your TimeSafari activity", ... })`
- i.e. it schedules the **single** daily reminder (plugins `scheduleDailyNotification`), using the same `reminderId` as Daily Reminder (`"daily_timesafari_reminder"`).
2. Then the callback runs and AccountViewView calls **`scheduleNewActivityDualNotification(timeText)`**, which calls the plugins **`scheduleDualNotification`**.
Result:
- **Two schedules** are created when enabling New Activity:
- One **single** reminder (wrong for New Activity): static “Daily Check-In” message, same ID as Daily Reminder.
- One **dual** schedule (correct): prefetch + notify with API/fallback content.
- When the user turns **New Activity** off, AccountViewView only calls **`cancelDualSchedule()`**. It never calls `cancelDailyNotification()` (or equivalent) for the single reminder.
- So the **single** reminder stays scheduled and keeps firing at the chosen time. Thats the notification that “always fires” and “cant be turned off.”
So the “huge problem with confusion with the web-push” is really: **the same dialog and the same “Turn on” path are used for both Daily Reminder and New Activity, but the dialog always schedules the single daily reminder on native**, while New Activity is supposed to use only the dual schedule. That mixing is what makes the wrong schedule stick and not be cancellable from the New Activity toggle.
---
## 3. Key Files and Flows
- **`src/components/PushNotificationPermission.vue`**
- Shared dialog for both “Daily Reminder” and “New Activity” (via `pushType` = `DIRECT_PUSH_TITLE` vs `DAILY_CHECK_TITLE`).
- On native it always uses `NotificationService.getInstance().scheduleDailyNotification(...)` (single reminder) and does not branch on “New Activity” to skip scheduling or to call the dual API.
- Saves `notifyingNewActivityTime` when `pushType === DAILY_CHECK_TITLE` (lines 834836). So the dialog both schedules the wrong thing and persists settings for New Activity.
- **`src/views/AccountViewView.vue`**
- **Daily Reminder:** toggle opens same dialog with `DIRECT_PUSH_TITLE`; on native, disable path calls `service.cancelDailyNotification()`.
- **New Activity:** toggle opens same dialog with `DAILY_CHECK_TITLE`; on success callback calls `scheduleNewActivityDualNotification(timeText)`; on disable only calls `DailyNotification.cancelDualSchedule()`.
- `initializeState()`: on native with `activeDid`, calls `configureNativeFetcherIfReady(activeDid)` and, if New Activity is on, `updateStarredPlans(...)`. It does **not** re-call `scheduleNewActivityDualNotification` on load (so no double dual-schedule from here).
- **`src/services/notifications/NativeNotificationService.ts`**
- Single reminder only: `scheduleDailyNotification` → plugin `scheduleDailyNotification` with `id: this.reminderId` (`"daily_timesafari_reminder"`); `cancelDailyNotification``cancelDailyReminder({ reminderId })`. No dual API here.
- **`src/services/notifications/nativeFetcherConfig.ts`**
- Only configures the plugin for API calls (JWT, apiBaseUrl, activeDid). No scheduling.
- **`src/services/notifications/dualScheduleConfig.ts`**
- Builds config for `scheduleDualNotification` (contentFetch 5 min before, userNotification at notify time). Used only from AccountViewViews `scheduleNewActivityDualNotification`.
- **`src/main.capacitor.ts`**
- Imports the daily-notification plugin; after a 2s delay calls `configureNativeFetcherIfReady()`. No scheduling; only fetcher config.
So: the “always fires / cant turn off” behavior is from the **single** reminder created in `PushNotificationPermission` for New Activity and never cancelled when New Activity is turned off. The “confusion with web-push” is the reuse of the same dialog and the same native “schedule single reminder” path for both features.
---
## 4. Plugin Usage Summary
- **Single daily reminder (Daily Reminder):**
- Scheduled/cancelled via `NativeNotificationService.scheduleDailyNotification` / `cancelDailyNotification` → plugin `scheduleDailyNotification` / `cancelDailyReminder` with one `reminderId`.
- **Dual schedule (New Activity):**
- Scheduled/cancelled only in AccountViewView via `DailyNotification.scheduleDualNotification` / `cancelDualSchedule` (and `configureNativeFetcherIfReady` + `updateStarredPlans` as per doc).
- **Fetcher config (New Activity):**
- `configureNativeFetcherIfReady()` from main.capacitor and from AccountViewView `initializeState` / `scheduleNewActivityDualNotification`; no scheduling by itself.
---
## 5. Root Cause (Concise)
- **Single code path in PushNotificationPermission** for native: it always schedules the **single** daily reminder, regardless of `pushType` (Daily Reminder vs New Activity).
- For **New Activity**, that creates an extra, wrong schedule (single reminder) in addition to the correct dual schedule.
- **Disable path for New Activity** only calls `cancelDualSchedule()` and never cancels the single reminder, so that reminder keeps firing and appears as “always fires” and “cant be turned off.”
---
## 6. Proper Fix: Options and Detail
A fix should ensure that (1) enabling New Activity creates only the dual schedule, and (2) disabling New Activity removes every schedule that was created for it. Below are concrete options and implementation notes.
### 6.1 Option A: Dont schedule the single reminder when the dialog is for New Activity (recommended)
**Idea:** On native, when the dialog is opened for **New Activity** (`pushType === DAILY_CHECK_TITLE`), the dialog should **not** call `scheduleDailyNotification`. Only the callback in AccountViewView should run, and it already calls `scheduleNewActivityDualNotification(timeText)`, which uses the dual API only.
**Where:** `PushNotificationPermission.vue`, inside `turnOnNativeNotifications()`.
**Implementation sketch:**
- After requesting permissions and before calling `service.scheduleDailyNotification(...)`, branch on `pushType` and platform:
- If native **and** `pushType === this.DAILY_CHECK_TITLE`: skip the `scheduleDailyNotification` call entirely. Still run the rest of the flow (e.g. build `timeText`, save settings if desired, call `callback(true, timeText, ...)`). AccountViewViews callback will then call `scheduleNewActivityDualNotification(timeText)` and that is the only schedule created for New Activity.
- Otherwise (web, or Daily Reminder on native): keep current behavior and call `scheduleDailyNotification` as today.
**Pros:** Single source of truth for “what is scheduled for New Activity” (dual only). No leftover single reminder to cancel later. Clear separation: dialog collects time + permission; AccountViewView owns native scheduling for New Activity.
**Cons:** Dialogs native path now has two behaviors (schedule vs no schedule) depending on `pushType`; needs a quick comment so future changes dont regress.
**Note:** The “edit reminder” flow already uses `skipSchedule: true` so the dialog doesnt schedule; only the parent does. For New Activity enable, were doing the same idea: dialog doesnt schedule on native, parent does.
### 6.2 Option B: When turning New Activity off, also cancel the single reminder
**Idea:** Assume the wrong single reminder might already exist (e.g. from before the fix, or from a different code path). When the user turns **New Activity** off, in addition to `cancelDualSchedule()`, call the services `cancelDailyNotification()` so the single reminder (same `reminderId` as Daily Reminder) is cancelled too.
**Where:** `AccountViewView.vue`, inside the disable branch of `showNewActivityNotificationChoice()` (where we currently only call `DailyNotification.cancelDualSchedule()`).
**Implementation sketch:**
- On native, when user confirms “turn off New Activity”:
1. Call `DailyNotification.cancelDualSchedule()` (existing).
2. Call `NotificationService.getInstance().cancelDailyNotification()` (new) so any single reminder that was mistakenly scheduled for this flow is removed.
**Pros:** Defensive: cleans up the bad schedule even if it was created in the past or by another path. Complements Option A (e.g. A prevents new wrong schedules; B cleans up existing ones).
**Cons:** That single `reminderId` is shared with **Daily Reminder**. If the user has **Daily Reminder** on and **New Activity** on, then turns only **New Activity** off, we must not cancel the reminder they still want for Daily Reminder. So either:
- Only call `cancelDailyNotification()` when were sure the single reminder was created for New Activity (e.g. we dont have a separate “New Activity reminder ID”), which is hard without more state, or
- Dont use Option B alone as the primary fix: use Option A so we never create the single reminder for New Activity, and only add B if we decide we need a one-time cleanup or a safety net (with care not to cancel Daily Reminders schedule).
**Recommendation:** Use Option A as the main fix. Add Option B only if the team agrees we need to cancel the single reminder on “New Activity off” and can do so without affecting Daily Reminder (e.g. by introducing a distinct reminder ID for a “New Activity legacy” reminder and only cancelling that, or by documenting that B is a one-time migration and not long-term behavior).
### 6.3 Optional cleanup: Separate reminder IDs or dialog responsibilities
- **Separate reminder IDs:** Today both Daily Reminder and the mistaken New Activity single reminder use `"daily_timesafari_reminder"`. If we ever want to support “both features on” and cancel only one, wed need a second ID (e.g. one for Daily Reminder, one for New Activity). With Option A in place, New Activity no longer creates a single reminder, so we might not need a second ID unless we add a dedicated “New Activity fallback” single alarm later.
- **Dialog responsibilities:** We could narrow the dialogs role when used for New Activity on native to “collect time + request permission and report success,” and leave all scheduling to AccountViewView. Thats what Option A does without necessarily refactoring the rest of the dialog (e.g. web push, Daily Reminder) in the same change.
- **Removing web-push logic for New Activity:** If the team decides to “totally remove” web-push logic that was added for New Activity, that would be a separate change (e.g. ensure New Activity on web either uses a different mechanism or is explicitly unsupported). The lay-of-the-land and this fix section focus on native; web can be scoped in a follow-up.
---
## 7. Testing New Activity on a Real Device (iOS or Android)
Use this section to verify the New Activity flow end-to-end on a physical device after implementing the fix (or to reproduce the current bug).
### Prerequisites
- **Build:** Native app built and installed (e.g. `npx cap sync` then build/run from Xcode or Android Studio), or a dev build on device.
- **Identity:** User is signed in (active DID set) so `configureNativeFetcherIfReady` and the native fetcher can use a valid JWT.
- **Endorser API URL:** New Activity prefetch uses **Account → API Server URL** (the Endorser base URL passed to `configureNativeFetcher`), not the Partner API URL. You can run these tests against **production, test, or local Endorser** (e.g. the test preset `https://test-api.endorser.ch`); use an identity, JWT, and starred plans that exist on **that** server. Changing only **Partner API** URL does not change where `plansLastUpdatedBetween` is called.
- **Optional:** One or more starred plans so the API can return activity; with zero starred plans the notification should still show with a sensible fallback (e.g. “No updates in your starred projects”).
### Enable flow
1. Open **Account** (Profile).
2. In the **Notifications** section, turn **New Activity Notification** on.
3. In the dialog, choose a time. For quick testing, set the device clock or pick a time **25 minutes from now** (e.g. if its 14:00, choose 14:03).
4. Tap **Turn on Daily Reminder** (or equivalent), grant notification permission when the OS prompts, and confirm the dialog closes and the toggle shows on with the chosen time.
5. **Background the app** (home or switch to another app). The prefetch runs ~5 minutes before the chosen time; the user notification fires at the chosen time.
### What to verify (after fix)
- **One notification** at the chosen time, with content from the API or the fallback text (e.g. “Check your starred projects and offers for updates.”). You should **not** see a second, static “Daily Check-In” / “Time to check your TimeSafari activity” notification from the old single-reminder path.
- **Before the fix:** You may see two notifications (one static from the mistaken single schedule, one from the dual schedule), and turning New Activity off will only stop the dual one; the static one will keep firing.
### Disable flow
1. On **Account**, turn **New Activity Notification** off and confirm in the “turn off” dialog.
2. Wait until the next occurrence of the previously chosen time (or use the same “time a few minutes ahead” trick and wait). **No notification** should appear. If one still appears, the single reminder was not cancelled (current bug or Option B not applied correctly).
### Device-specific notes
- **Android:** This app has **exact alarm disabled** (no `SCHEDULE_EXACT_ALARM`). Notification permission must be granted; delivery may be inexact or batched by the system. If the app is killed by the OS, behavior may depend on plugin boot/recovery behavior.
- **iOS:** Notification permission and background capabilities (e.g. background fetch) may affect prefetch. Test with app in background, not force-quit.
- **Time zone:** The chosen time is in the devices local time. Ensure the device date/time and time zone are correct when testing.
### Optional test cases
- **No starred plans:** Enable New Activity with no starred projects; confirm no crash and a sensible fallback message in the notification.
- **JWT / API errors:** After leaving the app in background for a long time, the JWT may expire. Re-opening Account (or app) may re-run `configureNativeFetcherIfReady`; document or test whether a new notification still gets valid content or shows fallback.
- **Daily Reminder and New Activity both on:** With the fix, turning off only New Activity should not affect the Daily Reminder notification (they use different plugin APIs; Option B must not cancel the single reminder if the user still has Daily Reminder on).
### Testing: starred project with new activity (Android native fetcher)
Use this to verify that when a **starred** plan has **new** activity reported by `plansLastUpdatedBetween`, the notification shows API-derived copy (not only the dual-schedule default from `dualScheduleConfig.ts`).
The steps and expected notification copy below are **Android-specific**: this repo registers `TimeSafariNativeFetcher` only on Android today. Do not assume the same strings or behavior on iOS until native fetcher parity exists; see **`doc/notification-from-api-call.md`** (iOS checklist and remaining tasks).
**How it works (short):** On Android, `TimeSafariNativeFetcher` POSTs to `/api/v2/report/plansLastUpdatedBetween` with `planIds` from the plugin (`updateStarredPlans`) and `afterId` from stored `last_acked_jwt_id` (or `"0"` initially). When the response `data` array is **non-empty**, the fetcher builds **one** `NotificationContent`: title **Starred Project Update** (one row) or **Starred Project Updates** (two or more rows); body uses each rows `plan.name` when present (else **Unnamed Project**). For a single update: `[name] has been updated.` For multiple: typographic quotes around the first rows name, then ` + N more have been updated.` (with `N` = number of additional rows). When `data` is **empty**, the fetcher returns **no** notification items (no “nothing to report” notification). (See `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`.)
**Procedure (repeatable on device)**
1. Sign in on the Endorser environment you mean to test (e.g. test API URL in Account—see **Prerequisites**, Endorser API URL) so `configureNativeFetcherIfReady` can set JWT and `activeDid`.
2. Star at least one project you can change (e.g. your own test plan on staging).
3. Turn **New Activity Notification** on and pick a time **25 minutes ahead** (same quick-test pattern as above).
4. Open **Account** once (or finish the enable flow) so `updateStarredPlans({ planIds })` runs with current `starredPlanHandleIds`.
5. **Background the app** (home out; do not force-quit). Prefetch runs on the cron **~5 minutes before** the chosen time; the user notification fires at the chosen time.
6. **Produce new activity the API will return:** before that prefetch window (i.e. early enough that the scheduled content fetch still sees it), make a real change to the starred plan so `plansLastUpdatedBetween` returns **new** rows after the current `afterId` (e.g. an edit or other update your backend exposes through that report). If you change the plan **after** prefetch already ran with no new rows, you may not get an API-derived notification until the next prefetch cycle (typically the next day at the same T5 schedule, unless you reschedule).
**What to verify**
- **One notification** at the chosen time (no extra static “Daily Check-In” after the fix—see “What to verify (after fix)” above).
- **Success path (API returns updates):** Title/body match **Starred Project Update(s)** and the `[name] has been updated.` / `[first name] + N more have been updated.` patterns (names from `plan.name`), not the generic `buildDualScheduleConfig` defaults (**New Activity** / **Check your starred projects and offers for updates.**), which apply when the plugin falls back—e.g. fetch failure—not when the Android fetcher successfully returns Endorser-parsed content.
- **Contrast (cursor caught up, no new rows):** After a successful fetch that returned data, `last_acked_jwt_id` advances. Without further plan changes, a later prefetch may return an empty `data` array; the fetcher then supplies **no** Endorser-derived notification (useful to compare against the “has activity” case; the plugin may still show dual-schedule fallback text depending on configuration).
**Repeatability:** Each successful fetch that returns data moves the `afterId` cursor forward. To see **Starred Project Update** copy again on subsequent tests, make **another** qualifying plan change (or accept heavier setup such as clearing app/plugin storage to reset cursor—usually unnecessary).
**Debugging:** On Android, filter **logcat** for `TimeSafariNativeFetcher` (e.g. HTTP 200, `Fetched N notification(s)`) to confirm prefetch ran and how many `NotificationContent` items were built.
**Note:** The in-app **New Activity** screen loads starred changes via the JS stack; the **push** path uses the native fetcher and plugin cache. Validate the notification using **background + prefetch timing**, not only by opening that screen.
---
## 8. Plugin Repo Alignment and Attention Items
Comparison with the **daily-notification-plugin** repo on gitea (`trent_larson/daily-notification-plugin`, `master` or the tag this app pins) to confirm our documentation and usage line up, and to flag anything that needs attention for the New Activity feature.
### 8.1 What lines up
- **API surface:** Plugin `definitions.ts` exposes `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })`, `scheduleDualNotification(config)`, `cancelDualSchedule()`, `updateStarredPlans({ planIds })`, `scheduleDailyNotification(options)`, and `cancelDailyReminder(reminderId)`. Our app uses these as described in this doc; `buildDualScheduleConfig` produces a `DualScheduleConfiguration` that matches the plugins `ContentFetchConfig` / `UserNotificationConfig` / `relationship` shape (cron schedules, title/body, `callbacks: {}`, `fallbackBehavior: "show_default"`, etc.).
- **Native fetcher:** Plugin is designed for a host-supplied JWT via `configureNativeFetcher` and a native fetcher implementation (e.g. Android `TimeSafariNativeFetcher`). Our `nativeFetcherConfig.ts` and Android `TimeSafariNativeFetcher.java` follow that model; prefetch runs in the plugins background workers and uses the configured credentials.
- **Dual vs single:** The plugin clearly separates:
- **Single daily path:** `scheduleDailyNotification(options)` (with `id` on Android) and `cancelDailyReminder(reminderId)` (iOS uses `reminder_<reminderId>` for the static-reminder path).
- **Dual path:** `scheduleDualNotification(config)` and `cancelDualSchedule()`.
So our analysis that “two schedules” are created when the dialog schedules the single reminder and AccountViewView schedules the dual is consistent with the plugin.
- **Exact alarm:** The plugins Android implementation does **not** require exact alarm: it proceeds with scheduling using inexact/windowed alarms when exact is not granted. The plugins `INTEGRATION_GUIDE.md` still shows `SCHEDULE_EXACT_ALARM` in the manifest example; this app has chosen to disable exact alarm, and the plugin supports that. No doc change needed beyond what we already state in section 7.
### 8.2 Attention items
- **`cancelDailyReminder` signature:** In the plugins `definitions.ts`, `cancelDailyReminder(reminderId: string)`. The app calls it with an object: `cancelDailyReminder({ reminderId })`. On iOS the plugin uses `call.getString("reminderId")`, so the object form works. If the plugins TypeScript definition is ever used for strict typing, prefer updating the plugin to accept `{ reminderId: string }` or document that the bridge accepts an object with a `reminderId` key.
- **Plugin INTEGRATION_GUIDE vs this app:** The guide describes generic polling, dual scheduling, and optional `SCHEDULE_EXACT_ALARM`. This app uses the dual-schedule + native-fetcher path only (no generic polling), and does not use exact alarm. When onboarding or debugging, treat the guide as the full plugin feature set; our flow is the “legacy dual scheduling” + native fetcher part plus `updateStarredPlans` and `configureNativeFetcher`.
- **iOS `scheduleDailyNotification` and stable `id`:** On **Android**, the plugin uses `options.getString("id")` as the stable `scheduleId` for “one per day” semantics and cleanup. On **iOS**, the implementation in the repo was observed to build notification content with an internally generated id (e.g. `daily_<timestamp>`) and not obviously use the app-provided `id` from the call. If the app ever relies on a stable id on iOS for the single reminder (e.g. to cancel or replace only that reminder), its worth confirming in the plugins iOS code whether the calls `id` is read and used; if not, consider requesting or contributing a change so iOS also uses the app-provided id for consistency with Android.
- **Dual schedule and content fetch:** The plugins dual schedule runs the content-fetch job on its cron and then the user notification at the configured time; our config uses a 5-minute gap and `relationship.contentTimeout` / `fallbackBehavior: "show_default"`. The native fetcher is invoked by the plugins background layer when the content-fetch schedule fires; we dont rely on JS `callbacks` in the config (we pass `callbacks: {}`). That matches the “native fetcher does the work” design.
### 8.3 iOS `UNIMPLEMENTED` on `scheduleDualNotification` (other methods work)
If iOS logs `scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}` while `configureNativeFetcher` succeeds, Capacitor is often rejecting the call in **JavaScript** because `scheduleDualNotification` is missing from `window.Capacitor.PluginHeaders` for `DailyNotification` (stale **Pods / Xcode binary** after upgrading the plugin). **Not** usually a missing Swift handler if `node_modules` already lists the method in `pluginMethods`.
**Recovery:** `npx cap sync ios`, `cd ios/App && pod install`, Xcode **Clean Build Folder**, rebuild. See **`doc/plugin-feedback-ios-scheduleDualNotification.md`** (troubleshooting section).
### 8.4 Summary
The plugin repo aligns with how we use it for New Activity (dual schedule + native fetcher, no generic polling, exact alarm optional). The main follow-ups are: (1) clarify or align `cancelDailyReminder` argument shape in the plugin if needed for typing/tooling, and (2) confirm on iOS whether `scheduleDailyNotification` uses the app-provided `id` for stable single-reminder semantics.

View File

@@ -1,203 +0,0 @@
# Plan: Background New Activity JWT — extended expiry + token pool
**Date:** 2026-03-27 14:29 PST
**Status:** Draft for implementation
**Audience:** TimeSafari / crowd-funder developers
**Related:** `doc/endorser-jwt-background-prefetch-options.md`, `android/.../TimeSafariNativeFetcher.java`, `src/services/notifications/nativeFetcherConfig.ts`, `src/libs/crypto/index.ts`
---
## 1. Problem statement
Background prefetch for New Activity calls Endorser with a Bearer JWT configured via `configureNativeFetcher`. The token previously came from `getHeaders()``accessToken()`, which used **`exp` ≈ 60 seconds** (`src/libs/crypto/index.ts`). Prefetch runs **minutes later** in WorkManager **without JavaScript**, so the JWT can be **expired** before the POST (`JWT_VERIFY_FAILED`).
**Goals:**
1. Use JWTs whose **`exp`** covers the gap between **last app-side configure** and **prefetch** (and ideally days without opening the app).
2. Optionally support a **pool** of distinct JWT strings so Endorser can enforce **duplicate-JWT** / **one-time-use** rules without breaking daily prefetch. **Pool size** should follow **`expiryDays + buffer`** (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); **implementation uses `BACKGROUND_JWT_POOL_SIZE = 100`** until policy changes.
3. Keep pool size and expiry policy **easy to change** (constants / remote config later).
---
## 2. Guiding principles
| Principle | Implication |
|-----------|-------------|
| **Background has no JS** | Token selection and HTTP must run in **native** (or plugin) code using **persisted** data. |
| **Single source of truth for signing** | Continue using **`createEndorserJwtForDid`** (same keys as today); do not fork crypto in Java/Kotlin. |
| **Configurable pool size** | One constant `BACKGROUND_JWT_POOL_SIZE`; **currently 100**. Size should satisfy **`≥ expiryDays + buffer`** (see below). |
| **Phased delivery** | Ship **extended expiry** first; add **pool** when server duplicate rules require it or in the same release if coordinated. |
### 2.1 Pool size rationale (`expiryDays + buffer`)
For **one New Activity prefetch per day**, each day should use a **distinct** JWT string if the server rejects reuse. Over the JWT lifetime (aligned with **`exp`**), you need at least **one token per day** the pool might be used without regeneration.
**Rule of thumb:**
```text
BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
```
- **`BACKGROUND_JWT_EXPIRY_DAYS`** — human-facing match to `exp` (e.g. **90**); convert to `BACKGROUND_JWT_EXPIRY_SECONDS` for the payload.
- **`BACKGROUND_JWT_POOL_BUFFER`** — extra slots for **same-day retries**, manual tests, or stricter duplicate rules (e.g. **10**).
**Example:** 90day `exp` + buffer 10 ⇒ **minimum 100** logical slots. **This plan keeps `BACKGROUND_JWT_POOL_SIZE = 100`** as the shipped default so it matches that example; if `expiryDays` or buffer change later, **bump the constant** so the inequality still holds.
---
## 3. Phases
### Phase A — Extended expiry only (minimum viable)
**Scope**
- Introduce a dedicated mint path for **background / native fetcher** use (name TBD, e.g. `accessTokenForBackgroundNotifications(did)`), producing **one** JWT per configure call with:
- `iss`: DID (unchanged)
- `iat`: now
- `exp`: now + **`BACKGROUND_JWT_EXPIRY_SECONDS`** (derived from **`BACKGROUND_JWT_EXPIRY_DAYS`**; see §2.1 / Phase B constants — **confirm** with Endorser policy)
- Optional: `jti` or nonce for uniqueness if needed for logging/debug
- **`configureNativeFetcherIfReady`** should pass this token (or keep using a thin wrapper) instead of reusing the **60s** `accessToken()` when configuring native fetcher **only****do not** change interactive `getHeaders()` / passkey caching behavior for normal API calls unless product asks for it.
**Files (likely)**
- `src/libs/crypto/index.ts` — new function or parameters; keep `accessToken()` default at 60s for existing callers.
- `src/services/notifications/nativeFetcherConfig.ts` — obtain background JWT via the new mint path, not `getHeaders()`s generic path, **or** add a dedicated branch that calls the new mint after resolving `did`.
**Native**
- **`TimeSafariNativeFetcher`**: still one `jwtToken` field; no pool yet. Ensure `configure()` is called whenever TS refreshes (startup, resume, Account — already partially covered).
**Exit criteria**
- Logcat: prefetch POST returns **200** (or non-expired 4xx) when user has not opened the app for several **minutes** after configure.
- Endorser accepts **`exp`** far enough in the future (coordinate TTL policy).
---
### Phase B — Token pool (size 100; driven by `expiryDays + buffer`)
**Why**
- Endorser may **reject duplicate JWT strings** (same bearer used twice). One long-lived token could fail on **day 2** if the server marks each JWT as consumed.
- A **pool** of **N** distinct JWTs (different payload, e.g. unique `jti` per token) gives **N** independent strings with the same long **`exp`**. **N** should follow **§2.1** (`expiryDays + buffer`); **100** is the initial **`BACKGROUND_JWT_POOL_SIZE`** (satisfies e.g. 90 + 10).
**Scope**
1. **Constants** (single place, e.g. `src/constants/backgroundJwt.ts` or next to native fetcher config):
```text
BACKGROUND_JWT_EXPIRY_DAYS = 90 // align with Endorser; drives exp
BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60 // derived
BACKGROUND_JWT_POOL_BUFFER = 10 // retries / headroom; tune with server team
BACKGROUND_JWT_POOL_SIZE = 100 // must be >= expiryDays + buffer; adjust if policy changes
```
2. **Mint in TS** (uses `createEndorserJwtForDid`):
- Loop `i = 0 .. POOL_SIZE - 1`
- Payload: `{ iss, iat, exp, jti: `${did}#bg#${i}` or uuid }` — **confirm** `jti` format with Endorser if required.
3. **Persistence** — native code must read the pool **without JS**:
- **Option B1 (preferred):** Implement in **`@timesafari/daily-notification-plugin`** (not in the app): extend **`configureNativeFetcher`** to accept an optional JWT pool, persist it for native read. **Handoff spec:** `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` — copy or reference that file in the plugin repo PR.
- **Option B2 (app-only, no plugin release):** Write JSON to **Capacitor Preferences** or **encrypted storage** from TS; **TimeSafariNativeFetcher** reads the same store on Android (requires knowing Capacitors Android `SharedPreferences` name/key convention or a tiny **bridge** in `MainActivity`). Use only if plugin work is deferred.
4. **Selection policy in `TimeSafariNativeFetcher`** (before each POST):
- **By calendar day:** `index = (epochDay + offset) % POOL_SIZE` (stable per day).
- Or **sequential:** persist `lastUsedIndex` in prefs and increment (wrap). **Decision:** document chosen policy; day-based is easier to reason about for “one token per day.”
5. **configureNativeFetcherIfReady** (and any “reset notifications on startup” hook):
- Regenerate full pool when user opens app (per product decision), then call configure with pool + **current** `apiBaseUrl` / `did`.
6. **iOS:** When iOS native fetcher exists, mirror Android behavior.
**Exit criteria**
- Prefetch succeeds on **consecutive days** with duplicate-JWT enforcement enabled on a **staging** Endorser.
- Pool **refreshes** on startup without breaking dual schedule.
---
## 4. Detailed tasks (checklist)
### Crypto & TypeScript
- [ ] Add `BACKGROUND_JWT_EXPIRY_DAYS`, `BACKGROUND_JWT_EXPIRY_SECONDS`, `BACKGROUND_JWT_POOL_BUFFER`, and `BACKGROUND_JWT_POOL_SIZE` (exported constants), with a **comment** that `POOL_SIZE >= expiryDays + buffer` (see §2.1).
- [ ] Implement `mintBackgroundJwtPool(did: string): Promise<string[]>` (or split single + pool).
- [ ] Ensure each JWT has **unique** `jti` (or equivalent) for duplicate detection.
- [ ] **Do not** break existing `accessToken()` 60s behavior for unrelated features.
- [ ] Wire `configureNativeFetcherIfReady` to pass **single extended token** (Phase A) then **pool** (Phase B).
- [ ] On **logout / identity clear**, clear persisted pool and call plugin clear if needed.
### Android
- [ ] **Phase A:** No structural change if `configure()` still receives one string; verify non-null `jwtToken` after configure.
- [ ] **Phase B:** Parse pool from persisted JSON; implement `selectTokenForRequest()`; use selected token in `Authorization` header instead of sole `jwtToken` field (keep `configure` for `apiBaseUrl` / `did`).
- [ ] Unit or instrumentation tests optional: selection index deterministic.
### Plugin (Option B1 — **daily-notification-plugin** repo)
- [ ] Follow **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** (API shape, Android/iOS, versioning).
- [ ] Release new plugin version; bump dependency in this app.
### Product & server
- [ ] Endorser: confirm **max `exp`**, **duplicate JWT** semantics, recommended **`jti`** format.
- [ ] Document operational limit: if user never opens app for **longer than `exp` allows** (or longer than **pool × daily use** without refresh), prefetch may fail until next open — align with `doc/endorser-jwt-background-prefetch-options.md`.
---
## 5. Security notes
- Longer-lived JWTs and **many** tokens increase impact if device is compromised. Mitigations: **encrypted prefs** where possible, **no logging** of full JWTs, **revocation** story with Endorser (key rotation, deny list).
- Pool regeneration on **login** should replace old pools.
---
## 6. Testing plan
| Test | Expected |
|------|----------|
| Configure → wait **> 5 min** → prefetch | **200** from `plansLastUpdatedBetween` (Phase A) |
| Two consecutive **days** with duplicate-JWT staging | **200** both days (Phase B) |
| Logout | Pool cleared; no stale bearer |
| Lower `BACKGROUND_JWT_POOL_SIZE` in dev only (below `expiryDays + buffer`) | Expect possible reuse / server duplicate errors — use to reproduce failures |
---
## 7. Rollout / staging
1. Implement Phase A behind feature flag **optional** (or direct if low risk).
2. Verify on **test-api.endorser.ch** with server team.
3. Phase B behind flag or same release once server duplicate rules are understood.
---
## 8. Where plugin documentation lives
| Document | Purpose |
|----------|---------|
| **`doc/plan-background-jwt-pool-and-expiry.md`** (this file) | End-to-end app plan: crypto, pool sizing, native host, rollout. |
| **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** | **Plugin-only** handoff: extend `configureNativeFetcher`, persist pool, Android/iOS notes — intended for PRs in **daily-notification-plugin** (or Cursor on that repo). |
Keeping them **separate** avoids mixing consumer app tasks with plugin API contract; the plan **links** to the plugin feedback doc for Option B1.
---
## 9. References
| Topic | Location |
|--------|----------|
| Current 60s `accessToken` | `src/libs/crypto/index.ts` |
| `createEndorserJwtForDid` | `src/libs/endorserServer.ts` |
| Native configure | `src/services/notifications/nativeFetcherConfig.ts` |
| Android HTTP | `android/.../TimeSafariNativeFetcher.java` |
| Options doc (TTL, refresh, BFF) | `doc/endorser-jwt-background-prefetch-options.md` |
| Plugin: `configureNativeFetcher` + JWT pool | `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` |
---
*Update this plan when Phase A/B ship or when Endorser policy changes.*

View File

@@ -1,126 +0,0 @@
# Plugin feedback: Android dual schedule — native fetcher not used; fetch timing wrong
**Date:** 2026-03-24 21:56 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android (Kotlin / Java)
**Related:** New Activity notifications (`scheduleDualNotification` / `cancelDualSchedule`)
---
## Summary
On Android, the **dual (New Activity) schedule** path is **not** implementing the intended contract:
1. **Prefetch does not call `NativeNotificationContentFetcher`.**
`ScheduleHelper.scheduleDualNotification` delegates fetch to `FetchWorker` (HTTP GET to optional `url`, or **mock JSON** when `url` is absent). The host apps `TimeSafariNativeFetcher` is **never** invoked. Logcat shows `DNP-FETCH: Starting content fetch from: null, notificationTime=0` and **no** `TimeSafariNativeFetcher` `fetchContent` lines.
2. **Fetch is not scheduled at `contentFetch.schedule` (e.g. T5 minutes).**
`FetchWorker.enqueueFetch` enqueues **immediate** `OneTimeWorkRequest` work (no `setInitialDelay` aligned to the fetch cron). The **notify** alarm is scheduled correctly for `dual_notify_*`, but there is **no** corresponding alarm/work at the **fetch** cron time. A `dual_fetch_*` row may exist in the DB with `nextRunAt`, but the **actual** fetch runs at **enable/setup time**, not at T5.
3. **Cache vs `DualScheduleHelper` / `contentTimeout`.**
`DualScheduleHelper.resolveDualContentBlocking` only uses `contentCache` when the latest fetch is within `relationship.contentTimeout` (e.g. 5 minutes). If fetch runs **once at setup** and notify fires **~9+ minutes later**, cache is **stale**`useCache=false` → default title/body from `userNotification`, even when mock payload was stored.
**Recommended direction (plugin):**
- For dual schedule when **no HTTP `url`** is configured (or when a flag indicates native mode), run **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** (same path as `DailyNotificationFetchWorker` uses), persist results into the same `contentCache` / pipeline `DualScheduleHelper` expects.
- **Schedule** that work (or an alarm that enqueues it) **at** `calculateNextRunTime(contentFetch.schedule)` — i.e. **before** the notify alarm, typically **5 minutes** earlier per app cron (see consuming app `timeToCronFiveMinutesBefore`).
- Optionally align **one** scheduling mechanism: either exact alarm for fetch + notify, or WorkManager with **initial delay** to the next fetch instant (and reschedule after run).
---
## Symptoms (consuming app + logcat)
- Notification shows **default** copy from `userNotification` (`title` / `body` from `buildDualScheduleConfig`), not API-derived or native “No updates” copy.
- Logcat: `DNP-DUAL: Resolved dual content: useCache=false` at notify time.
- Logcat: `DNP-FETCH: Starting content fetch from: null, notificationTime=0` followed by `Content fetch completed successfully` **at schedule/setup time**, not at T5.
- **No** `TimeSafariNativeFetcher` `fetchContent START` / `POST …/plansLastUpdatedBetween` during prefetch window (host registers `NativeNotificationContentFetcher` and logs on configure + fetch).
- **No** activity at the **prefetch cron** time (e.g. 19:05 for notify at 19:10); only **notify** fires at T.
---
## What the consuming app sends (contract)
**File:** `src/services/notifications/dualScheduleConfig.ts`
- `contentFetch.enabled: true`
- `contentFetch.schedule`: cron **5 minutes before** `userNotification.schedule` (e.g. `"25 19 * * *"` for notify `"30 19 * * *"`).
- **No** `contentFetch.url` — intended to use **native** Endorser API via `configureNativeFetcher` + `NativeNotificationContentFetcher`.
- `relationship.autoLink: true`, `relationship.contentTimeout: 5 * 60 * 1000`, `fallbackBehavior: "show_default"`.
**Host app:** `android/.../TimeSafariNativeFetcher.java` implements `NativeNotificationContentFetcher` and calls `POST /api/v2/report/plansLastUpdatedBetween` with starred plan IDs from `updateStarredPlans`.
---
## Root cause (plugin code — paths to review)
These paths are from a local clone of **daily-notification-plugin**; line numbers may drift.
### 1. `FetchWorker` is URL/mock-only; does not call native fetcher
`android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt`
- `enqueueFetch` passes `config.url` into `InputData`; `doWork` logs `Starting content fetch from: $url`.
- `fetchContent(url, …)` when `url` is null/blank returns **`generateMockContent()`** — never calls `DailyNotificationPlugin.getNativeFetcherStatic().fetchContent(...)`.
### 2. `scheduleDualNotification` runs fetch work immediately, not at fetch cron
`android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt``object ScheduleHelper`, `suspend fun scheduleDualNotification(...)`
- Calls `scheduleFetch(context, contentFetchConfig)` which resolves to `FetchWorker.scheduleFetchForDual``enqueueFetch` **without** delay tied to `contentFetchConfig.schedule`.
- Schedules **notify** via `NotifyReceiver.scheduleExactNotification` for `dual_notify_*` at `calculateNextRunTime(userNotificationConfig.schedule)`.
- Persists `dual_fetch_*` with `nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)` but **no** matching alarm/work is scheduled for that instant in the current flow (as observed).
### 3. Native fetcher exists elsewhere
`android/src/main/java/org/timesafari/dailynotification/DailyNotificationFetchWorker.java`
- Contains logic to call `NativeNotificationContentFetcher.fetchContent(FetchContext)` (with timeout). Dual schedule **does not** enqueue this worker for the TimeSafari `contentFetch` payload.
### 4. `DualScheduleHelper` behavior is consistent with “wrong fetch time”
`android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt`
- Uses latest `contentCache` only if `(now - fetchedAt) <= contentTimeoutMs`. If fetch ran at setup and notify is **later** than `contentTimeout`, **cache is ignored**`useCache=false` in logs.
---
## Acceptance criteria (plugin)
After a fix, on a device with:
- `configureNativeFetcher` + `updateStarredPlans` called (host app),
- `scheduleDualNotification` with `contentFetch.enabled: true`, no `url`, cron 5 min before notify,
then:
1. **At or before** the notify fire time, **within** `contentTimeout`, the cache used by `DualScheduleHelper` reflects **native** fetch results when the API returns data (or empty), not only mock JSON.
2. Logcat **includes** host tag `TimeSafariNativeFetcher` with `fetchContent START` (or equivalent) **when** prefetch runs, **or** plugin logs an explicit `NativeNotificationContentFetcher` invocation.
3. Prefetch **does not** run only at **INITIAL_SETUP**; it runs at the **next** occurrence of `contentFetch.schedule` (and reschedules for the following day after success, same as notify rollover).
4. **Optional:** If `url` is set, preserve HTTP GET behavior; if `url` is absent and native fetcher is registered, use native path.
---
## References in consuming app
| Topic | Location |
|--------|----------|
| Dual config builder | `src/services/notifications/dualScheduleConfig.ts` |
| `scheduleDualNotification` call | `src/views/AccountViewView.vue` (`scheduleNewActivityDualNotification`, `editNewActivityNotification`) |
| Native fetcher | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` |
| Registration | `MainActivity` / plugin init (host registers `DailyNotificationPlugin.setNativeFetcher`) |
---
## Notes for Cursor / implementers
- **Do not** assume `contentFetch.url` is present; TimeSafari intentionally omits it for native API.
- **Reuse** the same `FetchContext` / timeout semantics as `DailyNotificationFetchWorker` where possible to avoid two divergent native fetch implementations.
- After changing timing, **verify** `WorkManager` unique work name `fetch_dual` / `cancelDualSchedule` still cancel only dual fetch and do not break daily reminder.
---
## Related docs in this repo
- `doc/notification-from-api-call.md` — integration plan for API-driven New Activity.
- `doc/plugin-feedback-android-scheduleDualNotification-contentFetch-json.md` — optional `timeout` / `retry*` JSON parsing (already addressed on the plugin side).

View File

@@ -2,7 +2,7 @@
**Date:** 2026-02-18
**Generated:** 2026-02-18 17:47:06 PST
**Target repo:** `@timesafari/daily-notification-plugin` (https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin)
**Target repo:** daily-notification-plugin (local copy at `daily-notification-plugin_test`)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android

View File

@@ -1,99 +0,0 @@
# Plugin feedback: Android `parseUserNotificationConfig` — optional fields vs `getBoolean` / `getString`
**Date:** 2026-03-20 21:11 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android (Kotlin)
**Related:** Same class of issue as [plugin-feedback-android-scheduleDualNotification-contentFetch-json.md](./plugin-feedback-android-scheduleDualNotification-contentFetch-json.md) (`contentFetch` / `parseContentFetchConfig`).
---
## Summary
`DailyNotificationPlugin.parseUserNotificationConfig()` uses **`JSObject` / `JSONObject` strict getters** for fields that the published TypeScript **`UserNotificationConfig`** marks as **optional** (`sound?`, `vibration?`, `priority?`, `title?`, `body?`). If a key is omitted, Android throws **`JSONException`** (e.g. *No value for vibration*), and `scheduleDualNotification` fails before scheduling.
**Recommended direction (plugin):** Align Kotlin parsing with `dist/esm/definitions.d.ts` by using **optional reads + defaults**, consistent with the fix already applied for `parseContentFetchConfig` (e.g. `optIntOrNull`, or Capacitor/JSON equivalents for booleans and strings).
**Recommended direction (app / already done in TimeSafari):** Send explicit `sound`, `vibration`, and `priority` (and title/body) in `buildDualScheduleConfig()` so **older plugin builds** that still use strict getters continue to work.
**Does it make sense to change both sides?** **Yes** — same reasoning as for `contentFetch`: the plugin should match its public contract; the app can stay explicit for compatibility and clarity.
---
## Symptoms (consuming app)
- In-app toast: *“Could not schedule New Activity notification. Please try again.”* (generic catch after `scheduleDualNotification` rejects.)
- Logcat:
```text
E DNP-PLUGIN: Schedule dual notification error
E DNP-PLUGIN: org.json.JSONException: No value for vibration
E DNP-PLUGIN: at org.json.JSONObject.getBoolean(JSONObject.java:419)
E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.parseUserNotificationConfig(DailyNotificationPlugin.kt:2428)
E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.scheduleDualNotification(DailyNotificationPlugin.kt:1392)
```
(First failure observed after `contentFetch` timeouts were fixed was **`vibration`**; the same pattern can affect **`sound`** or **`priority`** if those keys are omitted.)
---
## Root cause
### Published TypeScript contract (`UserNotificationConfig`)
From `definitions.d.ts` (representative):
- `title?`, `body?`, `sound?`, `vibration?`, `priority?` — all optional.
### Current Android implementation (strict)
In `DailyNotificationPlugin.kt`, `parseUserNotificationConfig` (line numbers approximate; search for `parseUserNotificationConfig`):
```kotlin
private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.getBoolean("enabled") ?: true,
schedule = configJson.getString("schedule") ?: "0 9 * * *",
title = configJson.getString("title"),
body = configJson.getString("body"),
sound = configJson.getBoolean("sound"),
vibration = configJson.getBoolean("vibration"),
priority = configJson.getString("priority")
)
}
```
- **`getBoolean("vibration")`** (and **`getBoolean("sound")`**) throw if the key is **missing** — optional in TS, required at runtime on Android.
- **`getString("title")`**, **`getString("body")`**, **`getString("priority")`** likewise throw if missing (depending on `JSObject` / `JSONObject` behavior for absent keys).
So minimal or TS-faithful payloads omit `vibration` → immediate `JSONException`.
---
## Plugin-side recommendations
1. **Treat `UserNotificationConfig` optional fields as optional on Android**, mirroring `definitions.d.ts`:
- **`vibration`:** e.g. `optBoolean` / nullable + default **`true`** (or `false` if that matches product default — document the default).
- **`sound`:** same pattern; default **`true`** is typical for notifications.
- **`priority`:** optional string with default **`"normal"`** (or map from TS union).
- **`title` / `body`:** if TS allows omission, use optional reads + defaults consistent with dual-schedule UX (or reject with a clear `call.reject` message instead of a raw `JSONException`).
2. **Reuse the same helper style** as `parseContentFetchConfig` after the timeout fix (`optIntOrNull`, etc.) so one codebase convention applies to all dual-schedule JSON parsing.
3. **Tests:** Unit or integration test that calls `scheduleDualNotification` with a **minimal** `userNotification` object (only what TS strictly requires, if anything) and asserts scheduling succeeds on Android.
4. **iOS parity:** If iOS already accepts omitted `vibration` / `sound`, Android should match; if not, align both platforms to the same `UserNotificationConfig` rules.
---
## App-side note (TimeSafari)
`src/services/notifications/dualScheduleConfig.ts``buildDualScheduleConfig()` now includes **`vibration: true`** (with `sound: true`) so current native code paths succeed. Keeping this explicit is still recommended even after the plugin is fixed.
---
## References
- Plugin: `android/.../DailyNotificationPlugin.kt``parseUserNotificationConfig`
- TS: `dist/esm/definitions.d.ts``UserNotificationConfig`, `DualScheduleConfiguration`
- App: `src/services/notifications/dualScheduleConfig.ts``buildDualScheduleConfig`

View File

@@ -1,117 +0,0 @@
# Plugin feedback: Android `scheduleDualNotification` — `JSONException: No value for timeout`
**Date:** 2026-03-20 18:21 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android (Kotlin)
**Plugin version observed:** 2.1.2 (from app `node_modules`)
---
## Summary
Scheduling the **New Activity** dual notification on Android fails with a native `JSONException` because `DailyNotificationPlugin.parseContentFetchConfig()` uses **`JSONObject.getInt()`** for `timeout`, `retryAttempts`, and `retryDelay`. Those keys are **absent** from the apps `contentFetch` object built by `buildDualScheduleConfig()`. The plugins own TypeScript `ContentFetchConfig` marks those fields as **optional**, so the Android parser is stricter than the published contract.
**Recommended direction:**
1. **Plugin (primary):** Parse optional numeric fields with defaults (e.g. `optInt` / nullable + defaults) so payloads that omit them do not crash and match `definitions.d.ts`.
2. **App (secondary / compatibility):** Include explicit `timeout`, `retryAttempts`, and `retryDelay` on `contentFetch` so older plugin versions that still use `getInt` continue to work.
**Does it make sense to change both sides?** **Yes.** Fixing the plugin aligns behavior with the documented API and protects any consumer that omits those fields. Fixing the app is still valuable for **older shipped plugin builds** and makes network behavior explicit. Together you get backward compatibility, clearer intent, and no silent reliance on undocumented defaults.
---
## Symptoms (consuming app)
- In-app toast: *“Could not schedule New Activity notification. Please try again.”* (generic error path after `scheduleDualNotification` rejects.)
- Logcat (filtered on DNP / plugin tags):
```text
E DNP-PLUGIN: Schedule dual notification error
E DNP-PLUGIN: org.json.JSONException: No value for timeout
E DNP-PLUGIN: at org.json.JSONObject.getInt(JSONObject.java:487)
E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.parseContentFetchConfig(DailyNotificationPlugin.kt:2403)
E DNP-PLUGIN: at org.timesafari.dailynotification.DailyNotificationPlugin.scheduleDualNotification(DailyNotificationPlugin.kt:1391)
```
---
## Root cause
### Call path
`scheduleDualNotification` reads `config.contentFetch` and passes it to `parseContentFetchConfig`:
- File: `android/.../DailyNotificationPlugin.kt`
- `scheduleDualNotification` ~1391: `parseContentFetchConfig(contentFetchObj)`
- `parseContentFetchConfig` ~23972411: uses `getInt` for three keys.
### Strict Android parsing
Illustrative (exact line numbers may shift between releases):
```kotlin
// parseContentFetchConfig — timeout / retry fields are required via getInt()
timeout = configJson.getInt("timeout"),
retryAttempts = configJson.getInt("retryAttempts"),
retryDelay = configJson.getInt("retryDelay"),
```
`getInt` throws if the key is missing → first missing key in practice is `timeout``JSONException: No value for timeout`.
### App payload today (consuming app)
File: `src/services/notifications/dualScheduleConfig.ts``buildDualScheduleConfig()` sets `contentFetch` to:
- `enabled`, `schedule`, `callbacks` only (no `timeout`, `retryAttempts`, `retryDelay`, no `url`).
That matches the **TypeScript** contract in the plugins `dist/esm/definitions.d.ts`, where `timeout`, `retryAttempts`, and `retryDelay` are **optional** on `ContentFetchConfig`.
### Contract mismatch
| Layer | `timeout` / `retryAttempts` / `retryDelay` |
|--------|--------------------------------------------|
| TS `ContentFetchConfig` | Optional (`?`) |
| Android `parseContentFetchConfig` | Required (`getInt` — throws if absent) |
The consuming app followed the TS API; Android rejected it at runtime.
---
## Plugin-side recommendations
1. **Use optional reads with defaults** for `timeout`, `retryAttempts`, and `retryDelay` (and any similar fields), e.g. Kotlin/Capacitor equivalents of `optInt` or `getInteger` with fallbacks documented in `ContentFetchConfig`.
2. **Document defaults** in the plugin README or API docs if they are applied on native when omitted.
3. **Consider tests** that call `scheduleDualNotification` with a minimal `contentFetch` (only `enabled`, `schedule`, `callbacks`) and assert scheduling succeeds on Android.
4. **Optional:** If `url` is also read in a way that assumes presence, align with TS (`url?`) the same way.
---
## App-side recommendations (later; crowd-funder-for-time-pwa)
When you implement the app fix:
- Extend `contentFetch` in `buildDualScheduleConfig()` (`src/services/notifications/dualScheduleConfig.ts`) to include explicit integers, for example aligned with existing app/network conventions (the apps `capacitor.config.ts` already uses a `timeout` value in one place — reuse or document chosen values).
- Ensure **both** code paths that build dual config stay in sync (e.g. `AccountViewView.vue` uses `buildDualScheduleConfig` for New Activity scheduling and for `updateDualScheduleConfig` fallback).
This unblocks users on **current** plugin versions that still require those keys.
---
## References (paths in consuming app workspace)
- App config builder: `src/services/notifications/dualScheduleConfig.ts`
- Native scheduling entry: `node_modules/@timesafari/daily-notification-plugin/android/.../DailyNotificationPlugin.kt` (`scheduleDualNotification`, `parseContentFetchConfig`)
---
## Answer: change both plugin and app?
**Yes, it makes sense to change both**, for different reasons:
| Side | Why |
|------|-----|
| **Plugin** | Fixes the real bug: native behavior must match the published optional TS fields; avoids breaking any client that sends a minimal `contentFetch`. |
| **App** | Defense in depth and support for **already-shipped** plugin binaries that will not get the Kotlin fix until users update the app. Explicit values also document intended fetch/retry behavior in one place. |
If you only fix the plugin, new app releases still need users to update the **native** binary. If you only fix the app, any other consumer of the plugin or future minimal payloads can hit the same crash until the plugin is fixed.

View File

@@ -1,95 +0,0 @@
# Plugin feedback: `configureNativeFetcher` — optional JWT pool for background API calls
**Date:** 2026-03-27 PST
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1)
---
## Summary
The host apps **`NativeNotificationContentFetcher`** (`TimeSafariNativeFetcher` on Android) calls Endorser with a Bearer JWT set via **`configureNativeFetcher`**. For **background** prefetch, the token must stay valid until WorkManager runs (often **minutes later**); Endorser may also reject **duplicate** JWT strings across days.
The **app** will mint a **pool** of distinct JWTs (see app plan) and needs the plugin to **accept and persist** that pool so native code can select a token **without JavaScript** at prefetch time.
**Requested change (plugin):** extend **`configureNativeFetcher`** to accept an optional **JWT pool** alongside the existing **`jwtToken`**, persist it in the same storage the host already relies on (e.g. SharedPreferences / app group), and document how **`NativeNotificationContentFetcher`** implementations should read it.
---
## Motivation
| Issue | Why plugin support helps |
|-------|---------------------------|
| Single short-lived `jwtToken` | Expires before background fetch |
| Server duplicate-JWT rules | Need many distinct bearer strings over time |
| No JS in WorkManager | Pool must be readable **only** from native |
---
## Proposed API (TypeScript / Capacitor)
**Extend** existing `configureNativeFetcher` options (names indicative — align with plugin naming conventions):
```ts
configureNativeFetcher(options: {
apiBaseUrl: string;
activeDid: string;
/** Primary token; keep for backward compatibility and Phase A (single long-lived JWT). */
jwtToken: string;
/**
* Optional. Distinct JWT strings for background use (e.g. one per day slot).
* If omitted, behavior matches today (single jwtToken only).
*/
jwtTokens?: string[];
});
```
**Alternatives** (if size limits matter for bridge payload):
- `jwtTokenPoolJson: string` — JSON array string of JWT strings (single string across the bridge).
**Validation (plugin):**
- If `jwtTokens` present: length **≤** a sane cap (host will use ~100; plugin may enforce max e.g. 128).
- Empty array: treat as “no pool” (same as omitting).
---
## Android
1. **Parse** new fields in `DailyNotificationPlugin.configureNativeFetcher` (or equivalent).
2. **Persist** pool under the same prefs namespace used for other TimeSafari / dual-schedule data, or a **documented** key prefix (e.g. `jwt_token_pool` as JSON array string).
3. **Document** for host implementers: `NativeNotificationContentFetcher` should:
- Prefer **pool entry** for `fetchContent` when pool is non-empty (selection policy is **host** responsibility — e.g. day index % length), **or**
- Expose a small helper the host fetcher calls to resolve “current” bearer.
4. **Clear** pool when `configureNativeFetcher` is called with a new identity / empty pool / logout path (coordinate with host).
5. **Backward compatibility:** if only `jwtToken` is sent, behavior **unchanged** from current release.
---
## iOS
When `configureNativeFetcher` exists on iOS, mirror Android: accept optional pool, persist, document read path for native fetcher.
---
## Versioning & release
- Bump **plugin semver** (minor: new optional fields).
- Publish package; consuming app bumps **`@timesafari/daily-notification-plugin`** and updates `nativeFetcherConfig.ts` to pass `jwtTokens` when Phase B ships.
---
## References (host app)
| Topic | Location |
|--------|----------|
| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
| Android fetcher | `android/.../TimeSafariNativeFetcher.java` |
| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` |
| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.md` |
---
*This document is intended to be copied or linked from PRs in **daily-notification-plugin**; keep app-specific details in the app plan.*

View File

@@ -1,140 +0,0 @@
# Plugin Feedback: Implement scheduleDualNotification on iOS
**Target repo:** daily-notification-plugin (iOS native layer)
**Purpose:** Document for implementing or fixing `scheduleDualNotification` on iOS so the consuming app (TimeSafari / crowd-funder) can enable “New Activity” notifications.
**Consuming app doc:** `doc/notification-new-activity-lay-of-the-land.md`
---
## Troubleshooting: `UNIMPLEMENTED` on iOS (Capacitor 6)
If **`configureNativeFetcher`** (or other DailyNotification methods) work but **`scheduleDualNotification`** still fails with **`{"code":"UNIMPLEMENTED"}`** and you **do not** see a native log line like `To Native -> DailyNotification scheduleDualNotification`, the failure is often **not** missing Swift code—it is **Capacitors JavaScript layer** rejecting the call because the method is **not listed** in `window.Capacitor.PluginHeaders` for `DailyNotification`. Those headers are built at runtime from the **compiled** plugins `pluginMethods` list (`CAPBridgedPlugin`).
**Fix in the consuming app (usual cause: stale Pods / binary):**
1. Ensure `node_modules/@timesafari/daily-notification-plugin` includes `scheduleDualNotification` in `DailyNotificationPlugin.swift`s `pluginMethods` (v2.1.0+).
2. From the project root: `npx cap sync ios`
3. `cd ios/App && pod install` (or delete `Pods` + `Podfile.lock` and `pod install` if upgrading the plugin).
4. Xcode: **Product → Clean Build Folder**, then rebuild and run on device/simulator.
**Verify:** Safari → Develop → attach to the app WebView → Console: inspect `window.Capacitor.PluginHeaders` and confirm the `DailyNotification` entrys `methods` array includes `{ name: "scheduleDualNotification", ... }`.
If a full clean rebuild still doesn't fix it, clear Xcode's **system** DerivedData (quit Xcode, run `rm -rf ~/Library/Developer/Xcode/DerivedData/*TimeSafari*`, reopen and rebuild). On launch the app logs `[Capacitor] DNP PluginHeaders methods: [...]`; if that list omits `scheduleDualNotification`, the native binary is still stale.
If the method **is** present in headers but scheduling still fails, debug the Swift implementation (reject message, BG tasks, etc.).
### Misleading `UNIMPLEMENTED` before `scheduleDualNotification`
Capacitors `registerPlugin` proxy returns a **callable stub for every property name**. So `if (DailyNotification?.updateStarredPlans)` is **always truthy** even when iOS does not expose `updateStarredPlans` in `pluginMethods`. Calling that stub throws **`UNIMPLEMENTED`** in JS **before** any `To Native -> DailyNotification scheduleDualNotification` line appears—so logs look like “dual schedule is unimplemented” when the real failure was **`updateStarredPlans`**.
**Consuming-app fix:** treat `updateStarredPlans` as optional: catch `UNIMPLEMENTED` and continue, or only call after verifying the method name exists on `PluginHeaders` for `DailyNotification`. If the plugin adds `updateStarredPlans` natively later, starred-plan filtering will start working without app changes.
---
## Current behavior
- The **consuming app** calls `DailyNotification.scheduleDualNotification({ config })` from TypeScript when the user turns on “New Activity Notification” and picks a time (native iOS).
- On **iOS**, the plugin rejects with **`code: "UNIMPLEMENTED"`** (observed in Xcode: `[AccountViewView] scheduleNewActivityDualNotification failed: {"code":"UNIMPLEMENTED"}`).
- On **Android**, the same call is expected to work (dual schedule: content fetch + user notification).
The app has already:
- Called `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken })` so the plugin can use the native fetcher for API-driven content.
- Called `updateStarredPlans({ planIds })` so the fetcher knows which plans to query.
- Built a `config` object that matches the plugins `DualScheduleConfiguration` (see below).
So the missing piece on iOS is a **working implementation** of `scheduleDualNotification` that accepts this config and schedules the dual flow (content fetch at one time, user notification at a later time).
---
## Call from the consuming app
```ts
await DailyNotification.scheduleDualNotification({ config });
```
`config` is built by the apps `buildDualScheduleConfig({ notifyTime })` and has the following shape.
---
## Config shape the app sends
The app sends a single `config` object that matches the plugins `DualScheduleConfiguration` (see `definitions.ts`). Example for `notifyTime: "18:30"` (6:30 PM):
```json
{
"contentFetch": {
"enabled": true,
"schedule": "25 18 * * *",
"callbacks": {}
},
"userNotification": {
"enabled": true,
"schedule": "30 18 * * *",
"title": "New Activity",
"body": "Check your starred projects and offers for updates.",
"sound": true,
"priority": "normal"
},
"relationship": {
"autoLink": true,
"contentTimeout": 300000,
"fallbackBehavior": "show_default"
}
}
```
- **Cron format:** `"minute hour * * *"` (daily at that local time).
- **contentFetch.schedule:** 5 minutes **before** the users chosen time (e.g. 18:25 for notify at 18:30).
- **userNotification.schedule:** The users chosen time (e.g. 18:30).
- **contentFetch.callbacks:** The app sends `{}`; the actual fetch is done by the **native fetcher** (already configured via `configureNativeFetcher`). The plugin should run the content-fetch job at the contentFetch cron and use the native fetcher to get content; at userNotification time it should show a notification using that content or the fallback title/body.
- **relationship.contentTimeout:** Milliseconds to wait for content before showing the notification (app uses 5 minutes = 300000).
- **relationship.fallbackBehavior:** `"show_default"` means if content isnt ready in time, show the notification with the default title/body from `userNotification`.
The app does **not** send `contentFetch.url` or `contentFetch.timesafariConfig`; it relies on the native fetcher and `configureNativeFetcher` / `updateStarredPlans` for API behavior.
---
## Expected plugin behavior (iOS)
1. **Accept** the `config` argument (object with `contentFetch`, `userNotification`, and optional `relationship`).
2. **Parse** the cron expressions for `contentFetch.schedule` and `userNotification.schedule` (e.g. using a shared cron parser or the same approach as Android).
3. **Schedule** two things:
- **Content fetch:** At the time given by `contentFetch.schedule`, run the **native notification content fetcher** (the one configured via `configureNativeFetcher`). Store the result in the plugins cache (or equivalent) for use when the user notification fires.
- **User notification:** At the time given by `userNotification.schedule`, show a local notification. Use cached content from the fetch if available and within `relationship.contentTimeout`; otherwise use `userNotification.title` and `userNotification.body` (per `relationship.fallbackBehavior: "show_default"`).
4. **Do not** reject with `UNIMPLEMENTED`; resolve the promise once scheduling has succeeded (or reject with a descriptive error if scheduling fails).
5. **cancelDualSchedule()** should cancel both the content-fetch schedule and the user-notification schedule so the user can turn off New Activity from the app.
Alignment with **Android** (if implemented there) is desirable: same config shape, same semantics (prefetch then notify, fallback to default title/body). The plugins **definitions.ts** already defines `DualScheduleConfiguration`, `ContentFetchConfig`, `UserNotificationConfig`, and the `scheduleDualNotification` / `cancelDualSchedule` API.
---
## Where to look in the plugin (iOS)
- **Plugin entry:** `ios/Plugin/DailyNotificationPlugin.swift` (or equivalent)—find the handler for `scheduleDualNotification` (e.g. method that receives `call.getObject("config")`).
- **Android reference:** `android/` implementation of `scheduleDualNotification` and how it schedules WorkManager/alarms for content fetch and for the user notification.
- **Definitions:** `src/definitions.ts``DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`.
- **Native fetcher:** The app configures the native fetcher before calling `scheduleDualNotification`; the iOS plugin should invoke that same fetcher when the content-fetch job runs (BGAppRefreshTask or equivalent), not a URL from the config.
---
## Acceptance criteria
- [ ] On iOS, calling `DailyNotification.scheduleDualNotification({ config })` with the config shape above **does not** reject with `code: "UNIMPLEMENTED"`.
- [ ] The content-fetch job is scheduled at `contentFetch.schedule` and uses the configured native fetcher to fetch content.
- [ ] The user notification is scheduled at `userNotification.schedule` and shows with API-derived content when available, or with `userNotification.title` / `userNotification.body` as fallback.
- [ ] Calling `DailyNotification.cancelDualSchedule()` cancels both schedules on iOS.
- [ ] Behavior is consistent with Android where applicable (same config, same lifecycle).
---
## Relationship to consuming app
The consuming app will continue to call:
1. `configureNativeFetcher(...)` on startup and when enabling New Activity.
2. `updateStarredPlans({ planIds })` when enabling or when Account view loads with New Activity on.
3. `scheduleDualNotification({ config })` when the user turns on New Activity and picks a time.
4. `cancelDualSchedule()` when the user turns off New Activity.
No change to the apps config shape or call order is planned; the fix is entirely on the plugin iOS side to implement or correct `scheduleDualNotification` (and ensure `cancelDualSchedule` clears the dual schedule).

View File

@@ -1,96 +0,0 @@
# Plugin fix: Android compile error — duplicate `scheduleId` in `handleDisplayNotification`
**Date:** 2026-03-20
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
**Platform:** Android (Java)
---
## Summary
The Android module fails to compile with **two** `javac` errors: `variable scheduleId is already defined in method handleDisplayNotification(String)`. The method already declares `String scheduleId` at the start of the `try` block; two nested blocks incorrectly **redeclare** `String scheduleId`, which Java forbids in the same method scope. Remove the redundant declarations and reuse the existing variable (or assign without `String` if you ever need to refresh it).
---
## Problem
- **File:** `android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java`
- **Method:** `private Result handleDisplayNotification(String notificationId)`
**Compiler output (representative):**
```text
DailyNotificationWorker.java:162: error: variable scheduleId is already defined in method handleDisplayNotification(String)
String scheduleId = inputData.getString("schedule_id");
^
DailyNotificationWorker.java:193: error: variable scheduleId is already defined in method handleDisplayNotification(String)
String scheduleId = inputData.getString("schedule_id");
^
```
**Root cause:** At the top of the `try` block, the code already has:
```java
Data inputData = getInputData();
String scheduleId = inputData.getString("schedule_id");
```
Later, inside:
1. The `if (isStaticReminder) { ... }` branch — a line like `String scheduleId = inputData.getString("schedule_id");` (around line 162).
2. The `else { ... }` branch — the same pattern (around line 193).
In Java, a local variable name cannot be declared again in nested blocks that share the enclosing methods scope for that name. These inner `String scheduleId` lines are **illegal** and break `:timesafari-daily-notification-plugin:compileDebugJavaWithJavac`.
**Functional note:** Both inner reads use the same key (`"schedule_id"`) as the outer declaration, so they add **no** new information; the fix is to **delete** those inner declarations and keep using `scheduleId` from the first assignment.
---
## Required change
**Option A (recommended):** Delete the two redundant lines entirely:
- Remove the inner `String scheduleId = inputData.getString("schedule_id");` in the **static reminder** branch (post-reboot/rollover comment block).
- Remove the inner `String scheduleId = inputData.getString("schedule_id");` in the **regular notification** branch (rollover/notify_* comment block).
All subsequent uses of `scheduleId` in those branches should continue to refer to the variable declared immediately after `getInputData()`.
**Option B (only if you must re-read input later):** Replace redeclaration with assignment:
```java
scheduleId = inputData.getString("schedule_id");
```
Do **not** prefix with `String` again inside the same method.
---
## Verification
1. **Compile:** From the plugin repo, run the Android Java compile for the library (or assemble debug). Expect **zero** errors for `DailyNotificationWorker.java`.
2. **Consuming app:** Bump/publish the plugin version, update `package.json` in TimeSafari, `npm install`, `npx cap sync android`, then run the usual Android debug build (e.g. `./scripts/build-android.sh --test` or `assembleDebug`). The task `:timesafari-daily-notification-plugin:compileDebugJavaWithJavac` must succeed.
3. **Behavior:** No intended behavior change: `schedule_id` is still read once per worker run from `getInputData()` and used for dual-prefix checks, static reminder DB fallback, and canonical content by `schedule_id` in the non-static path.
---
## Context (how this was found)
- Observed when running `npm run build:android:test:run` on crowd-funder-for-time-pwa; Vite/TypeScript succeeded; Gradle failed on the plugins Java sources under `node_modules/.../DailyNotificationWorker.java`.
- Line numbers in published packages may drift slightly; search for `handleDisplayNotification` and duplicate `String scheduleId` inside that method.
---
## Cursor prompt (paste into plugin repo)
You can paste the block below into Cursor in the **daily-notification-plugin** workspace:
```text
Fix Android compile errors in DailyNotificationWorker.java: in handleDisplayNotification(String notificationId), scheduleId is declared once after getInputData(). Remove the two illegal inner redeclarations "String scheduleId = inputData.getString(\"schedule_id\");" (static reminder branch and else branch). Reuse the outer scheduleId variable. Do not shadow or redeclare String scheduleId in the same method. Verify compileDebugJavaWithJavac passes.
```
---
## After the fix
Release a new plugin version and update the consuming apps dependency so `node_modules` is not hand-edited (edits there are lost on `npm install`).

View File

@@ -1,6 +1,6 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"appName": "Giftopia",
"webDir": "dist",
"server": {
"cleartext": true
@@ -34,12 +34,12 @@
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
"biometricTitle": "Biometric login for Giftopia"
},
"electronIsEncryption": false
}
@@ -72,7 +72,7 @@
},
"buildOptions": {
"appId": "app.timesafari",
"productName": "TimeSafari",
"productName": "Giftopia",
"directories": {
"output": "dist-electron-packages"
},

3
ios/.gitignore vendored
View File

@@ -17,6 +17,7 @@ App/App/config.xml
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -15,7 +15,6 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */; };
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
@@ -56,7 +55,6 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSafariNativeFetcher.swift; sourceTree = "<group>"; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
@@ -66,7 +64,7 @@
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */ = {
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
@@ -79,7 +77,7 @@
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* Exceptions for "TimeSafariShareExtension" folder in "TimeSafariShareExtension" target */,
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
@@ -142,7 +140,6 @@
children = (
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
A3F8E2D91B4C5E60718293A4 /* TimeSafariNativeFetcher.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
@@ -177,9 +174,9 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -207,6 +204,8 @@
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
@@ -294,19 +293,19 @@
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
3FE25897CF40A571D4AC2ACE /* [CP] Copy Pods Resources */ = {
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
@@ -360,7 +359,6 @@
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
B7E1C4F82A9D3E506F1B2C8D /* TimeSafariNativeFetcher.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -524,20 +522,20 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.3.12;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -553,20 +551,20 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.3.12;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -584,12 +582,12 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -598,7 +596,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.3.12;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
@@ -622,12 +620,12 @@
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 65;
CURRENT_PROJECT_VERSION = 67;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -636,7 +634,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.3.8;
MARKETING_VERSION = 1.3.12;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -1,7 +1,6 @@
import UIKit
import Capacitor
import CapacitorCommunitySqlite
import TimesafariDailyNotificationPlugin
import UserNotifications
@UIApplicationMain
@@ -10,9 +9,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// New Activity / dual schedule: plugin requires a registered native fetcher before configureNativeFetcher (parity with Android setNativeFetcher).
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
// Set notification center delegate so notifications show in foreground and rollover is triggered
UNUserNotificationCenter.current().delegate = self
@@ -93,20 +89,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
/// Show notifications when app is in foreground and post DailyNotificationDelivered for rollover.
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String {
let scheduledTime: Int64? = {
if let v = userInfo["scheduled_time"] as? Int64 { return v }
if let n = userInfo["scheduled_time"] as? NSNumber { return n.int64Value }
if let i = userInfo["scheduled_time"] as? Int { return Int64(i) }
return nil
}()
if let scheduledTime = scheduledTime {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
}
if let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: ["notification_id": notificationId, "scheduled_time": scheduledTime]
)
}
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])

View File

@@ -2,17 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>TimeSafari</string>
<string>Giftopia</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -25,17 +18,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@@ -44,13 +26,6 @@
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Time Safari allows you to upload photos.</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -72,5 +47,30 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
</dict>
</plist>

View File

@@ -1,215 +0,0 @@
import Foundation
import TimesafariDailyNotificationPlugin
/// Native content fetcher for API-driven New Activity notifications on iOS.
/// Mirrors `TimeSafariNativeFetcher.java` (POST `plansLastUpdatedBetween`, starred plans, JWT pool, pagination).
final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
static let shared = TimeSafariNativeFetcher()
private let endpoint = "/api/v2/report/plansLastUpdatedBetween"
private let readTimeoutSec: TimeInterval = 15
private let maxRetries = 3
private let retryDelayMs = 1_000
/// Matches plugin `updateStarredPlans` storage (`DailyNotificationPlugin.swift`).
private let prefsStarredKey = "daily_notification_timesafari.starredPlanIds"
/// Matches Java `TimeSafariNativeFetcher` prefs namespace `daily_notification_timesafari` + `last_acked_jwt_id`.
private let prefsLastAckedKey = "daily_notification_timesafari.last_acked_jwt_id"
private var apiBaseUrl: String?
private var activeDid: String?
private var jwtToken: String?
private var jwtTokenPool: [String]?
private init() {}
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
self.apiBaseUrl = apiBaseUrl.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(
of: "/$",
with: "",
options: .regularExpression
)
self.activeDid = activeDid
self.jwtToken = jwtToken
self.jwtTokenPool = (jwtTokenPool?.isEmpty == false) ? jwtTokenPool : nil
}
func fetchContent(context: FetchContext) async throws -> [NotificationContent] {
try await fetchContentWithRetry(context: context, retryCount: 0)
}
/// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` same as Java.
private func selectBearerTokenForRequest() -> String? {
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
let idx = Int(epochDay) % pool.count
let t = pool[idx]
if t.isEmpty { return jwtToken }
return t
}
private func fetchContentWithRetry(context: FetchContext, retryCount: Int) async throws -> [NotificationContent] {
guard let base = apiBaseUrl, !base.isEmpty,
activeDid != nil,
let bearer = selectBearerTokenForRequest(), !bearer.isEmpty
else {
NSLog("[TimeSafariNativeFetcher] Not configured; call configureNativeFetcher from JS first.")
return []
}
guard let url = URL(string: base + endpoint) else {
return []
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = readTimeoutSec
let planIds = getStarredPlanIds()
var afterId = getLastAcknowledgedJwtId() ?? "0"
if afterId.isEmpty { afterId = "0" }
let body: [String: Any] = [
"planIds": planIds,
"afterId": afterId,
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
NSLog(
"[TimeSafariNativeFetcher] POST \(endpoint) planCount=\(planIds.count) afterId=\(afterId.prefix(12))"
)
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = readTimeoutSec
config.timeoutIntervalForResource = readTimeoutSec
let session = URLSession(configuration: config)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
return []
}
if http.statusCode == 200 {
let bodyStr = String(data: data, encoding: .utf8) ?? ""
let contents = parseApiResponse(responseBody: bodyStr, context: context)
if !contents.isEmpty {
updateLastAckedJwtIdFromResponse(responseBody: bodyStr)
}
return contents
}
if retryCount < maxRetries && (http.statusCode >= 500 || http.statusCode == 429) {
let delayMs = retryDelayMs * (1 << retryCount)
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
}
NSLog("[TimeSafariNativeFetcher] API error \(http.statusCode)")
return []
} catch {
NSLog("[TimeSafariNativeFetcher] Fetch failed: \(error.localizedDescription)")
if retryCount < maxRetries {
let delayMs = retryDelayMs * (1 << retryCount)
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
return try await fetchContentWithRetry(context: context, retryCount: retryCount + 1)
}
return []
}
}
private func getStarredPlanIds() -> [String] {
guard let jsonStr = UserDefaults.standard.string(forKey: prefsStarredKey),
!jsonStr.isEmpty, jsonStr != "[]",
let data = jsonStr.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
return arr.compactMap { $0 as? String }
}
private func getLastAcknowledgedJwtId() -> String? {
let s = UserDefaults.standard.string(forKey: prefsLastAckedKey)
return (s?.isEmpty == false) ? s : nil
}
private func updateLastAckedJwtIdFromResponse(responseBody: String) {
guard let data = responseBody.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
else { return }
let lastItem = dataArray[dataArray.count - 1]
var jwtId: String?
if let j = lastItem["jwtId"] as? String {
jwtId = j
} else if let plan = lastItem["plan"] as? [String: Any], let j = plan["jwtId"] as? String {
jwtId = j
}
if let jwtId = jwtId, !jwtId.isEmpty {
UserDefaults.standard.set(jwtId, forKey: prefsLastAckedKey)
}
}
private func extractProjectDisplayTitle(_ item: [String: Any]) -> String {
if let plan = item["plan"] as? [String: Any],
let name = plan["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return name.trimmingCharacters(in: .whitespacesAndNewlines)
}
return "Unnamed Project"
}
private func extractJwtIdFromItem(_ item: [String: Any]) -> String? {
if let plan = item["plan"] as? [String: Any], let j = plan["jwtId"] as? String, !j.isEmpty {
return j
}
if let j = item["jwtId"] as? String, !j.isEmpty { return j }
return nil
}
private func parseApiResponse(responseBody: String, context: FetchContext) -> [NotificationContent] {
guard let data = responseBody.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataArray = root["data"] as? [[String: Any]], !dataArray.isEmpty
else {
return []
}
let firstItem = dataArray[0]
let firstTitle = extractProjectDisplayTitle(firstItem)
let jwtId = extractJwtIdFromItem(firstItem)
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let scheduledMs: Int64 = context.scheduledTimeMillis ?? (nowMs + 3_600_000)
let n = dataArray.count
let quotedFirst = "\u{201C}\(firstTitle)\u{201D}"
let title: String
let body: String
if n == 1 {
title = "Starred Project Update"
body = "\(quotedFirst) has been updated."
} else {
title = "Starred Project Updates"
let more = n - 1
body = "\(quotedFirst) + \(more) more have been updated."
}
let id = "endorser_\(jwtId ?? "batch_\(nowMs)")"
return [
NotificationContent(
id: id,
title: title,
body: body,
scheduledTime: scheduledMs,
fetchedAt: nowMs,
url: apiBaseUrl,
payload: nil,
etag: nil
),
]
}
}

View File

@@ -1,8 +1,7 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
# Static linkage helps isolate SQLCipher from Apple's system SQLite module/headers.
use_frameworks! :linkage => :static
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
@@ -29,92 +28,11 @@ target 'App' do
# Add your Pods here
end
def merge_sqlite_omit_load_extension_definition(config)
defs = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
if defs.nil?
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = ['$(inherited)', 'SQLITE_OMIT_LOAD_EXTENSION']
elsif defs.is_a?(Array)
unless defs.any? { |d| d.to_s.include?('SQLITE_OMIT_LOAD_EXTENSION') }
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = defs + ['SQLITE_OMIT_LOAD_EXTENSION']
end
else
s = defs.to_s
unless s.include?('SQLITE_OMIT_LOAD_EXTENSION')
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = "#{s} SQLITE_OMIT_LOAD_EXTENSION".squeeze(' ').strip
end
end
end
def strip_system_sqlite_from_pod_config(config)
bad_header = lambda do |path|
p = path.to_s
p.include?('/usr/include') || p.include?('/usr/local/include')
end
%w[HEADER_SEARCH_PATHS USER_HEADER_SEARCH_PATHS].each do |key|
paths = config.build_settings[key]
next unless paths
if paths.is_a?(Array)
config.build_settings[key] = paths.reject(&bad_header)
else
kept = paths.to_s.split(/\s+/).reject(&bad_header)
config.build_settings[key] = kept.join(' ')
end
end
%w[OTHER_LDFLAGS OTHER_LIBTOOLFLAGS].each do |key|
val = config.build_settings[key]
next unless val
if val.is_a?(Array)
config.build_settings[key] = val.reject do |x|
s = x.to_s
s.match?(/libsqlite3\.tbd/) || s == '-l"sqlite3"' || s.match?(/-l\s*sqlite3\b/)
end
else
s = val.to_s.gsub(/\s*-l"sqlite3"\s+/, ' ')
.gsub(/\s*-l\s*sqlite3\b/, ' ')
.gsub(/[^\s]*libsqlite3\.tbd[^\s]*/, ' ')
config.build_settings[key] = s.squeeze(' ').strip
end
end
end
post_install do |installer|
assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
merge_sqlite_omit_load_extension_definition(config)
strip_system_sqlite_from_pod_config(config)
end
end
end
# Aggregate Pods-App xcconfigs merge -l"sqlite3" from dependencies; that pulls in Apple's
# libsqlite3 alongside SQLCipher. Strip it after CocoaPods writes the files (post_install is too early).
# Also strip SQLCipher header-guard macros leaked into GCC_PREPROCESSOR_DEFINITIONS: Swift explicit
# modules build the SDK SQLite3.modulemap PCM with the same -D flags; _SQLITE3_H_=1 empties sqlite3.h
# and breaks sqlite3ext.h (unknown sqlite3_* types).
def strip_aggregate_pods_app_xcconfig(contents)
# Unlink system libsqlite3 (SQLCipher is the only SQLite).
patched = contents.gsub(/\s+-l"sqlite3"\s+/, ' ')
.gsub(/\s+-lsqlite3\b/, ' ')
# SQLCipher leaks sqlite3*.h guard macros into GCC_PREPROCESSOR_DEFINITIONS; Swift explicit
# modules must not inherit them when building the SDK SQLite3 module.
%w[_SQLITE3_H_=1 _FTS5_H=1 _SQLITE3RTREE_H_=1].each do |macro|
escaped = Regexp.escape(macro)
patched.gsub!(/(?:^|\s)-D#{escaped}(?=\s|$)/, ' ')
patched.gsub!(/(?:^|\s)#{escaped}(?=\s|$)/, ' ')
end
patched.gsub(/[ \t]+/, ' ')
end
post_integrate do |installer|
support = File.join(installer.sandbox.root, 'Target Support Files', 'Pods-App')
%w[Pods-App.debug.xcconfig Pods-App.release.xcconfig].each do |name|
path = File.join(support, name)
next unless File.exist?(path)
contents = File.read(path)
patched = strip_aggregate_pods_app_xcconfig(contents)
File.write(path, patched) if patched != contents
end
end

View File

@@ -81,14 +81,14 @@ PODS:
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- PromisesObjC (2.4.0)
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.10.0)
- SQLCipher/standard (4.10.0):
- SQLCipher (4.9.0):
- SQLCipher/standard (= 4.9.0)
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher/common
- TimesafariDailyNotificationPlugin (3.0.0):
- TimesafariDailyNotificationPlugin (2.0.0):
- Capacitor
- ZIPFoundation (0.9.20)
- ZIPFoundation (0.9.19)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -171,10 +171,10 @@ SPEC CHECKSUMS:
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
TimesafariDailyNotificationPlugin: 4a344236630d9209234d46a417d351ac9c27e1b0
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: bf247ff01f83709ef1010f328f5fb4ab5370cb41
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
COCOAPODS: 1.16.2

1379
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "timesafari",
"version": "1.4.1-beta",
"description": "Gift Economies Application",
"name": "giftopia",
"version": "1.3.14-beta",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
},
@@ -28,7 +28,7 @@
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -138,7 +138,7 @@
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "^6.0.2",
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",

View File

@@ -204,7 +204,7 @@ run_android() {
safe_execute "Launching app" "adb -s $device_id shell am start -n app.timesafari.app/app.timesafari.MainActivity"
else
log_info "Launching emulator and installing app"
safe_execute "Launching app" "npx cap run android --no-sync"
safe_execute "Launching app" "npx cap run android"
fi
}

View File

@@ -645,13 +645,11 @@ if [ "$BUILD_AAB" = true ]; then
fi
# Step 11: Auto-run app if requested
# cap run runs sync by default, which would overwrite capacitor.plugins.json again;
# we already synced and ran restore-local-plugins.js above, so skip sync here.
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android --no-sync" || {
safe_execute "Launching app" "npx cap run android" || {
log_error "Failed to launch Android app"
log_info "You can manually run with: npx cap run android --no-sync"
log_info "You can manually run with: npx cap run android"
exit 9
}
log_success "Android app launched successfully!"

View File

@@ -413,6 +413,7 @@ fi
# Handle assets-only mode
if [ "$ASSETS_ONLY" = true ]; then
log_info "Assets-only mode: generating assets"
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
log_success "Assets generation completed successfully!"
exit 0
@@ -562,6 +563,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
ensure_ios_capacitor_asset_directories
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 8: Build iOS app

View File

@@ -337,6 +337,27 @@ parse_args() {
fi
}
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
# folders and Contents.json are missing; the tool opens Contents.json before writing
# PNGs, so we create minimal asset-catalog stubs when absent.
ensure_ios_capacitor_asset_directories() {
local base="ios/App/App/Assets.xcassets"
if [ ! -d "$base" ]; then
log_warn "Missing $base — cannot prepare iOS asset directories"
return 0
fi
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
fi
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
fi
log_debug "Ensured iOS capacitor-assets output directories exist"
}
# Export functions for use in child scripts
export -f log_info log_success log_warn log_error log_debug log_step
export -f measure_time print_header print_footer
@@ -344,4 +365,5 @@ export -f check_command check_directory check_file
export -f safe_execute check_venv get_git_hash
export -f clean_build_artifacts validate_env_vars
export -f setup_build_env setup_app_directories load_env_file print_env_vars
export -f print_usage parse_args
export -f print_usage parse_args
export -f ensure_ios_capacitor_asset_directories

View File

@@ -360,7 +360,6 @@
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { Capacitor } from "@capacitor/core";
import { NotificationIface } from "./constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -383,24 +382,6 @@ export default class App extends Vue {
async turnOffNotifications(
notification: NotificationIface,
): Promise<boolean> {
// On native (iOS/Android) we don't use web push; the callback handles cancel + state in the view.
// The callback is the one passed for this specific modal (New Activity or Daily Reminder), so we only turn off that one.
if (Capacitor.isNativePlatform()) {
if (notification.callback) {
await notification.callback(true);
}
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.",
},
5000,
);
return true;
}
let subscription: PushSubscriptionJSON | null = null;
let allGoingOff = false;

View File

@@ -39,7 +39,7 @@ import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
* ProjectSelectionDialog - Dialog for selecting a project
*
* Features:
* - EntityGrid integration for project selection
@@ -52,7 +52,7 @@ import { NotificationIface } from "../constants/app";
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
export default class ProjectSelectionDialog extends Vue {
/** Whether the dialog is visible */
visible = false;

View File

@@ -67,7 +67,7 @@
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click="handleTurnOnNotifications"
>
{{ isDailyCheck ? "Turn on New Activity Notifications" : "Turn on Daily Reminder" }}
Turn on Daily Reminder
</button>
</div>
@@ -95,7 +95,6 @@ import {
NOTIFY_PUSH_PERMISSION_ERROR,
NOTIFY_PUSH_SETUP_UNDERWAY,
NOTIFY_PUSH_SUCCESS,
NOTIFY_PUSH_SUCCESS_NEW_ACTIVITY,
NOTIFY_PUSH_SETUP_ERROR,
NOTIFY_PUSH_SUBSCRIPTION_ERROR,
PUSH_NOTIFICATION_TIMEOUT_SHORT,
@@ -759,35 +758,17 @@ export default class PushNotificationPermission extends Vue {
time24h,
);
// Option A: For New Activity we do not schedule the single daily reminder here.
// AccountViewView's callback will call scheduleNewActivityDualNotification(timeText),
// which uses the dual schedule (prefetch + notify) only. This keeps the two notification
// types separate and avoids a second, uncancellable reminder.
if (this.pushType === this.DAILY_CHECK_TITLE) {
logger.info(
"[PushNotificationPermission] New Activity: skipping single reminder schedule; parent will schedule dual notification",
);
const timeText = this.notificationTimeText;
await this.$saveSettings({ notifyingNewActivityTime: timeText });
logger.debug(
"[PushNotificationPermission] Settings saved: notifyingNewActivityTime",
);
this.$notify(
{
group: "alert",
type: "success",
title: NOTIFY_PUSH_SUCCESS_NEW_ACTIVITY.title,
text: NOTIFY_PUSH_SUCCESS_NEW_ACTIVITY.message,
},
PUSH_NOTIFICATION_TIMEOUT_LONG,
);
this.callback(true, timeText, this.messageInput);
return;
}
// Determine title and body based on pushType
const title =
this.pushType === this.DAILY_CHECK_TITLE
? "Daily Check-In"
: "Daily Reminder";
const body =
this.pushType === this.DIRECT_PUSH_TITLE
? this.messageInput || this.notificationMessagePlaceholder
: "Time to check your TimeSafari activity";
// Daily Reminder: schedule the single daily notification (native only).
const title = "Daily Reminder";
const body = this.messageInput || this.notificationMessagePlaceholder;
// Schedule notification
logger.info(
"[PushNotificationPermission] Scheduling native notification:",
{

View File

@@ -6,8 +6,8 @@
export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Gift Economies",
APP_NAME_NO_SPACES = "GiftEconomies",
APP_NAME = "Giftopia",
APP_NAME_NO_SPACES = APP_NAME,
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",

View File

@@ -1,15 +0,0 @@
/**
* JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`).
* See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
*/
export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
export const BACKGROUND_JWT_EXPIRY_SECONDS =
BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60;
/** Headroom for retries / tests; pool size should be ≥ expiryDays + buffer. */
export const BACKGROUND_JWT_POOL_BUFFER = 10;
/** Distinct JWT strings minted per configure (duplicate-JWT / daily slot). */
export const BACKGROUND_JWT_POOL_SIZE =
BACKGROUND_JWT_EXPIRY_DAYS + BACKGROUND_JWT_POOL_BUFFER;

View File

@@ -1175,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
message: "",
};
export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR = {
title: "Error",
message: "There was a problem deleting the image.",
};
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
title: "Missing Identifier",
message: "You must select an identifier before you can record a give.",
@@ -1640,18 +1635,12 @@ export const NOTIFY_PUSH_SETUP_UNDERWAY = {
"Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
};
// Used in: PushNotificationPermission.vue (turnOnNotifications method - success, Daily Reminder)
// Used in: PushNotificationPermission.vue (turnOnNotifications method - success)
export const NOTIFY_PUSH_SUCCESS = {
title: "Notifications On",
message: "Daily Reminder notifications are now enabled.",
};
// Used in: PushNotificationPermission.vue (turnOnNotifications method - success, New Activity only)
export const NOTIFY_PUSH_SUCCESS_NEW_ACTIVITY = {
title: "Notifications On",
message: "New Activity notifications are now enabled.",
};
// Used in: PushNotificationPermission.vue (turnOnNotifications method - general error)
export const NOTIFY_PUSH_SETUP_ERROR = {
title: "Error Setting Notification Permissions",

View File

@@ -258,14 +258,13 @@ export async function logToDb(
try {
const platform = PlatformServiceFactory.getInstance();
const timestamp = new Date().toISOString();
const todayKey = new Date().toDateString();
try {
memoryLogs.push(`${timestamp} ${message}`);
memoryLogs.push(`${new Date().toISOString()} ${message}`);
// Insert using actual schema: date, message (no level column)
await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
timestamp,
todayKey, // Use date string to match schema
`[${level.toUpperCase()}] ${message}`, // Include level in message
]);
@@ -274,7 +273,7 @@ export async function logToDb(
if (!lastCleanupDate || lastCleanupDate !== todayKey) {
const sevenDaysAgo = new Date(
new Date().getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString();
).toDateString(); // Use date string to match schema
memoryLogs = memoryLogs.filter(
(log) => log.split(" ")[0] > sevenDaysAgo,
);

View File

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

View File

@@ -80,6 +80,7 @@ export interface PlanActionClaim extends ClaimObject {
agent?: { identifier: string };
description?: string;
endTime?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
identifier?: string;
image?: string;
lastClaimId?: string;

View File

@@ -4,10 +4,6 @@ import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
import {
BACKGROUND_JWT_EXPIRY_SECONDS,
BACKGROUND_JWT_POOL_SIZE,
} from "@/constants/backgroundJwt";
import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
@@ -108,45 +104,6 @@ export const accessToken = async (did?: string) => {
}
};
/**
* JWT for native New Activity prefetch (`configureNativeFetcher` / WorkManager).
* Uses a long `exp` (`BACKGROUND_JWT_EXPIRY_SECONDS`); do not use for ordinary
* in-app API calls — use `getHeaders` / `accessToken` instead.
*/
export const accessTokenForBackgroundNotifications = async (
did?: string,
): Promise<string> => {
if (!did) {
return "";
}
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
return createEndorserJwtForDid(did, tokenPayload);
};
/**
* Mint {@link BACKGROUND_JWT_POOL_SIZE} distinct JWTs for native background prefetch
* (`configureNativeFetcher` `jwtTokens`). Unique `jti` per slot; same `exp` for all.
*/
export async function mintBackgroundJwtTokenPool(
did: string,
): Promise<string[]> {
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
const tokens: string[] = [];
for (let i = 0; i < BACKGROUND_JWT_POOL_SIZE; i++) {
const tokenPayload = {
exp: endEpoch,
iat: nowEpoch,
iss: did,
jti: `${did}#bg#${i}`,
};
tokens.push(await createEndorserJwtForDid(did, tokenPayload));
}
return tokens;
}
/**
* Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT

View File

@@ -43,37 +43,10 @@ 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);
// Diagnostic: log DailyNotification methods from native PluginHeaders (helps debug UNIMPLEMENTED)
type CapacitorWindow = {
Capacitor?: {
PluginHeaders?: Array<{ name: string; methods?: Array<{ name: string }> }>;
};
};
const cap =
typeof window !== "undefined"
? (window as unknown as CapacitorWindow).Capacitor
: undefined;
if (cap?.PluginHeaders) {
const dn = cap.PluginHeaders.find((h) => h.name === "DailyNotification");
const methodNames = dn?.methods?.map((m) => m.name) ?? null;
logger.log(
"[Capacitor] DNP PluginHeaders methods:",
methodNames ?? "DailyNotification NOT IN HEADERS",
);
if (methodNames && !methodNames.includes("scheduleDualNotification")) {
logger.warn(
"[Capacitor] scheduleDualNotification missing from PluginHeaders native plugin may be stale; try clearing Xcode DerivedData and rebuilding",
);
}
} else {
logger.warn("[Capacitor] Capacitor.PluginHeaders not present");
}
const app = initializeApp();
// Initialize API error handling for unhandled promise rejections
@@ -459,14 +432,11 @@ if (
if (isActive) {
logger.debug("[Main] 📱 App became active, checking for shared image");
await checkForSharedImageAndNavigate();
// Refresh JWT for background New Activity prefetch (WorkManager cannot run JS;
// short-lived tokens would expire between configure and T5 fetch without this).
await configureNativeFetcherIfReady();
}
});
}
// Register deeplink listener and configure native notification fetcher after app is mounted
// Register deeplink listener after app is mounted
setTimeout(async () => {
try {
logger.info(
@@ -474,8 +444,6 @@ 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);
}

View File

@@ -136,6 +136,11 @@ const routes: Array<RouteRecordRaw> = [
name: "help-onboarding",
component: () => import("../views/HelpOnboardingView.vue"),
},
{
path: "/help-terms",
name: "help-terms",
component: () => import("../views/HelpTermsView.vue"),
},
{
path: "/",
name: "home",

View File

@@ -13,7 +13,6 @@
import { Capacitor } from "@capacitor/core";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
/**
* Extended type for DailyNotification that includes the actual Swift implementation
@@ -45,10 +44,10 @@ export class NativeNotificationService implements NotificationServiceInterface {
private readonly platformName = "native";
/**
* Stable schedule/reminder ID for the Daily Reminder feature only.
* New Activity uses the dual schedule (scheduleDualNotification) and does not use this ID.
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
*/
private readonly reminderId = REMINDER_ID_DAILY_REMINDER;
private readonly reminderId = "daily_timesafari_reminder";
/**
* Ensures only one scheduleDailyNotification runs at a time (no rapid successive plugin calls).

View File

@@ -1,84 +0,0 @@
/**
* Builds DualScheduleConfiguration for the Daily Notification plugin.
* Used for API-driven "New Activity" notifications (prefetch + notify).
*/
import type { DualScheduleConfiguration } from "@timesafari/daily-notification-plugin";
/** Matches `plugins.DailyNotification.networkConfig` in capacitor.config.ts */
const CONTENT_FETCH_NETWORK = {
timeout: 30_000,
retryAttempts: 3,
retryDelay: 1_000,
} as const;
/**
* Convert "HH:mm" (24h) to cron expression "minute hour * * *" (daily at that time).
*/
export function timeToCron(timeHHmm: string): string {
const [h, m] = timeHHmm.split(":").map(Number);
const hour = Math.max(0, Math.min(23, h ?? 0));
const minute = Math.max(0, Math.min(59, m ?? 0));
return `${minute} ${hour} * * *`;
}
/**
* Cron for 5 minutes before the given "HH:mm" (so prefetch runs before the notification).
*/
export function timeToCronFiveMinutesBefore(timeHHmm: string): string {
const [h, m] = timeHHmm.split(":").map(Number);
let hour = Math.max(0, Math.min(23, h ?? 0));
let minute = Math.max(0, Math.min(59, m ?? 0));
minute -= 5;
if (minute < 0) {
minute += 60;
hour -= 1;
if (hour < 0) hour += 24;
}
return `${minute} ${hour} * * *`;
}
export interface DualScheduleConfigInput {
/** Time in HH:mm (24h) for the user notification */
notifyTime: string;
/** Optional title; default "New Activity" */
title?: string;
/** Optional body; default describes API-driven content */
body?: string;
}
/**
* Build plugin DualScheduleConfiguration for scheduleDualNotification().
* contentFetch runs 5 minutes before notifyTime; userNotification at notifyTime.
*/
export function buildDualScheduleConfig(
input: DualScheduleConfigInput,
): DualScheduleConfiguration {
const notifyTime = input.notifyTime || "09:00";
const fetchCron = timeToCronFiveMinutesBefore(notifyTime);
const notifyCron = timeToCron(notifyTime);
return {
contentFetch: {
enabled: true,
schedule: fetchCron,
timeout: CONTENT_FETCH_NETWORK.timeout,
retryAttempts: CONTENT_FETCH_NETWORK.retryAttempts,
retryDelay: CONTENT_FETCH_NETWORK.retryDelay,
callbacks: {},
},
userNotification: {
enabled: true,
schedule: notifyCron,
title: input.title ?? "New Activity",
body: input.body ?? "Check your starred projects and offers for updates.",
sound: true,
vibration: true,
priority: "normal",
},
relationship: {
autoLink: true,
contentTimeout: 5 * 60 * 1000, // 5 minutes
fallbackBehavior: "show_default",
},
};
}

View File

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

View File

@@ -1,98 +0,0 @@
/**
* 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 { mintBackgroundJwtTokenPool } from "@/libs/crypto";
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 jwtTokens = await mintBackgroundJwtTokenPool(did);
const jwtToken = jwtTokens[0] ?? "";
if (!jwtToken) {
logger.warn(
"[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail",
);
}
if (!DailyNotification?.configureNativeFetcher) {
logger.warn(
"[nativeFetcherConfig] Plugin configureNativeFetcher not available",
);
return false;
}
await DailyNotification.configureNativeFetcher({
apiBaseUrl:
apiBaseUrl?.trim().replace(/\/$/, "") ?? DEFAULT_ENDORSER_API_SERVER,
activeDid: did,
jwtToken,
jwtTokens,
});
logger.info(
"[nativeFetcherConfig] Native fetcher configured (JWT pool size=" +
jwtTokens.length +
")",
);
return true;
} catch (error) {
logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error);
return false;
}
}

View File

@@ -1,13 +0,0 @@
/**
* Stable reminder/schedule IDs for native daily notifications.
* Keeps Daily Reminder and New Activity distinct so we can support both on
* and cancel only one. New Activity uses the dual schedule (scheduleDualNotification)
* only; this ID is for reference/future use (e.g. if we ever add a single-reminder
* fallback for New Activity).
*/
/** ID for the single daily reminder (Daily Reminder feature). Used by NativeNotificationService. */
export const REMINDER_ID_DAILY_REMINDER = "daily_timesafari_reminder";
/** ID for New Activity. Not used for scheduling (we use dual schedule only); kept for clarity and future use. */
export const REMINDER_ID_NEW_ACTIVITY = "new_activity_timesafari";

View File

@@ -1,30 +0,0 @@
import { Capacitor } from "@capacitor/core";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { logger } from "@/utils/logger";
/**
* Pushes starred plan handle IDs to the native Daily Notification plugin so
* Android TimeSafariNativeFetcher uses the current list for prefetch
* (plansLastUpdatedBetween planIds).
*
* No-op on web. Ignores UNIMPLEMENTED when the plugin omits the method on some builds.
*/
export async function syncStarredPlansToNativePlugin(
planIds: string[],
): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
try {
await DailyNotification.updateStarredPlans({ planIds });
} catch (e: unknown) {
if ((e as { code?: string })?.code === "UNIMPLEMENTED") {
return;
}
logger.warn(
"[syncStarredPlansToNativePlugin] updateStarredPlans failed",
e,
);
}
}

View File

@@ -164,10 +164,10 @@ async function logToDatabase(
try {
const platform = PlatformServiceFactory.getInstance();
const timestamp = new Date().toISOString();
const todayKey = new Date().toDateString();
await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
timestamp,
todayKey,
`[${level.toUpperCase()}] ${message}`,
]);
} catch (error) {

View File

@@ -139,61 +139,41 @@
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editReminderNotification"
>
Edit Daily Reminder
Edit Notification Details
</button>
</div>
</div>
<div class="flex items-center justify-between mt-4 mb-2">
<div v-if="false" class="mt-4 flex items-center justify-between">
<!-- label -->
<div>
New Activity Notification
<button
<font-awesome
icon="question-circle"
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about New Activity notifications"
@click.stop="showNewActivityNotificationInfo"
>
<font-awesome icon="question-circle" aria-hidden="true" />
</button>
/>
</div>
<!-- toggle -->
<div
class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingNewActivity"
aria-label="Toggle New Activity notifications"
tabindex="0"
@click.stop.prevent="showNewActivityNotificationChoice()"
@click="showNewActivityNotificationChoice()"
>
<!-- input -->
<input
:checked="notifyingNewActivity"
v-model="notifyingNewActivity"
type="checkbox"
class="sr-only"
readonly
@click.stop.prevent
@change.stop.prevent
/>
<!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot -->
<div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div>
</div>
</div>
<div v-if="notifyingNewActivity" class="w-full">
<div
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
>
<div>
<b>Time:</b> {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
</div>
<div class="mt-2 text-center">
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editNewActivityNotification"
>
Edit New Activity Notification
</button>
</div>
<div v-if="notifyingNewActivityTime" class="w-full text-right">
{{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div>
<div class="mt-2 text-center">
<router-link class="text-sm text-blue-500" to="/help-notifications">
@@ -313,16 +293,12 @@
<h3
data-testid="advancedSettings"
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
@click="toggleShowGeneralAdvanced"
@click="showAdvanced = !showAdvanced"
>
{{
showGeneralAdvanced
? "Hide Advanced Settings"
: "Show Advanced Settings"
}}
{{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
</h3>
<section
v-if="showGeneralAdvanced"
v-if="showAdvanced"
id="sectionAdvanced"
aria-labelledby="advancedHeading"
>
@@ -827,13 +803,7 @@ 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,
configureNativeFetcherIfReady,
buildDualScheduleConfig,
syncStarredPlansToNativePlugin,
} from "@/services/notifications";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { NotificationService } from "@/services/notifications";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -1121,19 +1091,12 @@ export default class AccountViewView extends Vue {
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showAdvanced = this.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;
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) {
const planIds = settings?.starredPlanHandleIds ?? [];
void syncStarredPlansToNativePlugin(planIds);
}
}
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds
@@ -1227,24 +1190,11 @@ 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: "",
});
@@ -1255,100 +1205,6 @@ export default class AccountViewView extends Vue {
}
}
/**
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
*/
async scheduleNewActivityDualNotification(notifyTime: string): Promise<void> {
const plugin = DailyNotification as unknown as {
scheduleDualNotification?: (opts: { config: unknown }) => Promise<void>;
};
if (!plugin.scheduleDualNotification) {
logger.warn(
"[AccountViewView] scheduleDualNotification not available on this device",
);
this.notify.error(
"New Activity scheduling is not available on this device. Please update the app.",
TIMEOUTS.STANDARD,
);
return;
}
try {
const time24h = this.parseTimeTo24Hour(notifyTime);
await configureNativeFetcherIfReady(this.activeDid);
const settings = await this.$accountSettings();
const planIds = settings?.starredPlanHandleIds ?? [];
await syncStarredPlansToNativePlugin(planIds);
const config = buildDualScheduleConfig({ notifyTime: time24h });
// Diagnostic: log what Capacitor sees at call time (helps debug UNIMPLEMENTED)
const cap = (typeof window !== "undefined" &&
(
window as unknown as {
Capacitor?: {
PluginHeaders?: Array<{
name: string;
methods?: Array<{ name: string }>;
}>;
};
}
).Capacitor) as
| {
PluginHeaders?: Array<{
name: string;
methods?: Array<{ name: string }>;
}>;
}
| undefined;
const dnHeader = cap?.PluginHeaders?.find(
(h) => h.name === "DailyNotification",
);
const methodsAtCall = dnHeader?.methods?.map((m) => m.name) ?? null;
logger.warn(
"[AccountViewView] Before scheduleDualNotification, PluginHeaders methods:",
methodsAtCall ?? "DailyNotification not in headers",
);
if (
methodsAtCall &&
!methodsAtCall.includes("scheduleDualNotification")
) {
logger.warn(
"[AccountViewView] scheduleDualNotification missing from PluginHeaders at call time bridge may be stale for this run",
);
}
await plugin.scheduleDualNotification!({ config });
} catch (error) {
logger.error(
"[AccountViewView] scheduleNewActivityDualNotification failed:",
error,
);
const err = error as {
code?: string;
errorMessage?: string;
message?: string;
};
const code = err?.code;
const msg = err?.errorMessage ?? err?.message ?? "";
if (code === "UNIMPLEMENTED") {
this.notify.error(
"New Activity scheduling is not yet available on this device. Please update the app when support is added.",
TIMEOUTS.STANDARD,
);
} else if (
msg.includes("BGTaskSchedulerErrorDomain") ||
msg.includes("error 1")
) {
this.notify.error(
"New Activity scheduling needs a real device and Background App Refresh enabled. It does not work in Simulator.",
TIMEOUTS.STANDARD,
);
} else {
this.notify.error(
"Could not schedule New Activity notification. Please try again.",
TIMEOUTS.STANDARD,
);
}
}
}
async showReminderNotificationInfo(): Promise<void> {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
@@ -1546,91 +1402,6 @@ export default class AccountViewView extends Vue {
}, 150);
}
/**
* Edit existing New Activity notification time.
* Opens the dialog with current time; on success updates dual schedule via
* updateDualScheduleConfig when available (plugin v2.1.0+), else scheduleDualNotification.
*/
async editNewActivityNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
dialog.open(
DAILY_CHECK_TITLE,
async (success: boolean, timeText: string) => {
if (!success) return;
if (Capacitor.isNativePlatform()) {
const time24h = this.parseTimeTo24Hour(timeText);
const config = buildDualScheduleConfig({ notifyTime: time24h });
const plugin = DailyNotification as unknown as {
updateDualScheduleConfig?: (opts: {
config: unknown;
}) => Promise<void>;
scheduleDualNotification?: (opts: {
config: unknown;
}) => Promise<void>;
};
try {
if (plugin.updateDualScheduleConfig) {
await plugin.updateDualScheduleConfig({ config });
} else {
await this.scheduleNewActivityDualNotification(timeText);
}
} catch (e) {
logger.warn(
"[AccountViewView] updateDualScheduleConfig failed, falling back to scheduleDualNotification:",
e,
);
try {
await this.scheduleNewActivityDualNotification(timeText);
} catch (fallbackError) {
logger.error(
"[AccountViewView] editNewActivityNotification schedule failed:",
fallbackError,
);
this.notify.error(
"Could not update New Activity time. Please try again.",
TIMEOUTS.STANDARD,
);
return;
}
}
}
await this.$saveSettings({ notifyingNewActivityTime: timeText });
this.notifyingNewActivityTime = timeText;
this.notify.success(
"New Activity notification time updated.",
TIMEOUTS.STANDARD,
);
},
{ skipSchedule: true },
);
// Pre-populate the dialog with current New Activity time
setTimeout(() => {
const timeMatch = this.notifyingNewActivityTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
}
}
}, 150);
}
/**
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
* if reminder is already on, reschedules so the plugin uses the new interval.

View File

@@ -398,7 +398,165 @@
</div>
</div>
<!--
<!--
Network Connections section: shows nearest neighbors in the registration
graph for all DIDs in this claim. The same conventions and styling are used
in UserProfileView.vue for user-profile nearest neighbors. Keep changes in sync.
-->
<div v-if="activeDid && hasVisibleNeighbors" class="mt-8">
<h2 class="text-lg font-semibold mb-3">
Network Connections
<button
title="What is this?"
class="ml-1 align-middle"
@click="showNeighborsInfo = true"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500 cursor-pointer"
/>
</button>
</h2>
<!-- Info modal for network connections explanation -->
<div
v-if="showNeighborsInfo"
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
@click.self="showNeighborsInfo = false"
>
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
<p class="text-sm text-slate-700">
This section shows
{{
Object.values(claimNeighbors).flat().length === 1
? "a contact that is"
: "contacts that are"
}}
nearer to the people involved in this activity. If you want more
information, reach out to one of them and ask for an introduction.
</p>
<button
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
@click="showNeighborsInfo = false"
>
Got it
</button>
</div>
</div>
<div v-if="loadingNeighbors">
<div class="flex justify-center items-center py-8">
<font-awesome
icon="spinner"
class="fa-spin-pulse text-2xl text-slate-400"
/>
</div>
</div>
<div
v-else-if="neighborsError"
class="bg-red-50 border border-red-300 rounded-md p-4"
>
<div class="flex items-start gap-2">
<font-awesome
icon="exclamation-triangle"
class="text-red-500 mt-0.5"
/>
<p class="text-red-700">{{ neighborsError }}</p>
</div>
</div>
<div v-else-if="Object.keys(claimNeighbors).length > 0">
<div v-for="(neighbors, did) in claimNeighbors" :key="did" class="mb-4">
<h3
v-if="Object.keys(claimNeighbors).length > 1"
class="text-sm font-medium text-slate-600 mb-1"
>
Near {{ didInfo(did as string) }}:
</h3>
<!-- DID has no linked neighbors on this server -->
<p
v-if="neighbors.length === 0"
class="text-sm text-slate-500 italic"
>
Nobody on this server is linked to {{ didInfo(did as string) }}. The
data may be a mistake, or a test, or a reference to someone on a
different system. Anyway, we have no way to contact them.
</p>
<div v-else class="space-y-2">
<div
v-for="neighbor in neighbors"
:key="neighbor.did"
class="bg-slate-50 border border-slate-300 rounded-md"
>
<div class="flex items-center justify-between gap-3 p-3">
<div class="flex items-center gap-2 flex-1 min-w-0">
<button
title="Copy claim link and expand"
class="text-blue-600 flex-shrink-0"
@click="onNeighborExpandClick(neighbor.did)"
>
<font-awesome
:icon="
expandedNeighborDid === neighbor.did
? 'chevron-down'
: 'chevron-right'
"
class="text-sm"
/>
{{ getNeighborDisplayName(neighbor.did) }}
</button>
<span :class="getRelationBadgeClass(neighbor.relation)">
{{ getRelationLabel(neighbor.relation) }}
</span>
</div>
</div>
<div
v-if="expandedNeighborDid === neighbor.did"
class="border-t border-slate-300 p-3 bg-white"
>
<router-link
:to="{ path: '/did/' + encodeURIComponent(neighbor.did) }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the claim link from your clipboard. Ask them for
an introduction.
<div
v-if="neighborIsNotInContacts(neighbor.did)"
class="flex flex-col gap-1 mt-2"
>
<p class="text-xs text-slate-600">
This person is connected to you, but they are not in this
device's contacts. Copy this DID link and check on another
device or check with different people.
</p>
<span class="flex items-center gap-1 min-w-0">
<span class="text-xs truncate text-slate-600 min-w-0">
{{ neighbor.did }}
</span>
<button
title="Copy DID Link"
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
@click.prevent="
copyTextToClipboard(
'DID link',
`${APP_SERVER}/deep-link/did/${encodeURIComponent(neighbor.did)}`,
)
"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
-->
<h2
@@ -631,12 +789,19 @@ export default class ClaimView extends Vue {
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
providersForGive: ProviderInfo[] = [];
showIdCopy = false;
showNeighborsInfo = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowDeepLink = window.location.href; // changed in the setup for deep linking
// Network Connections state (same pattern as UserProfileView.vue)
claimNeighbors: Record<string, Array<{ did: string; relation: string }>> = {};
expandedNeighborDid: string | null = null;
loadingNeighbors = false;
neighborsError = "";
APP_SERVER = APP_SERVER;
R = R;
yaml = yaml;
@@ -745,6 +910,16 @@ export default class ClaimView extends Vue {
return (claim as { image?: string })?.image;
}
/**
* Whether the Network Connections section should be shown.
* Hidden if the only DIDs in claimNeighbors are the active user,
* or if there are no entries at all (after filtering).
*/
get hasVisibleNeighbors(): boolean {
const keys = Object.keys(this.claimNeighbors);
return keys.length > 0 || this.loadingNeighbors;
}
resetThisValues() {
this.confirmerIdList = [];
this.confsVisibleErrorMessage = "";
@@ -759,6 +934,10 @@ export default class ClaimView extends Vue {
this.isEditedGlobalId = false;
this.numConfsNotVisible = 0;
this.providersForGive = [];
this.claimNeighbors = {};
this.expandedNeighborDid = null;
this.loadingNeighbors = false;
this.neighborsError = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
this.veriClaimDidsVisible = {};
@@ -825,6 +1004,7 @@ export default class ClaimView extends Vue {
const claimId = this.$route.params.id as string;
if (claimId) {
await this.loadClaim(claimId, this.activeDid);
await this.loadClaimNeighbors();
} else {
this.notify.error("No claim ID was provided.");
}
@@ -1000,6 +1180,125 @@ export default class ClaimView extends Vue {
}
}
/**
* Loads nearest neighbors for all DIDs in this claim via the
* endorser-ch claimNearestNeighbors endpoint.
* Same display conventions as UserProfileView.vue's loadNeighbors.
*/
async loadClaimNeighbors() {
if (!this.veriClaim.id) return;
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const url =
this.apiServer +
"/api/claim/claimNearestNeighbors/" +
encodeURIComponent(this.veriClaim.id as string);
const headers = await serverUtil.getHeaders(this.activeDid);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200) {
const raw = resp.data.data || {};
// Filter out the current user's own DID entry — their neighbors
// aren't useful here since "You" is already known.
const filtered: Record<
string,
Array<{ did: string; relation: string }>
> = {};
for (const [did, neighbors] of Object.entries(raw)) {
if (did === this.activeDid) continue;
filtered[did] = neighbors as Array<{
did: string;
relation: string;
}>;
}
this.claimNeighbors = filtered;
} else {
this.claimNeighbors = {};
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
await this.$logError(
"Error loading claim neighbors: " + JSON.stringify(error),
);
this.claimNeighbors = {};
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Gets display name for a neighbor DID (same as UserProfileView.vue)
*/
getNeighborDisplayName(did: string): string {
return serverUtil.didInfo(
did,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
neighborIsNotInContacts(did: string): boolean {
return !this.allContacts.some((contact) => contact.did === did);
}
/**
* Gets human-readable label for relation type (same as UserProfileView.vue)
*/
getRelationLabel(relation: string): string {
switch (relation) {
case "REGISTERED_BY_YOU":
return "Registered by You";
case "REGISTERED_YOU":
return "Registered You";
case "TARGET":
return "Yourself";
default:
return relation;
}
}
/**
* Gets CSS classes for relation badge styling (same as UserProfileView.vue)
*/
getRelationBadgeClass(relation: string): string {
const baseClasses =
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
switch (relation) {
case "REGISTERED_BY_YOU":
return `${baseClasses} bg-blue-100 text-blue-700`;
case "REGISTERED_YOU":
return `${baseClasses} bg-green-100 text-green-700`;
case "TARGET":
return `${baseClasses} bg-purple-100 text-purple-700`;
default:
return `${baseClasses} bg-slate-100 text-slate-700`;
}
}
/**
* Handles clicking expand on a neighbor - copies claim link and toggles
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
return;
}
try {
await copyToClipboard(this.windowDeepLink);
this.notify.copied("Claim link");
} catch (error) {
this.$logAndConsole(`Error copying claim link: ${error}`, true);
this.notify.error("Failed to copy claim link.");
}
this.expandedNeighborDid = did;
}
async showFullClaim(claimId: string) {
const url =
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
@@ -1110,6 +1409,7 @@ export default class ClaimView extends Vue {
(this.$router as Router).push(route).then(async () => {
this.resetThisValues();
await this.loadClaim(claimId, this.activeDid);
await this.loadClaimNeighbors();
});
}

View File

@@ -118,11 +118,13 @@ import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { retrieveAccountMetadata } from "../libs/util";
import { AxiosError } from "axios";
import { Account } from "@/db/tables/accounts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import {
@@ -139,7 +141,10 @@ import {
NOTIFY_QR_URL_COPIED,
NOTIFY_QR_CODE_HELP,
NOTIFY_QR_DID_COPIED,
NOTIFY_QR_REGISTRATION_SUBMITTED,
NOTIFY_QR_REGISTRATION_ERROR,
createQRContactAddedMessage,
createQRRegistrationSuccessMessage,
QR_TIMEOUT_MEDIUM,
QR_TIMEOUT_STANDARD,
QR_TIMEOUT_LONG,
@@ -204,6 +209,7 @@ export default class ContactQRScanFull extends Vue {
activeDid = "";
apiServer = "";
givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
profileImageUrl = "";
qrValue = "";
@@ -278,6 +284,8 @@ export default class ContactQRScanFull extends Vue {
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
@@ -575,6 +583,34 @@ export default class ContactQRScanFull extends Vue {
createQRContactAddedMessage(!!this.activeDid),
QR_TIMEOUT_STANDARD,
);
if (
this.isRegistered &&
!this.hideRegisterPromptOnNewContact &&
!contact.registered
) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onNo: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
},
onYes: async () => {
await this.register(contact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
} catch (error) {
logger.error("Error saving contact to database:", {
did: contact.did,
@@ -585,6 +621,74 @@ export default class ContactQRScanFull extends Vue {
}
}
async register(contact: Contact) {
logger.debug("Submitting contact registration", {
did: contact.did,
name: contact.name,
});
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message);
if (regResult.success) {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
logger.debug("Contact registration successful", { did: contact.did });
this.notify.success(
createQRRegistrationSuccessMessage(contact.name || ""),
QR_TIMEOUT_LONG,
);
} else {
this.notify.error(
(regResult.error as string) || NOTIFY_QR_REGISTRATION_ERROR.message,
QR_TIMEOUT_LONG,
);
}
} catch (error) {
logger.error("Error registering contact:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"message" in serverError.response.data
) {
userMessage = (serverError.response.data as { message: string })
.message;
} else if (serverError.message) {
userMessage = serverError.message;
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
this.notify.error(userMessage, QR_TIMEOUT_LONG);
}
}
private async handleRegistrationPromptResponse(
stopAsking?: boolean,
): Promise<void> {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
}
/**
* Vue lifecycle hook - component mounted
* Sets up event listeners and starts scanning automatically

View File

@@ -897,7 +897,7 @@ export default class DiscoverView extends Vue {
public computedStarredTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"py-2": true,
"rounded-t-lg": true,
"border-b-2": true,

View File

@@ -263,7 +263,6 @@ import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
@@ -302,6 +301,7 @@ export default class GiftedDetails extends Vue {
giverName = "";
hideBackButton = false;
imageUrl = "";
imageUrlToDelete = "";
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
@@ -517,7 +517,10 @@ export default class GiftedDetails extends Vue {
}
cancel() {
this.deleteImage(); // not awaiting, so they'll go back immediately
// Only delete freshly uploaded images, not ones from an existing claim
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
}
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });
} else {
@@ -526,7 +529,10 @@ export default class GiftedDetails extends Vue {
}
cancelBack() {
this.deleteImage(); // not awaiting, so they'll go back immediately
// Only delete freshly uploaded images, not ones from an existing claim
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
}
(this.$router as Router).back();
}
@@ -539,13 +545,18 @@ export default class GiftedDetails extends Vue {
confirmDeleteImage() {
this.notify.confirm(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM.message,
this.deleteImage,
() => {
// Stage the image for deletion on submit rather than deleting immediately,
// so that canceling the edit doesn't destroy the referenced image.
this.imageUrlToDelete = this.imageUrl;
this.imageUrl = "";
},
TIMEOUTS.LONG,
);
}
async deleteImage() {
if (!this.imageUrl) {
async deleteImage(imageUrl: string) {
if (!imageUrl) {
return;
}
try {
@@ -559,38 +570,21 @@ export default class GiftedDetails extends Vue {
);
}
const response = await this.axios.delete(
DEFAULT_IMAGE_API_SERVER +
"/image/" +
encodeURIComponent(this.imageUrl),
DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(imageUrl),
{ headers },
);
if (response.status === 204) {
// don't bother with a notification
// (either they'll simply continue or they're canceling and going back)
} else {
logger.error("Problem deleting image:", response);
this.notify.error(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
TIMEOUTS.LONG,
);
return;
}
this.imageUrl = "";
} catch (error) {
logger.error("Error deleting image:", error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((error as any)?.response?.status === 404) {
logger.log("Weird: the image was already deleted.", error);
this.imageUrl = "";
// it already doesn't exist so we won't say anything to the user
logger.log("Image was already deleted:", error);
} else {
this.notify.error(
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
TIMEOUTS.LONG,
);
logger.error("Failed to delete image from server:", error);
}
}
}
@@ -733,6 +727,12 @@ export default class GiftedDetails extends Vue {
TIMEOUTS.LONG,
);
} else {
// Delete the old image from storage now that the edit is saved
if (this.imageUrlToDelete) {
this.deleteImage(this.imageUrlToDelete); // not awaiting
this.imageUrlToDelete = "";
}
this.notify.success(
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
TIMEOUTS.SHORT,

View File

@@ -32,6 +32,9 @@
>
<h3>Troubleshooting Notifications</h3>
Note that the notifications will not arrive exactly at the time you set
(because phones don't let non-alarm-apps set exact alarms).
<h4>Check your in-app notification settings</h4>
<ul>
<li>Tap <strong>Profile</strong> in the bottom bar</li>

144
src/views/HelpTermsView.vue Normal file
View File

@@ -0,0 +1,144 @@
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Terms & Conditions and Privacy Policies
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</div>
<!-- eslint-disable prettier/prettier -->
<div>
<p style="display:inline; align-items: center">
This work is public domain. (If you like rules, reference
<a
href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1"
target="_blank"
rel="license noopener noreferrer"
>
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"
/>
</a>
)
</p>
<p class="mt-4">
This is offered freely, with the hope that it helps but without any
warranty or guarantee. When you share data or even look at information here,
you accept the risk that goes with those activities. In other words,
if you expect some functionality or you expect some protection, and you
feel it is appropriate to force those expectations on the system or its
operators or creators, then you are not allowed to use it.
</p>
<p class="mt-4">
Here is how your data is used:
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing
each claim and deleting the image.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key
and message data are stored on a server. Those can be removed via
direct personal request (email
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
{{ SUPPORT_EMAIL }}
</a>).
</li>
<li>
For all other claim data,
<a
href="https://endorser.ch/privacy-policy"
target="_blank"
class="text-blue-500"
>
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
<p class="mt-4">
<!--
This section is for Twilio's A2P Campaign requirements.
They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold).
They link here for a sample: https://help.twilio.com/articles/223134847-Industry-standards-for-US-Short-Code-Terms-of-Service
-->
Here are the details for SMS notifications:
<ul class="list-disc list-outside ml-4">
<li>You may opt to receive SMS messages for two purposes:
<ul class="list-disc list-outside ml-4">
<li>A daily reminder message</li>
<li>A notification of new activity for items that you are watching</li>
</ul>
</li>
<li>
Before enabling these notifications, you must register your phone number and give permission to use it for searches.
</li>
<li>
Once your phone number is registered and linked to your DID, you can enable or disable either kind of SMS message.
You can disable these any time with the same toggle.
</li>
<li>
If you lose your credentials, you can register your phone with a different DID.
Then you can enable and disable notifications for your phone.
</li>
<li>
Carriers are not liable for delayed or undelivered messages.
</li>
<li>
As always, message and data rates may apply for any messages sent to you from us and to us from you.
You will receive at most one of each kind of message per day.
If you have any questions about your text plan or data plan, it is best to contact your wireless provider.
</li>
<li>
Our servers will only store your phone number and the type of notifications you have enabled,
along with the explicit signed permission to use it for searches.
</li>
</ul>
</p>
</div>
<!-- eslint-enable -->
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import QuickNav from "../components/QuickNav.vue";
import { SUPPORT_EMAIL } from "../constants/app";
@Component({ components: { QuickNav } })
export default class HelpTermsView extends Vue {
SUPPORT_EMAIL = SUPPORT_EMAIL;
}
</script>

View File

@@ -480,46 +480,14 @@
</p>
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
<p style="display:inline; align-items: center">
This work is public domain. (If you like rules, reference
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"
/>
</a>
.) This is offered freely, with the hope that it helps but without any warranty or guarantee;
if it helps you then enjoy using it,
but if you may try to forcibly collect damages for things you think it should do (or not do)
then don't use it.
<br />
As for data & privacy:
<p>
<router-link
class="text-blue-500"
:to="{ name: 'help-terms' }"
>
Read them here.
</router-link>
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing each claim
and deleting the image.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request (via contact below).
</li>
<li>
For all other claim data,
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
the Endorser Service has this Privacy Policy.
</a>
</li>
</ul>
<h2 class="text-xl font-semibold">How can I contribute?</h2>
<p>

View File

@@ -114,6 +114,49 @@
@assign="handleRepresentativeAssigned"
/>
<!-- Parent Project Selection -->
<div class="w-full flex items-stretch my-4">
<div
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
@click="openParentProjectDialog"
>
<div>
<font-awesome icon="folder" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': parentProjectHandleId,
'text-slate-400': !parentProjectHandleId,
}"
class="truncate"
>
{{
parentProjectHandleId
? parentProjectName || "Parent Project"
: "Select Parent Project\u2026"
}}
</div>
</div>
</div>
<button
v-if="parentProjectHandleId"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetParentProject"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectSelectionDialog
ref="parentProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleParentProjectSelected"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
@@ -283,6 +326,7 @@ import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
AppString,
@@ -311,6 +355,7 @@ import {
PROJECT_TIMEOUT_VERY_LONG,
} from "../constants/notifications";
import { PlanActionClaim } from "../interfaces/claims";
import { PlanData } from "../interfaces/records";
import {
createEndorserJwtVcFromClaim,
getHeaders,
@@ -378,6 +423,7 @@ import { logger } from "../utils/logger";
components: {
EntityIcon,
ImageMethodDialog,
ProjectSelectionDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
@@ -429,6 +475,8 @@ export default class NewEditProjectView extends Vue {
latitude = 0;
longitude = 0;
numAccounts = 0;
parentProjectHandleId = "";
parentProjectName = "";
projectId = "";
projectIssuerDid = "";
sendToTrustroots = false;
@@ -510,6 +558,10 @@ export default class NewEditProjectView extends Vue {
);
}
}
if (this.fullClaim?.fulfills?.identifier) {
this.parentProjectHandleId = this.fullClaim.fulfills.identifier;
this.loadParentProjectName(this.parentProjectHandleId);
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.startTime as string,
@@ -623,6 +675,14 @@ export default class NewEditProjectView extends Vue {
} else {
delete vcClaim.agent;
}
if (this.parentProjectHandleId) {
vcClaim.fulfills = {
"@type": "PlanAction",
identifier: this.parentProjectHandleId,
};
} else {
delete vcClaim.fulfills;
}
if (this.imageUrl) {
vcClaim.image = this.imageUrl;
} else {
@@ -1075,5 +1135,33 @@ export default class NewEditProjectView extends Vue {
unsetRepresentative(): void {
this.agentDid = "";
}
openParentProjectDialog(): void {
(this.$refs.parentProjectDialog as ProjectSelectionDialog).open();
}
handleParentProjectSelected(project: PlanData): void {
this.parentProjectHandleId = project.handleId;
this.parentProjectName = project.name;
}
unsetParentProject(): void {
this.parentProjectHandleId = "";
this.parentProjectName = "";
}
private async loadParentProjectName(handleId: string): Promise<void> {
try {
const url =
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(handleId);
const headers = await getHeaders(this.activeDid);
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data?.claim?.name) {
this.parentProjectName = resp.data.claim.name;
}
} catch {
// Parent project name will remain empty
}
}
}
</script>

View File

@@ -267,7 +267,7 @@
</form>
</div>
<MeetingProjectDialog
<ProjectSelectionDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -585,7 +585,7 @@ import TopMessage from "../components/TopMessage.vue";
import MeetingMembersList from "../components/MeetingMembersList.vue";
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
@@ -637,7 +637,7 @@ interface MeetingSetupInputs {
MeetingMembersList,
MeetingMemberMatch,
MeetingExclusionGroups,
MeetingProjectDialog,
ProjectSelectionDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
@@ -1468,7 +1468,7 @@ export default class OnboardMeetingView extends Vue {
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
(this.$refs.meetingProjectDialog as ProjectSelectionDialog).open();
}
/**

View File

@@ -193,7 +193,7 @@
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mt-3">
Projects That Contribute To This
These Projects Are Part Of This
</h3>
<!--
centering because long, wrapped project names didn't left align with blank
@@ -218,7 +218,7 @@
<div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
This Project Is Part Of These
</h3>
<!--
centering because long, wrapped project names didn't left align with blank
@@ -647,7 +647,6 @@ import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { copyToClipboard } from "../services/ClipboardService";
import { logger } from "../utils/logger";
import { syncStarredPlansToNativePlugin } from "@/services/notifications";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
@@ -1546,9 +1545,6 @@ export default class ProjectViewView extends Vue {
);
if (result) {
this.isStarred = true;
if (settings.notifyingNewActivityTime) {
void syncStarredPlansToNativePlugin(newStarredIds);
}
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to star a project.");
@@ -1571,9 +1567,6 @@ export default class ProjectViewView extends Vue {
);
if (result) {
this.isStarred = false;
if (settings.notifyingNewActivityTime) {
void syncStarredPlansToNativePlugin(updatedIds);
}
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to unstar a project.");

View File

@@ -223,7 +223,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
this.notify.error(
timeResult?.error || NOTIFY_BVC_TIME_ERROR.message,
TIMEOUTS.LONG,
timeResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}
@@ -251,7 +251,7 @@ export default class QuickActionBvcBeginView extends Vue {
);
this.notify.error(
attendResult?.error || NOTIFY_BVC_ATTENDANCE_ERROR.message,
TIMEOUTS.LONG,
attendResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}
@@ -276,7 +276,7 @@ export default class QuickActionBvcBeginView extends Vue {
logger.error("[QuickActionBvcBeginView] Error sending claims:", error);
this.notify.error(
error.userMessage || NOTIFY_BVC_SUBMISSION_ERROR.message,
TIMEOUTS.LONG,
error.userMessage ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
);
}
}

View File

@@ -54,7 +54,11 @@
</p>
</div>
<!-- Nearest Neighbors Section -->
<!--
Network Connections section: shows nearest neighbors in the registration
graph for this user profile. The same conventions and styling are used in
ClaimView.vue for claim-level nearest neighbors. Keep changes in sync.
-->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
@@ -63,7 +67,46 @@
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<h2 class="text-lg font-semibold mb-3">
Network Connections
<button
title="What is this?"
class="ml-1 align-middle"
@click="showNeighborsInfo = true"
>
<font-awesome
icon="circle-info"
class="text-base text-blue-500 cursor-pointer"
/>
</button>
</h2>
<!-- Info modal for network connections explanation -->
<div
v-if="showNeighborsInfo"
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
@click.self="showNeighborsInfo = false"
>
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
<p class="text-sm text-slate-700">
This section shows
{{
neighbors.length === 1
? "a contact that is"
: "contacts that are"
}}
nearer to this person. If you want more information, reach out to
one of them and ask for an introduction.
</p>
<button
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
@click="showNeighborsInfo = false"
>
Got it
</button>
</div>
</div>
<div v-if="loadingNeighbors">
<div class="flex justify-center items-center py-8">
@@ -124,8 +167,8 @@
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
and send them the profile link from your clipboard. Ask them to
introduce you to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
@@ -269,6 +312,7 @@ export default class UserProfileView extends Vue {
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
showNeighborsInfo = false;
// make this function available to the Vue template
didInfo = didInfo;