Compare commits

...

29 Commits

Author SHA1 Message Date
Matthew Raymer
2deb84aa42 fix: Store starred plans in SharedPreferences when configuring native fetcher
- Add updateStarredPlans call in configureNativeFetcher to store starred
  plan IDs in SharedPreferences for the native fetcher to use
- Clear starred plans if none provided to ensure state consistency
- Add starredPlansCount to configuration log for debugging

This fixes the issue where the native fetcher was querying the API
with an empty planIds array because starred plans weren't being stored
in SharedPreferences. Now when configureNativeFetcher is called with
starredPlanHandleIds, they are properly stored and available for
background prefetch operations.

Also updates package-lock.json for daily-notification-plugin v1.0.11.
2025-11-11 08:14:04 +00:00
Matthew Raymer
5528c44f2b fix: Add notification channel creation and improve native fetcher configuration
- Add notification channel creation in TimeSafariApplication for Android 8.0+
  Required for daily notifications to display properly. Channel ID matches
  plugin's 'timesafari.daily' channel.

- Convert localhost to 10.0.2.2 in CapacitorPlatformService for Android emulators
  Android emulators cannot reach localhost - they need 10.0.2.2 to access
  the host machine's API server.

- Refresh native fetcher configuration when API server changes in AccountViewView
  Ensures background notification prefetch uses the updated endpoint when
  user changes API server URL in settings.

- Add directive for fixing notification dismiss cancellation in plugin
  Documents the fix needed in plugin source to cancel notification from
  NotificationManager when dismiss button is clicked.

These changes ensure daily notifications work correctly on Android, including
proper channel setup, emulator network connectivity, and configuration refresh.
2025-11-11 07:53:20 +00:00
Matthew Raymer
28a825a460 feat(android): Add instrumentation logs for prefetch investigation
Add comprehensive instrumentation to diagnose why native fetcher
is not being called during prefetch operations.

Instrumentation Added:
- TimeSafariApplication: Log app initialization, fetcher registration,
  and verification with process ID and timestamps
- TimeSafariNativeFetcher: Log configuration, fetch start, source
  resolution (native vs fallback), and write completion
- Diagnostic script: Filter logcat for prefetch-related events
- Investigation summary: Document root cause hypotheses and
  diagnostic checklist

Log Tags:
- APP|ON_CREATE: App initialization timing and process info
- FETCHER|REGISTER_START/REGISTERED: Fetcher registration lifecycle
- FETCHER|CONFIGURE_START/CONFIGURE_COMPLETE: Configuration tracking
- PREFETCH|START/SOURCE/WRITE_OK: Prefetch operation tracking
- DISPLAY|START/LOOKUP: Display worker tracking (future)
- STORAGE|POST_PREFETCH/PRE_DISPLAY: Storage verification (future)

This instrumentation will help diagnose:
1. Registration timing issues (worker before onCreate)
2. Fetcher resolution failures (null fetcher)
3. Process mismatches (multi-process issues)
4. ID schema inconsistencies (prefetch vs display)
5. Storage persistence issues (content not saved)

Author: Matthew Raymer
2025-11-11 05:05:10 +00:00
Matthew Raymer
b585c4d183 feat(android): Add host-side setup for daily notification plugin
Implement Android host-side integration for daily notification plugin
by creating custom Application class and native content fetcher.

Changes:
- Add TimeSafariApplication.java: Custom Application class that registers
  NativeNotificationContentFetcher with the plugin on app startup
- Add TimeSafariNativeFetcher.java: Implementation of NativeNotificationContentFetcher
  interface that fetches notification content from endorser API endpoint
  (/api/v2/report/plansLastUpdatedBetween) using JWT authentication
- Update AndroidManifest.xml: Declare TimeSafariApplication as the custom
  Application class using android:name attribute
- Add Gson dependency: Include com.google.code.gson:gson:2.10.1 in build.gradle
  for JSON parsing in the native fetcher

This setup mirrors the test app configuration and enables the plugin's
background content prefetching feature. The native fetcher will be called
by the plugin 5 minutes before scheduled notification times to prefetch
content for display.

