9 Commits
Author SHA1 Message Date
JeffGandGitHub d57a883e53 Implement iOS NSE notifications and Android push token plumbing (#673)
* Add remote push notification plumbing

* Checkpoint notification reliability cleanup

* Prepare notification reliability PR

* Fix notification PR review findings

* Address notification review follow-ups

* Clean up failed iOS data migration copies

* Address app resume and migration cleanup review

* Restore coverage for notification review fixes

* Fix App Group migration guard

* Address notification PR review feedback

* Cover notification review edge cases

* Address notification test review feedback

* Address NSE migration and startup retry review

* Use foreground catch-up for notification refresh

* Update whitenoise-rs foreground catch-up dependency

* Cover notification catch-up error paths

* Fix notification review edge cases
2026-05-20 14:47:09 +02:00
Javier G. Montoya S.andGitHub 418fca8beb test(integration): add integration test support with basic messaging flow test (#666)
* test(integration): add integration test support with basic messaging flow test

* chore(): add docker-compose file with needed relays for integration tests
2026-05-18 12:03:00 -04:00
JeffGandGitHub e480f49e9e fix(android): external signer notification recovery (#635)
* Reconcile external signer callbacks on auth lifecycle

* Fix headless external signer notification recovery

* Point whitenoise-rs dependency at merged recovery fix

* Address external signer notification review feedback

* Address notification review follow-ups

* Cover external signer registry fallback paths

* Use developer log in foreground task
2026-05-11 17:21:50 +02:00
92b7e9b205 fix(android): enable boot auto-restart for foreground service (#577)
* fix(android): enable boot auto-restart for foreground service

The foreground service was not auto-restarting after device reboot,
so notifications only started working once the user reopened the
app. This was caused by two stacked misconfigurations:

1. `AndroidManifest.xml` explicitly disabled the plugin's
   `RebootReceiver` and `RestartReceiver` via `tools:node="replace"`.
   This was added in c3b4a1909 as defense-in-depth against an
   upgrade-path crash caused by a `foregroundServiceType 0x201`
   bitmask being persisted in native SharedPreferences. The
   primary fix for that crash — a one-time prefs cleanup in
   `MainActivity.onCreate()` — has been shipped for ~2 months,
   so the defensive disable is no longer load-bearing.

2. `ForegroundTaskOptions` did not set `autoRunOnBoot` or
   `autoRunOnMyPackageReplaced`, which default to `false`. Even
   with the receivers enabled, the plugin's `RebootReceiver`
   early-returns unless these flags are `true`.

Both fixes are required: the receiver is the *only* mechanism
the plugin provides for boot restart, and it only fires when
the Dart-side flags opt in.

Particularly impactful for GrapheneOS users, who typically run
without sandboxed Google Play Services and therefore cannot rely
on FCM wake-ups — the foreground service is their only path to
notifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add headless-isolate spike to foreground task handler

Temporary instrumentation to verify the background isolate can reach
Rust (via flutter_rust_bridge), platform channels (path_provider), and
persisted data (accounts) when the foreground service starts.

Each step is logged with a `[SPIKE]` prefix so logcat can be filtered
with `adb logcat | grep SPIKE`. Also installs a log listener in the
task isolate's `_startCallback` — without one, log output never reaches
logcat because the main isolate's listener is scoped to its own isolate.

This will run on every foreground-service start, including:
- Normal app launch (main isolate already initialized whitenoise; spike
  will log "already initialized" for that step)
- RebootReceiver auto-start (fresh isolate; spike should pass every step)

Comparing logcat between the two paths tells us whether a real headless
task handler is viable. To be removed when follow-up lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(android): initialize keyring context in Application.onCreate

The Rust keyring crate panics with "android context was not
initialized" when invoked from the foreground service after a
headless boot restart. Root cause: Keyring.initializeNdkContext was
called from MainActivity.configureFlutterEngine, which only runs
when an Activity is launched. When RebootReceiver starts the
foreground service directly, no Activity launches, so the JNI
Context pointer never gets handed to Rust.

Move the init to a new WhitenoiseApplication.onCreate. Application
subclasses run on every process start regardless of entry point
(Activity, Service, or Receiver), so the Context is ready for any
component that touches Rust.

Confirmed via device spike: all spike steps passed pre-reboot, and
initializeWhitenoise specifically failed post-reboot with this exact
panic. Re-running the spike after this change should show a full
"[SPIKE] DONE" line on the post-reboot path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(spike): log WhitenoiseApplication.onCreate to prove it runs

Spike still panics post-install with "android context was not
initialized" despite the WhitenoiseApplication fix. Two possibilities:

1. WhitenoiseApplication.onCreate isn't actually running in the
   headless process (class-resolution or build-cache issue).
2. It is running, but Keyring.initializeNdkContext only sets up
   keyring-internal state, not the global ndk_context that other
   crates (rustls-platform-verifier-android, etc.) need.

Add an Android Log call so we can tell which case we're in. Also
wrap the Keyring call in try/catch so any failure there is visible
instead of silently allowing later crates to panic with a confusing
message.

After rebuilding with `flutter clean && flutter build apk --profile
--flavor staging --dart-define=WHITENOISE_ENABLE_SPIKE_LOGS=true`
and installing, logcat should include:

  adb logcat | grep -E 'SPIKE|WhitenoiseApp'

If WhitenoiseApp lines appear → Application subclass loaded; problem
is a broader ndk_context init (H2). If they don't → class isn't being
loaded on this path (H1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: extract NotificationSubscription for isolate-agnostic reuse

Previously the notification subscription lived inside
notification_provider.dart's _initializeAndListen and was gated on a
Riverpod Ref (so it could only run from the widget tree). This
prevents the foreground-task background isolate from reusing the same
subscription logic, which is what PR B needs.

Extract the subscription into lib/services/notification_subscription.dart
as a plain class. It takes dependencies via callbacks rather than Ref:

  - ActiveChatGetter: String? Function()
  - LocaleGetter: Locale Function()

The main-isolate provider wires these with ref.read(...). The upcoming
task handler will wire them with isolate-local defaults (no active
chat, locale from SharedPreferences).

Side effects of the extraction:
- formatNotification moves to the new file (still @visibleForTesting)
- handleNotificationUpdate becomes NotificationSubscription.handleUpdate
  (tests migrate accordingly)
- notification_provider.dart shrinks; its coverage:ignore block now
  wraps a smaller amount of genuinely-untestable Ref-wiring code

All ~40 existing tests for formatting + update handling migrate to
test/services/notification_subscription_test.dart. Coverage holds at
99%+. No behavioral change — this is a pure extraction.

Foundation for the next commit, which wires the new subscription into
the foreground-task handler for headless post-reboot delivery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(android): headless notification handler + isolate coordination

Replace the diagnostic SPIKE instrumentation with a real
_NotificationTaskHandler that runs inside the foreground-task
background isolate and owns the notification subscription when the
main UI isolate isn't running.

Behavior:

- On TaskStarter.system (boot / MY_PACKAGE_REPLACED, main isolate
  not running): task isolate bootstraps Flutter bindings + Rust FFI
  + whitenoise, loads locale from persisted app settings, and starts
  its own NotificationSubscription. Notifications arrive without
  the user opening the app.

- On TaskStarter.developer (main isolate called foregroundService.
  start()): task isolate bootstraps but does NOT start a subscription;
  main owns it. Task awaits coordination via sendDataToTask.

- Coordination protocol (via FlutterForegroundTask.sendDataToTask):
    {'event': 'main_started'}  -> task stops its subscription
    {'event': 'main_stopped'}  -> task starts its subscription
  The main-isolate caller triggers these via ForegroundService.
  notifyMainStarted() / notifyMainStopped(). Wiring into the main
  isolate's lifecycle observer lands in the next commit.

- onDestroy cancels the headless subscription so it doesn't leak
  across service restarts.

- Headless notification tap currently just launches the app via
  FlutterForegroundTask.launchApp(). Deep-link routing to the
  specific chat/invite is tracked in #488.

Cleanup:

- Remove _runHeadlessSpike, _spikeShowTestNotification, and the
  WHITENOISE_ENABLE_SPIKE_LOGS dart-define. Their job is done —
  device testing confirmed all headless steps work.

- Remove SPIKE-reference comments from WhitenoiseApplication.kt.
  Keep the try/catch + Log.e defensive around Keyring init.

- Logcat forwarding for ForegroundService + NotificationSubscription
  loggers remains, gated on kDebugMode, for development visibility.

Tests:

- Existing ForegroundService tests remain green; new tests cover
  notifyMainStarted/notifyMainStopped (sends correct event, no-op
  when service isn't running, no-op when disabled).

- _NotificationTaskHandler itself runs in a separate isolate and
  can't be unit-tested; its work (NotificationSubscription) is
  fully covered in test/services/notification_subscription_test.dart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(android): send lifecycle signals from main to task handler

Wire _WnAppState as a WidgetsBindingObserver so the main isolate can
tell the task handler whether it owns the notification subscription:

  - AppLifecycleState.resumed -> foregroundService.notifyMainStarted()
    (task handler yields)
  - paused/inactive/hidden/detached -> notifyMainStopped()
    (task handler takes over notification delivery)

This closes the loop with the _NotificationTaskHandler coordination
protocol from the previous commit. Combined with the TaskStarter-based
initial decision, the notification channel is always owned by exactly
one isolate:

  Boot/package-replaced (headless)    -> task isolate owns it
  Main isolate starts, pushes resumed -> main isolate owns it
  Main isolate pauses/detaches        -> task isolate takes over again

Edge cases accepted for this PR (tracked as follow-ups):
- If Android kills the main isolate without firing detached
  (rare under a foreground service, but possible), the task handler
  may remain idle. User reopen re-establishes ownership.
- Briefly during pause -> resume transitions, both isolates may
  deliver the same update. NotificationService uses a group-id-
  derived notification ID so the Android plugin de-duplicates
  visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(android): deep-link handoff for headless notification taps

When the task isolate fires a notification and the user taps it, the
task handler now persists the payload (groupId, isInvite,
receiverPubkey) via FlutterForegroundTask.saveData before calling
launchApp(). The main isolate consumes the payload on startup /
resume and routes to the correct chat or invite screen, switching
the active account first if the tap targeted a different one.

Changes:

- foreground_service.dart:
  - _persistTapAndLaunch: the task isolate's notification tap handler.
    Stashes the payload, then launches the app.
  - consumePendingNotificationTap(): public helper that reads and
    clears the stashed payload. Returns a typed PendingNotificationTap
    or null.

- notification_provider.dart:
  - Rename _onNotificationTap -> handleNotificationTap (public).
  - Refactor its signature to take plain values + a switchToProfile
    callback instead of a Riverpod Ref, so it's callable from both
    Provider (Ref) and ConsumerState (WidgetRef) contexts.

- main.dart:
  - _WnAppState consumes pending taps in two places:
    (a) post-frame callback from initState (covers first launch)
    (b) AppLifecycleState.resumed (covers returning from background)
  - Both paths guard against missing context / unmounted state.

Edge cases and trade-offs:
- In-app taps (main isolate active) still go through the synchronous
  NotificationService callback; no SharedPreferences round-trip.
- If the user taps a headless notification but the router isn't yet
  ready when we try to navigate, the logger warns ("No navigator
  context") and the payload is dropped — next tap works. Acceptable
  for first iteration; a deferred-navigation queue is a possible
  follow-up if it surfaces in testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add unit tests for handleNotificationTap profile-switch logic

Covers three cases using plain-value callbacks (no Riverpod Ref
needed):
- Switches profile when the active pubkey differs from the tap's
  target receiver
- Does NOT switch when they match
- Switches when there's no active pubkey (first-tap case)

TestWidgetsFlutterBinding.ensureInitialized() is required because
handleNotificationTap reaches Routes.navigatorKey.currentContext,
which touches the widget binding. The binding returns null in tests
(no router mounted), which the function tolerates via a warn log —
we're specifically testing the pre-navigation logic here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: restore 99.36% coverage baseline

CI flagged a coverage regression (99.36% → 99.07%) from the
headless-notification landings. Close the gap:

1. NotificationSubscription
   - Add `enabled` parameter (like NotificationService) so tests can
     bypass the Platform.isAndroid guard.
   - Add tests covering start(), stop(), double-start idempotency,
     stream event dispatch, stream errors, stream close, outer catch
     on initialize failure, and default-enabled fallback.
   - Narrow coverage:ignore on the defensive `if (_stopped)` branch
     after stream.listen — structurally unreachable (no await between
     the earlier _stopped check and this one) but kept as a guard in
     case future edits introduce one.

2. ForegroundService
   - Extract lifecycle switch into handleAppLifecycleChange method
     so tests can drive each AppLifecycleState branch directly.
   - Add tests for all five lifecycle states (resumed, paused,
     inactive, hidden, detached) plus the disabled-service no-op case.
   - main.dart's didChangeAppLifecycleState becomes a one-line call to
     this method, drastically shrinking untestable widget-glue.

3. routePendingTap
   - Extract the pending-tap routing logic from _WnAppState into a
     top-level function in notification_provider.dart. Takes plain
     values (bool isMounted, callbacks) — no Ref, no ConsumerState
     dependency.
   - Add tests covering null payload, unmounted guard, and the full
     route-through case.
   - main.dart now delegates to routePendingTap with an inlined bool
     for mounted — no more uncovered lambdas.

4. Widget test for WnApp lifecycle
   - Add a widget test that pumps WnApp and cycles through all
     AppLifecycleState values. Verifies the observer chain wires
     without throwing and exercises the main.dart switch body.

5. Re-export PendingNotificationTap + consumePendingNotificationTap
   from notification_provider.dart so main.dart imports from a single
   place.

Coverage: 99.07% → 99.36% (matches master baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(android): claim subscription ownership + detect failed headless start

Two reviewer-flagged correctness gaps in the headless notification
delivery path:

1. Main isolate didn't claim ownership when opening after a system-
   started service. ForegroundService.start() returns early when the
   service is already running (post-reboot path), and
   WidgetsBindingObserver doesn't replay the current lifecycle state
   at registration — so notifyMainStarted() never fired before the
   main isolate's NotificationSubscription started. Result: brief
   double-subscription window where the task isolate (with
   getActiveChatId: () => null) could surface notifications for the
   currently-open chat.

   Send notifyMainStarted() explicitly during _startForegroundAndSubscribe,
   right after the service is known to be running, before the main
   subscription starts.

2. Failed headless subscription start was recorded as success.
   NotificationSubscription.start() catches its own startup failures
   internally; _NotificationTaskHandler stored the wrapper before
   awaiting start, then logged success regardless. A transient boot-
   time failure left _subscription non-null forever, so future
   coordination signals early-returned and the user got perma-silence
   until service destruction.

   Move the _subscription assignment after the await, check
   sub.isRunning, and only persist the wrapper on actual attachment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:32:57 +02:00
JeffGandGitHub c3b4a19098 fix: handle app upgrade crashes from incompatible data and foreground service receivers (#312)
* fix: handle app upgrade crashes from incompatible data and foreground service

When upgrading from an older version, multiple issues caused crashes:

1. The Rust whitenoise crate's secrets store format changed, causing
   "Secrets store error: Key not found" during initialization. Since
   the Rust singleton (tokio::sync::OnceCell) can't be reinitialized
   after partial failure, a version marker file now detects stale data
   and wipes the data directory before Rust init runs.

2. The flutter_foreground_task plugin stored foregroundServiceType
   0x201 (dataSync|shortService) in native SharedPreferences, but the
   manifest only declares dataSync (0x01). Android rejected
   startForeground() with IllegalArgumentException. A one-time cleanup
   in MainActivity.onCreate() clears these native prefs.

3. FlutterSecureStorage's internal migration from EncryptedSharedPreferences
   re-introduced the old active_account_pubkey after deleteAll(). Fixed by
   calling readAll() first to trigger the migration, then deleteAll().

4. The plugin's RebootReceiver and RestartReceiver caused cascade crashes
   on package update. Both are now disabled via manifest merge overrides.

* chore: remove stale url_launcher from generated plugin files and add build improvements

Remove url_launcher references from Linux/Windows plugin registrants
(dependency was already removed from pubspec.yaml). Add split APK,
AAB build targets and quiet spinner to OpenSSL build script.
2026-02-19 14:47:51 +01:00
JeffGandGitHub 2352bad48b fix: wipe old unencrypted database on upgrade (#224)
* fix: wipe old unencrypted database on upgrade

When initializeWhitenoise() fails with 'database was created without
encryption', delete the env-specific data directory (data/dev or
data/release) and retry initialization to create a fresh encrypted
database. Unrelated errors are rethrown without wiping.

Closes #222

* refactor: extract error string to constant and fix async test assertions

Extract 'database was created without encryption' into a shared
kUnencryptedDatabaseError constant in main.dart so tests import and
reuse the same value. Fix two async tests that were not awaiting the
Future from initializeAppContainer() by using expectLater.
2026-02-11 17:58:53 +01:00
PepiandGitHub 2cec4e850b Migrate to white noise app bundle id (#163)
* chore: use whitenoise app team and bundle id

* chore: add apk commands

* chore: rename package from sloth to whitenoise

* docs: remove sloth word from readme and agents.md

* chore: replace sloth texts with whitenoise

* chore: remove unused error screen

* docs: copy LICENSE file from whitenoise repo

* chore: upgrade version to 0.3.0+15 so that android apk can replace old whitenoise ones

* refactor: rename MyApp to WnApp

* chore: use new staging bundle id

* chore: rename org.parres to IPF

* chore: remove .so file

* fix: make android signer work after bundle id change

* core: replace org.parres with dev.ipf for widgebtook

* test: improve coverage
2026-02-05 14:45:04 -03:00
PepiandGitHub 3f8209fd88 Avatar colors (#108)
* feat: change formatting initials to just one

* feat: calculate accent color from pubkey

* refactor: extract image picker logic to hook, to be able to then replace wn image picker with wn avatar

* feat: adjust avatar design

* feat: use avatar colors

* feat: handle pick error case and replace image picker with avatar in signup an edit profile screens

* docs: add avatar widgetbook story

* test: fix dropdown tests warnings

* docs: update changelog

* test: use hex pubkeys in tests

* fix: overflow in settings screen
2026-01-30 11:04:29 -03:00
PepiandGitHub 072686e038 Fix/coverage check (#57)
* fix: include files with no tests in coverage calculation

* test: add tests for main.dart
2026-01-20 17:45:01 -03:00