Author: Matthew Raymer
2025-11-11 05:03:25 +00:00
Matthew Raymer
80d5199259 chore: synchronize package 2025-11-11 01:04:20 +00:00
Matthew Raymer
816c7a6582 Fix daily notification plugin integration and bundling
- Change from dynamic to static imports for @timesafari/daily-notification-plugin
- Remove plugin from external dependencies in vite.config.capacitor.mts to ensure proper bundling
- Add debug logging to DailyNotificationSection for troubleshooting
- Update package-lock.json with latest dependencies
2025-11-10 04:23:45 +00:00
Matthew Raymer
9ea9f4969e Merge branch 'master' into integrate-notification-plugin 2025-11-10 02:55:07 +00:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
1053bb6e4c Merge pull request 'bulk-members-dialog-refactor' (#218) from bulk-members-dialog-refactor into master
Reviewed-on: #218
2025-11-05 03:34:27 -05:00
88f46787e5 Merge pull request 'entity-selection-list-component' (#216) from entity-selection-list-component into master
Reviewed-on: #216
2025-11-05 03:25:35 -05:00
Jose Olarte III
d9230d0be8 fix: restore proper dialog max-height 2025-11-05 16:25:06 +08:00
Jose Olarte III
38f301f053 Merge branch 'master' into entity-selection-list-component 2025-11-05 16:12:39 +08:00
e42552c67a Merge pull request 'feat(EntityGrid): implement infinite scroll for entity lists' (#215) from entity-selection-list-component-infinite-scroll into entity-selection-list-component
Reviewed-on: #215
2025-11-05 02:52:30 -05:00
Jose Olarte III
c06ffec466 refactor: combine member processing methods in BulkMembersDialog
Consolidate organizerAdmitAndAddWithVisibility() and
memberAddContactWithVisibility() into a single unified method
processSelectedMembers() that handles both organizer and member
modes based on the isOrganizer prop.

- Remove redundant handleMainAction() wrapper method
- Update template to call processSelectedMembers directly
- Reduce code duplication by ~30% (140 lines → 98 lines)
- Maintain identical functionality for both modes

This simplifies the component structure and makes the processing
logic easier to maintain.
2025-11-04 18:39:45 +08:00
Jose Olarte III
8b199ec76c refactor: remove redundant dialogType prop from BulkMembersDialog
Remove dialogType prop and consolidate to use only isOrganizer prop.

- Remove dialogType prop from BulkMembersDialog component
- Replace all dialogType checks with isOrganizer boolean checks
- Add comments clarifying isOrganizer true/false meanings
- Remove dialog-type prop binding from MembersList component

This simplifies the component API while maintaining the same functionality.
2025-11-04 17:57:38 +08:00
Jose Olarte III
d32cca4f53 feat(EntityGrid): implement infinite scroll for entity lists
Add infinite scroll functionality to EntityGrid component using VueUse's
useInfiniteScroll composable to handle large volumes of entities efficiently.

Changes:
- Integrate @vueuse/core useInfiniteScroll composable
- Add infinite scroll state management (displayedCount, reset function)
- Configure initial batch size (20 items) and increment size (20 items)
- Update displayedEntities, alphabeticalContacts to support progressive loading
- Add canLoadMore() logic for people, projects, and search modes
- Reset scroll state when search term or entities prop changes
- Remove maxItems prop (replaced by infinite scroll)
- Simplify displayEntitiesFunction signature (removed maxItems parameter)
- Update EntitySelectionStep and test files to remove max-items prop

Technical details:
- Uses template ref (scrollContainer) to access scrollable container
- Recent contacts (3) count toward initial batch for people grid
- Special entities (You, Unnamed) always displayed, don't count toward limits
- Infinite scroll works for both entity types and search results
- Constants are configurable at top of component (INITIAL_BATCH_SIZE, INCREMENT_SIZE)

This improves performance and UX when displaying large lists of contacts or
projects by loading content progressively as users scroll.
2025-11-03 21:47:25 +08:00
Jose Olarte III
4004d9fe52 feat(EntityGrid): Split contacts into recent and alphabetical sections
When displaying contacts (not search results), show the 3 most recently
added contacts at the top with a "Recently Added" heading, followed by
the rest sorted alphabetically with an "Everyone Else" heading.

- Add recentContacts and alphabeticalContacts computed properties
- Hide "You" and "Unnamed" special entities during search
- Only show search spinner when actively searching with a term
- Style section headings with uppercase, improved spacing, and borders
2025-11-03 16:32:59 +08:00
Jose Olarte III
2f99d0b416 fix(components): prevent icon shrinking in PersonCard and ProjectCard
Add shrink-0 class to icon elements to maintain consistent icon sizing
when card layouts flex or wrap content.
2025-10-31 19:10:13 +08:00
Jose Olarte III
9c3002f9c7 feat(EntityGrid): sort search results alphabetically
Sort search results alphabetically while preserving original order for
default list when no search term is present.
2025-10-31 19:07:50 +08:00
Jose Olarte III
82fd7cddf7 feat: Add showUnnamedEntity prop to EntityGrid
Add prop to control visibility of "Unnamed" entity, matching showYouEntity
pattern. Defaults to true for backward compatibility.
2025-10-31 18:59:35 +08:00
Jose Olarte III
10f2920e11 feat(EntityGrid): display no results message for empty search queries
Add contextual feedback message when a search term is entered but no matching entities are found. The message dynamically adjusts its wording based on whether searching for people or projects.
2025-10-31 18:34:54 +08:00
Jose Olarte III
75c89b471c fix: linting 2025-10-30 21:49:35 +08:00
Jose Olarte III
a804877a08 feat: Add quick search to EntityGrid with date-based contact sorting
- Add search-as-you-type functionality with 500ms debounce
- Implement search across contact names and DIDs, project names and handleIds
- Add loading spinner and dynamic clear button
- Add $contactsByDateAdded() method to PlatformServiceMixin for newest-first sorting
- Update GiftedDialog to use date-based contact ordering
- Maintain backward compatibility with existing $contacts() alphabetical sorting
- Add proper cleanup for search timeouts on component unmount

The search feature provides real-time filtering with visual feedback,
while the new sorting ensures recently added contacts appear first.
2025-10-30 21:16:36 +08:00
Jose Olarte III
f7441f39e7 feat: remove Show All navigation card from entity grids
- Remove ShowAllCard component and all related functionality
- Remove showAllRoute, showAllQueryParams, and hideShowAll props
- Remove shouldShowAll computed property from EntityGrid
- Clean up ShowAll-related code from EntitySelectionStep and GiftedDialog
- Delete ShowAllCard.vue component file
- Update component documentation to reflect removal

This simplifies the entity selection interface by removing the navigation
card that allowed users to view all entities in a separate view.
2025-10-30 17:31:18 +08:00
Jose Olarte III
e647af0777 refactor: convert entity display to list style
- Switch from grid display to list layout for persons and projects
- Re-styled special entities (unnamed, You) to match
- Added max-height limit to list in preparation for scrolling and displaying more items
2025-10-24 13:26:36 +08:00
31 changed files with 1709 additions and 466 deletions

View File

@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
- ... and you may have to fix these, especially with pkgx:
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
```bash
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
##### 2. Build
Here's prod. Also available: test, dev
Here's prod. Also available: test, dev
```bash
npm run build:ios:prod
```
```bash
npm run build:ios:prod
```
3.1. Use Xcode to build and run on simulator or device.
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means
- Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build
@@ -1315,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
```
```bash
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
```
##### 2. Build
Here's prod. Also available: test, dev
```bash
npm run build:android:prod
```
```bash
npm run build:android:prod
```
##### 3. Open the project in Android Studio
```bash
npx cap open android
```
```bash
npx cap open android
```
##### 4. Use Android Studio to build and run on emulator or device
@@ -1379,6 +1380,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations
```bash

View File

@@ -6,6 +6,12 @@ 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.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46
versionName "1.1.1"
versionCode 47
versionName "1.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -101,6 +101,8 @@ dependencies {
implementation project(':capacitor-android')
implementation project(':capacitor-community-sqlite')
implementation "androidx.biometric:biometric:1.2.0-alpha05"
// Gson for JSON parsing in native notification fetcher
implementation "com.google.code.gson:gson:2.10.1"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".TimeSafariApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View File

@@ -0,0 +1,118 @@
/**
* TimeSafariApplication.java
*
* Application class for the TimeSafari app.
* Registers the native content fetcher for the Daily Notification Plugin.
*
* @author TimeSafari Team
* @version 1.0.0
*/
package app.timesafari;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import com.timesafari.dailynotification.DailyNotificationPlugin;
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
/**
* Application class that registers native fetcher for daily notifications
*/
public class TimeSafariApplication extends Application {
private static final String TAG = "TimeSafariApplication";
@Override
public void onCreate() {
super.onCreate();
// Instrumentation: Log app initialization with process info
int pid = android.os.Process.myPid();
String processName = getApplicationInfo().processName;
Log.i(TAG, String.format(
"APP|ON_CREATE ts=%d pid=%d processName=%s",
System.currentTimeMillis(),
pid,
processName
));
Log.i(TAG, "Initializing TimeSafari Application");
// Create notification channel for daily notifications (required for Android 8.0+)
createNotificationChannel();
// Register native fetcher with application context
Context context = getApplicationContext();
NativeNotificationContentFetcher nativeFetcher =
new TimeSafariNativeFetcher(context);
// Instrumentation: Log before registration
Log.i(TAG, String.format(
"FETCHER|REGISTER_START instanceHash=%d ts=%d",
nativeFetcher.hashCode(),
System.currentTimeMillis()
));
DailyNotificationPlugin.setNativeFetcher(nativeFetcher);
// Instrumentation: Verify registration succeeded
NativeNotificationContentFetcher verified =
DailyNotificationPlugin.getNativeFetcherStatic();
boolean registered = (verified != null && verified == nativeFetcher);
Log.i(TAG, String.format(
"FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=%d registered=%s ts=%d",
nativeFetcher.hashCode(),
registered,
System.currentTimeMillis()
));
Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName());
}
/**
* Create notification channel for daily notifications
* Required for Android 8.0 (API 26) and above
*/
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager == null) {
Log.w(TAG, "NotificationManager is null, cannot create channel");
return;
}
// Channel ID must match the one used in DailyNotificationWorker
String channelId = "timesafari.daily";
String channelName = "Daily Notifications";
String channelDescription = "Daily notification updates from TimeSafari";
// Check if channel already exists
NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId);
if (existingChannel != null) {
Log.d(TAG, "Notification channel already exists: " + channelId);
return;
}
// Create the channel with high importance (for priority="high" notifications)
NotificationChannel channel = new NotificationChannel(
channelId,
channelName,
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription(channelDescription);
channel.enableVibration(true);
channel.setShowBadge(true);
notificationManager.createNotificationChannel(channel);
Log.i(TAG, "Notification channel created: " + channelId);
}
}
}

View File

@@ -0,0 +1,605 @@
/**
* TimeSafariNativeFetcher.java
*
* Implementation of NativeNotificationContentFetcher for the TimeSafari app.
* Fetches notification content from the endorser API endpoint.
*
* @author TimeSafari Team
* @version 1.0.0
*/
package app.timesafari;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import com.timesafari.dailynotification.FetchContext;
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
import com.timesafari.dailynotification.NotificationContent;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonArray;
import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* Native content fetcher implementation for TimeSafari
*
* Fetches notification content from the endorser API endpoint.
* Uses the same endpoint as the TypeScript code: /api/v2/report/plansLastUpdatedBetween
*/
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; // 10 seconds
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
// SharedPreferences constants
// NOTE: Must match plugin's SharedPreferences name and keys for starred plans
// Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans)
private static final String PREFS_NAME = "daily_notification_timesafari";
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // For pagination
private final Gson gson = new Gson();
private final Context appContext;
private SharedPreferences prefs;
// Volatile fields for configuration, set via configure() method
private volatile String apiBaseUrl;
private volatile String activeDid;
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
/**
* Constructor
*
* @param context Application context for SharedPreferences access
*/
public TimeSafariNativeFetcher(Context context) {
this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
Log.d(TAG, "TimeSafariNativeFetcher: Initialized with context");
}
/**
* Configure the native fetcher with API credentials
*
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
* This method stores the configuration for use in background fetches.
*
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
* TimeSafari's {@code accessTokenForBackground()} function (ES256K DID-based signing).
* The native fetcher just uses the token directly - no JWT generation needed.</p>
*
* @param apiBaseUrl Base URL for API server (e.g., "https://api.endorser.ch")
* @param activeDid Active DID for authentication (e.g., "did:ethr:0x...")
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript
*/
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
// Instrumentation: Log configuration start
int pid = android.os.Process.myPid();
Log.i(TAG, String.format(
"FETCHER|CONFIGURE_START instanceHash=%d pid=%d ts=%d",
this.hashCode(),
pid,
System.currentTimeMillis()
));
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
// Instrumentation: Log configuration completion
boolean configured = (apiBaseUrl != null && activeDid != null && jwtToken != null);
Log.i(TAG, String.format(
"FETCHER|CONFIGURE_COMPLETE instanceHash=%d configured=%s apiBaseUrl=%s activeDid=%s jwtLength=%d ts=%d",
this.hashCode(),
configured,
apiBaseUrl != null ? apiBaseUrl : "null",
activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null",
jwtToken != null ? jwtToken.length() : 0,
System.currentTimeMillis()
));
// Enhanced logging for JWT diagnostic purposes
Log.i(TAG, "TimeSafariNativeFetcher: Configured with API: " + apiBaseUrl);
if (activeDid != null) {
Log.i(TAG, "TimeSafariNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) +
(activeDid.length() > 30 ? "..." : ""));
} else {
Log.w(TAG, "TimeSafariNativeFetcher: ActiveDID is NULL");
}
if (jwtToken != null) {
Log.i(TAG, "TimeSafariNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars");
// Log first and last 10 chars for verification (not full token for security)
String tokenPreview = jwtToken.length() > 20
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
: jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "...";
Log.d(TAG, "TimeSafariNativeFetcher: JWT preview: " + tokenPreview);
} else {
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL - API calls will fail");
}
}
@Override
@NonNull
public CompletableFuture<List<NotificationContent>> fetchContent(
@NonNull FetchContext context) {
// Instrumentation: Log fetch start with context
int pid = android.os.Process.myPid();
Log.i(TAG, String.format(
"PREFETCH|START id=%s notifyAt=%d trigger=%s instanceHash=%d pid=%d ts=%d",
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
context.scheduledTime != null ? context.scheduledTime : 0,
context.trigger,
this.hashCode(),
pid,
System.currentTimeMillis()
));
Log.d(TAG, "TimeSafariNativeFetcher: Fetch triggered - trigger=" + context.trigger +
", scheduledTime=" + context.scheduledTime + ", fetchTime=" + context.fetchTime);
// Start with retry count 0
return fetchContentWithRetry(context, 0);
}
/**
* Fetch content with retry logic for transient errors
*
* @param context Fetch context
* @param retryCount Current retry attempt (0 for first attempt)
* @return Future with notification contents or empty list on failure
*/
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> {
try {
// Check if configured
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
Log.e(TAG, String.format(
"PREFETCH|SOURCE from=fallback reason=not_configured apiBaseUrl=%s activeDid=%s jwtToken=%s ts=%d",
apiBaseUrl != null ? "set" : "null",
activeDid != null ? "set" : "null",
jwtToken != null ? "set" : "null",
System.currentTimeMillis()
));
Log.e(TAG, "TimeSafariNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList();
}
// Instrumentation: Log native fetcher usage
Log.i(TAG, String.format(
"PREFETCH|SOURCE from=native instanceHash=%d apiBaseUrl=%s ts=%d",
this.hashCode(),
apiBaseUrl,
System.currentTimeMillis()
));
Log.i(TAG, "TimeSafariNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT);
// Build request URL
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
URL url = new URL(urlString);
// Create HTTP connection
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
// Diagnostic logging for JWT usage
if (jwtToken != null) {
String jwtPreview = jwtToken.length() > 20
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
: jwtToken;
Log.d(TAG, "TimeSafariNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() +
", Preview: " + jwtPreview + ", ActiveDID: " +
(activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null"));
} else {
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL when making API call!");
}
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
connection.setDoOutput(true);
// Build request body
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("planIds", getStarredPlanIds());
// afterId is required by the API endpoint
// Use "0" for first request (no previous data), or stored jwtId for subsequent requests
String afterId = getLastAcknowledgedJwtId();
if (afterId == null || afterId.isEmpty()) {
afterId = "0"; // First request - start from beginning
}
requestBody.put("afterId", afterId);
String jsonBody = gson.toJson(requestBody);
Log.d(TAG, "TimeSafariNativeFetcher: Request body: " + jsonBody);
// Write request body
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
// Execute request
int responseCode = connection.getResponseCode();
Log.d(TAG, "TimeSafariNativeFetcher: HTTP response code: " + responseCode);
if (responseCode == 200) {
// Read response
BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
StringBuilder response = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
String responseBody = response.toString();
Log.d(TAG, "TimeSafariNativeFetcher: Response body length: " + responseBody.length());
// Parse response and convert to NotificationContent
List<NotificationContent> contents = parseApiResponse(responseBody, context);
// Update last acknowledged JWT ID from the response (for pagination)
if (!contents.isEmpty()) {
// Get the last JWT ID from the parsed response (stored during parsing)
updateLastAckedJwtIdFromResponse(contents, responseBody);
}
Log.i(TAG, "TimeSafariNativeFetcher: Successfully fetched " + contents.size() +
" notification(s)");
// Instrumentation: Log successful fetch
Log.i(TAG, String.format(
"PREFETCH|WRITE_OK id=%s items=%d ts=%d",
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
contents.size(),
System.currentTimeMillis()
));
return contents;
} else {
// Read error response
String errorMessage = "Unknown error";
try {
BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8));
StringBuilder errorResponse = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
errorResponse.append(line);
}
reader.close();
errorMessage = errorResponse.toString();
} catch (Exception e) {
Log.w(TAG, "TimeSafariNativeFetcher: Could not read error stream", e);
}
Log.e(TAG, "TimeSafariNativeFetcher: API error " + responseCode + ": " + errorMessage);
// Handle retryable errors (5xx server errors, network timeouts)
if (shouldRetry(responseCode, retryCount)) {
long delayMs = RETRY_DELAY_MS * (1 << retryCount); // Exponential backoff
Log.w(TAG, "TimeSafariNativeFetcher: Retryable error, retrying in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", e);
return Collections.emptyList();
}
// Recursive retry
return fetchContentWithRetry(context, retryCount + 1).join();
}
// Non-retryable errors (4xx client errors, max retries reached)
if (responseCode >= 400 && responseCode < 500) {
Log.e(TAG, "TimeSafariNativeFetcher: Non-retryable client error " + responseCode);
} else if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "TimeSafariNativeFetcher: Max retries (" + MAX_RETRIES + ") reached");
}
// Return empty list on error (fallback will be handled by worker)
return Collections.emptyList();
}
} catch (java.net.SocketTimeoutException | java.net.UnknownHostException e) {
// Network errors are retryable
Log.w(TAG, "TimeSafariNativeFetcher: Network error during fetch", e);
if (shouldRetry(0, retryCount)) { // Use 0 as response code for network errors
long delayMs = RETRY_DELAY_MS * (1 << retryCount);
Log.w(TAG, "TimeSafariNativeFetcher: Retrying after network error in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", ie);
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
Log.e(TAG, "TimeSafariNativeFetcher: Max retries reached for network error");
return Collections.emptyList();
} catch (Exception e) {
Log.e(TAG, "TimeSafariNativeFetcher: Error during fetch", e);
// Non-retryable errors (parsing, configuration, etc.)
return Collections.emptyList();
}
});
}
/**
* Determine if an error should be retried
*
* @param responseCode HTTP response code (0 for network errors)
* @param retryCount Current retry attempt count
* @return true if error is retryable and retry count not exceeded
*/
private boolean shouldRetry(int responseCode, int retryCount) {
if (retryCount >= MAX_RETRIES) {
return false; // Max retries exceeded
}
// Retry on network errors (responseCode 0) or server errors (5xx)
// Don't retry on client errors (4xx) as they indicate permanent issues
if (responseCode == 0) {
return true; // Network error (timeout, unknown host, etc.)
}
if (responseCode >= 500 && responseCode < 600) {
return true; // Server error (retryable)
}
// Some 4xx errors might be retryable (e.g., 429 Too Many Requests)
if (responseCode == 429) {
return true; // Rate limit - retry with backoff
}
return false; // Other client errors (401, 403, 404, etc.) are not retryable
}
/**
* Get starred plan IDs from SharedPreferences
*
* @return List of starred plan IDs, empty list if none stored
*/
private List<String> getStarredPlanIds() {
try {
// Use the same SharedPreferences as the plugin (not the instance variable 'prefs')
// Plugin stores in "daily_notification_timesafari" with key "starredPlanIds"
SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]");
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
Log.d(TAG, "TimeSafariNativeFetcher: No starred plan IDs found in SharedPreferences");
return new ArrayList<>();
}
// Parse JSON array (plugin stores as JSON string)
JsonParser parser = new JsonParser();
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
List<String> planIds = new ArrayList<>();
for (int i = 0; i < jsonArray.size(); i++) {
planIds.add(jsonArray.get(i).getAsString());
}
Log.i(TAG, "TimeSafariNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences");
if (planIds.size() > 0) {
Log.d(TAG, "TimeSafariNativeFetcher: First plan ID: " +
planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "...");
}
return planIds;
} catch (Exception e) {
Log.e(TAG, "TimeSafariNativeFetcher: Error loading starred plan IDs from SharedPreferences", e);
return new ArrayList<>();
}
}
/**
* Get last acknowledged JWT ID from SharedPreferences (for pagination)
*
* @return Last acknowledged JWT ID, or null if none stored
*/
private String getLastAcknowledgedJwtId() {
try {
String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
if (jwtId != null) {
Log.d(TAG, "TimeSafariNativeFetcher: Loaded last acknowledged JWT ID");
}
return jwtId;
} catch (Exception e) {
Log.e(TAG, "TimeSafariNativeFetcher: Error loading last acknowledged JWT ID", e);
return null;
}
}
/**
* Update last acknowledged JWT ID from the API response
* Uses the last JWT ID from the data array for pagination
*
* @param contents Parsed notification contents (may contain JWT IDs)
* @param responseBody Original response body for parsing
*/
private void updateLastAckedJwtIdFromResponse(List<NotificationContent> contents, String responseBody) {
try {
JsonParser parser = new JsonParser();
JsonObject root = parser.parse(responseBody).getAsJsonObject();
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray != null && dataArray.size() > 0) {
// Get the last item's JWT ID (most recent)
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
// Try to get JWT ID from different possible locations in response structure
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()) {
updateLastAckedJwtId(jwtId);
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID: " +
jwtId.substring(0, Math.min(20, jwtId.length())) + "...");
}
}
} catch (Exception e) {
Log.w(TAG, "TimeSafariNativeFetcher: Could not extract JWT ID from response for pagination", e);
}
}
/**
* Update last acknowledged JWT ID in SharedPreferences
*
* @param jwtId JWT ID to store as last acknowledged
*/
private void updateLastAckedJwtId(String jwtId) {
try {
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID");
} catch (Exception e) {
Log.e(TAG, "TimeSafariNativeFetcher: Error updating last acknowledged JWT ID", e);
}
}
/**
* Parse API response and convert to NotificationContent list
*/
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
List<NotificationContent> contents = new ArrayList<>();
try {
JsonParser parser = new JsonParser();
JsonObject root = parser.parse(responseBody).getAsJsonObject();
// Parse response structure (matches PlansLastUpdatedResponse)
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray != null) {
for (int i = 0; i < dataArray.size(); i++) {
JsonObject item = dataArray.get(i).getAsJsonObject();
NotificationContent content = new NotificationContent();
// Extract data from API response
// Support both flat structure (jwtId, planId) and nested (plan.jwtId, plan.handleId)
String planId = null;
String jwtId = null;
if (item.has("planId")) {
planId = item.get("planId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("handleId")) {
planId = plan.get("handleId").getAsString();
}
}
if (item.has("jwtId")) {
jwtId = item.get("jwtId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("jwtId")) {
jwtId = plan.get("jwtId").getAsString();
}
}
// Create notification ID
String notificationId = "endorser_" + (jwtId != null ? jwtId :
System.currentTimeMillis() + "_" + i);
content.setId(notificationId);
// Create notification title
String title = "Project Update";
if (planId != null) {
title = "Update: " + planId.substring(Math.max(0, planId.length() - 8));
}
content.setTitle(title);
// Create notification body
StringBuilder body = new StringBuilder();
if (planId != null) {
body.append("Plan ").append(planId.substring(Math.max(0, planId.length() - 12))).append(" has been updated.");
} else {
body.append("A project you follow has been updated.");
}
content.setBody(body.toString());
// Use scheduled time from context, or default to 1 hour from now
long scheduledTimeMs = context.scheduledTime != null ?
context.scheduledTime : (System.currentTimeMillis() + 3600000);
content.setScheduledTime(scheduledTimeMs);
// Set notification properties
content.setPriority("default");
content.setSound(true);
contents.add(content);
}
}
// If no data items, create a default notification
if (contents.isEmpty()) {
NotificationContent defaultContent = new NotificationContent();
defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis());
defaultContent.setTitle("No Project Updates");
defaultContent.setBody("No updates found in your starred projects.");
long scheduledTimeMs = context.scheduledTime != null ?
context.scheduledTime : (System.currentTimeMillis() + 3600000);
defaultContent.setScheduledTime(scheduledTimeMs);
defaultContent.setPriority("default");
defaultContent.setSound(true);
contents.add(defaultContent);
}
} catch (Exception e) {
Log.e(TAG, "TimeSafariNativeFetcher: Error parsing API response", e);
// Return empty list on parse error
}
return contents;
}
}

View File

@@ -0,0 +1,109 @@
# Fix Notification Dismiss to Cancel Notification
## Problem
When a user clicks the "Dismiss" button on a daily notification, the notification is removed from storage and alarms are cancelled, but the notification itself is not cancelled from the NotificationManager. This means the notification remains visible in the system tray even though it's been dismissed.
Additionally, clicking on the notification (not the dismiss button) launches the app, which is working as intended.
## Root Cause
In `DailyNotificationWorker.java`, the `handleDismissNotification()` method:
1. ✅ Removes notification from storage
2. ✅ Cancels pending alarms
3. ❌ **MISSING**: Does not cancel the notification from NotificationManager
The notification is displayed with ID = `content.getId().hashCode()` (line 440), but this ID is never used to cancel the notification when dismissing.
## Solution
Add notification cancellation to `handleDismissNotification()` method in `DailyNotificationWorker.java`.
### IMPORTANT: Plugin Source Change
**This change must be applied to the plugin source repository**, not the host app. The file is located in the `@timesafari/daily-notification-plugin` package.
### File to Modify
**Plugin Source Repository:**
`android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
**Note:** In the host app's `node_modules`, this file is located at:
`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
However, changes to `node_modules` will be overwritten on the next `npm install`. This fix must be applied to the plugin's source repository.
### Change Required
In the `handleDismissNotification()` method (around line 177-206), add code to cancel the notification from NotificationManager:
```java
private Result handleDismissNotification(String notificationId) {
Trace.beginSection("DN:dismiss");
try {
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
// Cancel the notification from NotificationManager FIRST
// This ensures the notification disappears immediately when dismissed
NotificationManager notificationManager =
(NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager != null) {
int systemNotificationId = notificationId.hashCode();
notificationManager.cancel(systemNotificationId);
Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId);
}
// Remove from Room if present; also remove from legacy storage for compatibility
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// No direct delete DAO exposed via service; legacy removal still applied
} catch (Exception ignored) { }
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
storage.removeNotification(notificationId);
// Cancel any pending alarms
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
getApplicationContext(),
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
);
scheduler.cancelNotification(notificationId);
Log.i(TAG, "DN|DISMISS_OK id=" + notificationId);
return Result.success();
} catch (Exception e) {
Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
```
### Key Points
1. **Notification ID**: Use `notificationId.hashCode()` to match the ID used when displaying (line 440: `int notificationId = content.getId().hashCode()`)
2. **Order**: Cancel the notification FIRST, before removing from storage, so it disappears immediately
3. **Null check**: Check that NotificationManager is not null before calling cancel()
4. **Logging**: Add instrumentation log to track cancellation
### Expected Behavior After Fix
1. User clicks "Dismiss" button → Notification disappears immediately from system tray
2. User clicks notification body → App launches (unchanged behavior)
3. User swipes notification away → Notification dismissed (Android handles this automatically with `setAutoCancel(true)`)
## Testing Checklist
- [ ] Click dismiss button → Notification disappears immediately
- [ ] Click notification body → App launches
- [ ] Swipe notification away → Notification dismissed
- [ ] Check logs for `DN|DISMISS_CANCEL_NOTIF` entry
- [ ] Verify notification is removed from storage after dismiss
- [ ] Verify alarms are cancelled after dismiss
## Related Code
- Notification display: `DailyNotificationWorker.displayNotification()` line 440
- Notification ID generation: `content.getId().hashCode()`
- Auto-cancel: `builder.setAutoCancel(true)` line 363 (handles swipe-to-dismiss)

View File

@@ -0,0 +1,109 @@
# Prefetch Investigation Summary
## Problem Statement
The daily notification prefetch job (T-5 min) is not calling the native fetcher, resulting in:
- `from: null` in prefetch logs
- Fallback/mock content being used
- `DISPLAY_SKIP content_not_found` at notification time
- Storage empty (`[]`) when display worker runs
## Root Cause Hypothesis
Based on the directive analysis, likely causes (ranked):
1. **Registration Timing**: Prefetch worker runs before `Application.onCreate()` completes
2. **Discovery Failure**: Worker resolves fetcher to `null` (wrong scope, process mismatch)
3. **Persistence Bug**: Content written but wiped/deduped before display
4. **ID Mismatch**: Prefetch writes `notify_...` but display looks for `daily_...`
## Instrumentation Added
### TimeSafariApplication.java
- `APP|ON_CREATE ts=... pid=... processName=...` - App initialization timing
- `FETCHER|REGISTER_START instanceHash=... ts=...` - Before registration
- `FETCHER|REGISTERED providerKey=... instanceHash=... registered=... ts=...` - After registration with verification
### TimeSafariNativeFetcher.java
- `FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...` - Configuration start
- `FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=... apiBaseUrl=... activeDid=... jwtLength=... ts=...` - Configuration completion
- `PREFETCH|START id=... notifyAt=... trigger=... instanceHash=... pid=... ts=...` - Fetch start
- `PREFETCH|SOURCE from=native/fallback reason=... ts=...` - Source resolution
- `PREFETCH|WRITE_OK id=... items=... ts=...` - Successful fetch
## Diagnostic Tools
### Log Filtering Script
```bash
./scripts/diagnose-prefetch.sh app.timesafari.app
```
Filters logcat for:
- `APP|ON_CREATE`
- `FETCHER|*`
- `PREFETCH|*`
- `DISPLAY|*`
- `STORAGE|*`
### Manual Filtering
```bash
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\|"
```
## Investigation Checklist
### A. App/Plugin Initialization Order
- [ ] Confirm `APP|ON_CREATE` appears before `PREFETCH|START`
- [ ] Verify `FETCHER|REGISTERED registered=true`
- [ ] Check for multiple `onCreate` invocations (process restarts)
- [ ] Confirm single process (no `android:process` on workers)
### B. Prefetch Worker Resolution
- [ ] Check `PREFETCH|SOURCE from=native` (not `from=fallback`)
- [ ] Verify `instanceHash` matches between registration and fetch
- [ ] Compare `pid` values (should be same process)
- [ ] Check `FETCHER|CONFIGURE_COMPLETE configured=true` before prefetch
### C. Storage & Persistence
- [ ] Verify `PREFETCH|WRITE_OK items>=1`
- [ ] Check storage logs for content persistence
- [ ] Compare prefetch ID vs display lookup ID (must match)
### D. ID Schema Consistency
- [ ] Prefetch ID format: `daily_<epoch>` or `notify_<epoch>`
- [ ] Display lookup ID format: must match prefetch ID
- [ ] Verify ID derivation rules are consistent
## Next Steps
1. **Run diagnostic script** during a notification cycle
2. **Analyze logs** for timing issues and process mismatches
3. **If fetcher is null**: Implement Fix #2 (Pass Fetcher Context With Work) or Fix #3 (Process-Safe DI)
4. **If ID mismatch**: Normalize ID schema across prefetch and display
5. **If storage issue**: Add transactional writes and read-after-write verification
## Expected Log Flow (Success Case)
```
APP|ON_CREATE ts=... pid=... processName=app.timesafari.app
FETCHER|REGISTER_START instanceHash=... ts=...
FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=... registered=true ts=...
FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...
FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=true ... ts=...
PREFETCH|START id=daily_... notifyAt=... trigger=prefetch instanceHash=... pid=... ts=...
PREFETCH|SOURCE from=native instanceHash=... apiBaseUrl=... ts=...
PREFETCH|WRITE_OK id=daily_... items=1 ts=...
STORAGE|POST_PREFETCH total=1 ids=[daily_...]
DISPLAY|START id=daily_...
STORAGE|PRE_DISPLAY total=1 ids=[daily_...]
DISPLAY|LOOKUP result=hit id=daily_...
```
## Failure Indicators
- `PREFETCH|SOURCE from=fallback` - Native fetcher not resolved
- `PREFETCH|SOURCE from=null` - Fetcher registration failed
- `FETCHER|REGISTERED registered=false` - Registration verification failed
- `STORAGE|PRE_DISPLAY total=0` - Content not persisted
- `DISPLAY|LOOKUP result=miss` - ID mismatch or content cleared

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -152,7 +152,7 @@
},
"../daily-notification-plugin": {
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.1",
"version": "1.0.11",
"license": "MIT",
"workspaces": [
"packages/*"

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Clean Gradle build
# Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type
# Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi
# Step 7: Sync with Capacitor
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested
# Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi
# Step 10: Auto-run app if requested
# Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || {
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!"
fi
# Step 11: Open Android Studio if requested
# Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi

View File

@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Sync with Capacitor
# Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Generate assets
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested
# Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
@@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully"
fi
# Step 9: Auto-run app if requested
# Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi
# Step 10: Open Xcode if requested
# Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi

36
scripts/diagnose-prefetch.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
#
# Diagnostic script for daily notification prefetch issues
# Filters logcat output for prefetch-related instrumentation logs
#
# Usage:
# ./scripts/diagnose-prefetch.sh [package_name]
#
# Example:
# ./scripts/diagnose-prefetch.sh app.timesafari.app
#
set -e
PACKAGE_NAME="${1:-app.timesafari.app}"
echo "🔍 Daily Notification Prefetch Diagnostic Tool"
echo "=============================================="
echo ""
echo "Package: $PACKAGE_NAME"
echo "Filtering for instrumentation tags:"
echo " - APP|ON_CREATE"
echo " - FETCHER|*"
echo " - PREFETCH|*"
echo " - DISPLAY|*"
echo " - STORAGE|*"
echo ""
echo "Press Ctrl+C to stop"
echo ""
# Filter logcat for instrumentation tags
adb logcat -c # Clear logcat buffer first
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\||DailyNotification|TimeSafariApplication|TimeSafariNativeFetcher" | \
grep -i "$PACKAGE_NAME\|TimeSafari\|DailyNotification"

View File

@@ -38,7 +38,7 @@
}
.dialog {
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto;
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
}
/* Markdown content styling to restore list elements */

View File

@@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
]"
@click="handleMainAction"
@click="processSelectedMembers"
>
{{ buttonText }}
</button>
@@ -145,7 +145,7 @@ import { Contact } from "@/db/tables/contacts";
export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility";
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
@Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system
@@ -252,15 +252,7 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid);
}
async handleMainAction() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
async processSelectedMembers() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
@@ -275,16 +267,20 @@ export default class BulkMembersDialog extends Vue {
for (const member of selectedMembers) {
try {
// First, admit the member
await this.admitMember(member);
// Register them
await this.registerMember(member);
admittedCount++;
// Organizer mode: admit and register the member first
if (this.isOrganizer) {
await this.admitMember(member);
await this.registerMember(member);
admittedCount++;
}
// If they're not a contact yet, add them as a contact
if (!member.isContact) {
await this.addAsContact(member, true);
// Organizer mode: set isRegistered to true, member mode: undefined
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++;
}
@@ -299,88 +295,51 @@ export default class BulkMembersDialog extends Vue {
}
// Show success notification
if (admittedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
10000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async memberAddContactWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member, undefined);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
if (this.isOrganizer) {
if (admittedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
},
5000,
);
}
if (errors > 0) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to fully admit some members. Work with them individually below.",
},
5000,
);
}
} else {
// Member mode: show contacts added notification
if (contactAddedCount > 0) {
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
}
}
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`,
},
5000,
);
this.close(notSelectedMembers.map((member) => member.did));
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error adding contacts:", error);
console.error(
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify(
{
group: "alert",
@@ -487,10 +446,10 @@ export default class BulkMembersDialog extends Vue {
}
showContactInfo() {
const message =
this.dialogType === "admit"
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
// isOrganizer: true = admit mode, false = visibility mode
const message = this.isOrganizer
? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet.";
this.$notify(
{

View File

@@ -2,12 +2,55 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<ul :class="gridClasses">
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity"
v-if="showYouEntity && !searchTerm.trim()"
entity-type="you"
label="You"
icon="hand"
@@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
@@ -38,16 +82,60 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
@@ -63,28 +151,27 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected"
/>
</template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
@@ -93,7 +180,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
@@ -104,7 +190,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
PersonCard,
ProjectCard,
SpecialEntityCard,
ShowAllCard,
},
})
export default class EntityGrid extends Vue {
@@ -112,14 +197,21 @@ export default class EntityGrid extends Vue {
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
@@ -140,18 +232,14 @@ export default class EntityGrid extends Vue {
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
@@ -160,42 +248,31 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" })
conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering, sorting, and
* display logic beyond the default simple slice behavior.
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type, max) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type, max) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[];
/**
@@ -206,33 +283,60 @@ export default class EntityGrid extends Vue {
}
/**
* Computed CSS classes for the grid layout
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/
get gridClasses(): string {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Computed entities to display - uses function prop if provided, otherwise defaults
* Get the 3 most recently added contacts (when showing contacts and not searching)
*/
get displayedEntities(): Contact[] | PlanData[] {
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(
this.entities,
this.entityType,
this.maxItems,
);
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
}
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
return this.entities.slice(0, maxDisplay);
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
}
/**
@@ -246,15 +350,6 @@ export default class EntityGrid extends Vue {
}
}
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/**
* Whether the "You" entity is conflicted
*/
@@ -328,6 +423,144 @@ export default class EntityGrid extends Vue {
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
@@ -340,6 +573,33 @@ export default class EntityGrid extends Vue {
} {
return data;
}
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>

View File

@@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Show All navigation with context preservation * - Cancel functionality * - Event
delegation for entity selection * - Warning notifications for conflicted
entities * - Template streamlined with computed CSS properties * * @author
Matthew Raymer */
Cancel functionality * - Event delegation for entity selection * - Warning
notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
@@ -16,18 +15,14 @@ Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify"
:conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
/>
@@ -68,7 +63,6 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display
* - Conflict detection and prevention
* - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
@@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/**
* CSS classes for the cancel button
*/
@@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/**
* Handle entity selection from EntityGrid
*/

View File

@@ -29,7 +29,6 @@
:unit-code="unitCode"
:offer-id="offerId"
:notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected"
@cancel="cancel"
/>
@@ -117,7 +116,6 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as
| "person"
| "project";
@@ -233,7 +231,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer,
});
this.allContacts = await this.$contacts();
this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids();

View File

@@ -223,7 +223,6 @@
ref="bulkMembersDialog"
:active-did="activeDid"
:api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback"
/>

View File

@@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */
<template>
<li :class="cardClasses" @click="handleClick">
<div class="relative w-fit mx-auto">
<div>
<EntityIcon
v-if="person.did"
:contact="person"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="circle-question"
class="text-slate-400 text-5xl mb-1"
class="text-slate-400 text-5xl mb-1 shrink-0"
/>
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div>
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="overflow-hidden">
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
</li>
</template>
@@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return "opacity-50 cursor-not-allowed";
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return "cursor-pointer hover:bg-slate-50";
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the person name
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.person.name) {
return `${baseClasses} italic text-slate-500`;
return `${baseNameClasses} italic text-slate-500`;
}
return baseClasses;
return baseNameClasses;
}
/**

View File

@@ -2,25 +2,26 @@
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer" @click="handleClick">
<div class="relative w-fit mx-auto">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
/>
</div>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<h3
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ project.name || unnamedProject }}
</h3>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
</h3>
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" />
{{ issuerDisplayName }}
<div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }}
</div>
</div>
</li>
</template>

View File

@@ -1,66 +0,0 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

View File

@@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string;
/**
* Computed CSS classes for the card container
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseClasses = "block";
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) {
return `${baseClasses} cursor-not-allowed opacity-50`;
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseClasses} cursor-pointer`;
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the icon
*/
get iconClasses(): string {
const baseClasses = "text-5xl mb-1";
const baseClasses = "text-[2rem]";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;
@@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
*/
get nameClasses(): string {
const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) {
return `${baseClasses} text-slate-400`;

View File

@@ -357,6 +357,10 @@ export default class DailyNotificationSection extends Vue {
this.loading = true;
const platformService = PlatformServiceFactory.getInstance();
logger.debug(
"[DailyNotificationSection] Checking notification support...",
);
// Check if notifications are supported on this platform
// This also verifies plugin availability (returns null if plugin unavailable)
const status = await platformService.getDailyNotificationStatus();
@@ -364,11 +368,16 @@ export default class DailyNotificationSection extends Vue {
// Notifications not supported or plugin unavailable - don't initialize
this.notificationsSupported = false;
logger.warn(
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
"[DailyNotificationSection] Notifications not supported or plugin unavailable - section will be hidden",
);
return;
}
logger.debug(
"[DailyNotificationSection] Notifications supported, status:",
status,
);
this.notificationsSupported = true;
this.notificationStatus = status;

View File

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return { error: errorMessage || "Got a server error when registering." };
return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
}
return { error: "Got a server error when registering." };
}

View File

@@ -13,6 +13,7 @@ import {
CapacitorSQLite,
DBSQLiteValues,
} from "@capacitor-community/sqlite";
import { DailyNotification } from "@timesafari/daily-notification-plugin";
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
@@ -96,16 +97,92 @@ export class CapacitorPlatformService
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
// Try to create/Open database connection
try {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
await this.db.open();
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");
@@ -1345,9 +1422,8 @@ export class CapacitorPlatformService
*/
async getDailyNotificationStatus(): Promise<NotificationStatus | null> {
try {
// Dynamic import to avoid build issues if plugin unavailable
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
logger.debug(
"[CapacitorPlatformService] Getting daily notification status...",
);
const pluginStatus = await DailyNotification.getNotificationStatus();
@@ -1387,10 +1463,16 @@ export class CapacitorPlatformService
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
"[CapacitorPlatformService] Failed to get notification status:",
errorMessage,
error,
);
logger.warn(
"[CapacitorPlatformService] Daily notification section will be hidden - plugin may not be installed or available",
);
return null;
}
}
@@ -1401,10 +1483,6 @@ export class CapacitorPlatformService
*/
async checkNotificationPermissions(): Promise<PermissionStatus | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
const permissions = await DailyNotification.checkPermissions();
// Log the raw permission state for debugging
@@ -1454,10 +1532,6 @@ export class CapacitorPlatformService
*/
async requestNotificationPermissions(): Promise<PermissionResult | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
logger.info(
`[CapacitorPlatformService] Requesting notification permissions...`,
);
@@ -1495,10 +1569,6 @@ export class CapacitorPlatformService
*/
async scheduleDailyNotification(options: ScheduleOptions): Promise<void> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.scheduleDailyNotification({
time: options.time,
title: options.title,
@@ -1525,10 +1595,6 @@ export class CapacitorPlatformService
*/
async cancelDailyNotification(): Promise<void> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.cancelAllNotifications();
logger.info("[CapacitorPlatformService] Cancelled daily notification");
@@ -1594,10 +1660,6 @@ export class CapacitorPlatformService
config: NativeFetcherConfig,
): Promise<void | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
// Step 1: Get activeDid from database (single source of truth)
// This ensures we're using the correct user identity for authentication
const activeIdentity = await this.getActiveIdentity();
@@ -1631,10 +1693,21 @@ export class CapacitorPlatformService
// Step 3: Get API server from config or use default
// This ensures the plugin knows where to fetch content from
const apiServer =
let apiServer =
config.apiServer ||
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
// Step 3.5: Convert localhost to 10.0.2.2 for Android emulators
// Android emulators can't reach localhost - they need 10.0.2.2 to access the host machine
const platform = Capacitor.getPlatform();
if (platform === "android" && apiServer) {
// Replace localhost or 127.0.0.1 with 10.0.2.2 for Android emulator compatibility
apiServer = apiServer.replace(
/http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/,
"http://10.0.2.2$2",
);
}
// Step 4: Configure plugin with credentials
// Plugin stores these in its Room database for background workers
await DailyNotification.configureNativeFetcher({
@@ -1643,11 +1716,34 @@ export class CapacitorPlatformService
jwtToken,
});
// Step 5: Update starred plans if provided
// This stores the starred plan IDs in SharedPreferences for the native fetcher
if (
config.starredPlanHandleIds &&
config.starredPlanHandleIds.length > 0
) {
await DailyNotification.updateStarredPlans({
planIds: config.starredPlanHandleIds,
});
logger.info(
`[CapacitorPlatformService] Updated starred plans: ${config.starredPlanHandleIds.length} plans`,
);
} else {
// Clear starred plans if none provided
await DailyNotification.updateStarredPlans({
planIds: [],
});
logger.info(
"[CapacitorPlatformService] Cleared starred plans (none provided)",
);
}
logger.info("[CapacitorPlatformService] Configured native fetcher", {
activeDid,
apiServer,
tokenExpirationHours: 72,
tokenExpirationMinutes: expirationMinutes,
starredPlansCount: config.starredPlanHandleIds?.length || 0,
});
} catch (error) {
logger.error(
@@ -1664,10 +1760,6 @@ export class CapacitorPlatformService
*/
async updateStarredPlans(plans: { planIds: string[] }): Promise<void | null> {
try {
const { DailyNotification } = await import(
"@timesafari/daily-notification-plugin"
);
await DailyNotification.updateStarredPlans({
planIds: plans.planIds,
});

View File

@@ -19,7 +19,6 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -39,7 +38,6 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
return entities.filter((person) => person.profileImageUrl);
};
/**
@@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people", 5).length;
return this.customPeopleFunction(this.people, "people").length;
}
return Math.min(5, this.people.length);
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects", 3).length;
return this.customProjectsFunction(this.projects, "projects").length;
}
return Math.min(7, this.projects.length);
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
}
}
</script>

View File

@@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts);
},
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/**
* Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts
@@ -2057,6 +2071,7 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

View File

@@ -825,6 +825,7 @@ import {
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
@@ -1547,6 +1548,33 @@ export default class AccountViewView extends Vue {
settingsSaved: true,
timestamp: new Date().toISOString(),
});
// Refresh native fetcher configuration with new API server
// This ensures background notification prefetch uses the updated endpoint
try {
const platformService = PlatformServiceFactory.getInstance();
const settings = await this.$accountSettings();
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
await platformService.configureNativeFetcher({
apiServer: newApiServer,
jwt: "", // Will be generated automatically by configureNativeFetcher
starredPlanHandleIds,
});
logger.info(
"[AccountViewView] Native fetcher configuration refreshed after API server change",
{
newApiServer,
},
);
} catch (error) {
logger.error(
"[AccountViewView] Failed to refresh native fetcher config after API server change:",
error,
);
// Don't throw - API server change should still succeed even if native fetcher refresh fails
}
}
async onClickSavePartnerServer(): Promise<void> {

View File

@@ -10,10 +10,8 @@ export default defineConfig(async () => {
...baseConfig.build,
rollupOptions: {
...baseConfig.build?.rollupOptions,
// Externalize Capacitor plugins that are bundled natively
external: [
"@timesafari/daily-notification-plugin"
],
// Note: @timesafari/daily-notification-plugin is NOT externalized
// because it needs to be bundled for dynamic imports to work in Capacitor WebView
output: {
...baseConfig.build?.rollupOptions?.output,
}