The widget renders a pre-computed snapshot Angular pushes into `widget_data`;
the blob carried no expiry, so a process that stayed dead across midnight kept
rendering yesterday's tasks under a hardcoded "Today" header, indefinitely.
Native cannot recompute the list. TODAY_TAG membership is virtual, today's
repeat instances do not exist as entities until TaskDueEffects materializes
them on a day change, and overdue carry-over runs there too — so there is no
persisted field a native filter could read that would give the right answer
even in principle. Only running the app can produce today's list.
So make the widget honest instead of wrong: Angular stamps the snapshot with
`validUntil`, the instant it stops being today (start-of-next-day offset
included), and `dayStr` for the label. Native's whole verdict is
`WidgetData.headerFor` → `now >= validUntil`; when stale it renders the
snapshot's own date instead of "Today". The list stays visible and useful, it
just stops claiming to be today's. A stale snapshot whose day cannot be read
says "Outdated" rather than falling back to the very lie this fixes.
Shipping the boundary rather than its inputs keeps the app's calendar rules in
one language. The iOS port (#8950) can consume `validUntil` unchanged instead
of mirroring getDbDateStr semantics into Swift, where a non-Gregorian default
locale would silently misread the day. The verdict is a pure function so it is
unit-testable: this project has no Robolectric, and a decision left inside the
provider could ship inverted and green.
Both refresh paths rebuild the header. A push can change the day the blob
describes; a tap cannot, but it re-renders at a later `now` than the last
verdict was computed at — and it is the one interaction that reaches our code
while the app is dead, so a tap on a new day must not redraw rows under a
"Today" header. Also fixed: onUpdate re-registered the adapter with an
unchanged intent, which does not re-invoke the factory's onDataSetChanged(),
so the periodic update rendered whatever rows the adapter last built.
`v` stays 1: the fields are additive, and parse() returns empty for any other
version, so a bump would blank the widget of every install until next opened.
A pre-#9098 blob has no `validUntil` and is never reported stale — unknown must
not read as expired.
Known bounds, documented in the plan rather than papered over: the label flips
only on a push, a tap, or the inexact Doze-deferred 30-min periodic update, and
the launcher paints cached views on unlock — so the lie is bounded, not
eliminated. Force-stop is not a gap: the system masks a stopped package's
widget entirely, so the reported symptom can only occur in the Doze band. An
exact alarm at `validUntil` would close the rest; deferred on cost/scope, not
because it would not work.
Verified: 25/25 Kotlin across 6 timezones incl. midnight-gap zones (Santiago,
Apia); 203/203 Angular in both timezones CI runs. Boundary math asserted
against the real getDbDateStr rollover. Sabotage-measured, not assumed:
inverting the verdict fails 5 tests, >=→> fails 4, dropping the validUntil
guard fails 3, Locale.US→getDefault fails 1, dropping the day round-trip
fails 1.
Counter-scale fixed-size font icons by the Android WebView text zoom while preserving accessible text and inline icon scaling. Covers Angular icons, raw Material Symbols, and shared pseudo-element icons.
Fixes#5694
* feat(android): add home screen widget for today's tasks (#3818)
Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:
- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
selector, debounced, hydration-guarded, re-pushed on sync-window end
and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
taps are overlaid natively at render time (no native blob write, no
race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
PendingIntent template); exported receiver no longer lists the custom
actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
connection; access stays @Synchronized via the App singleton)
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
* feat(android): polish widget UI and support toggling done state
Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
(#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
project-less tasks; whole row and empty state open the app
Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
done task queues setUnDone, and a done->undone round trip before
the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
tasks already in the target state
---------
Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
* feat(rate-dialog): native store review + prompt after a productive win
Baseline of PR #8680 (squashed) so review improvements land as a separate,
cherry-pickable commit on top.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(rate-dialog): calm banner, feedback cooldown, delayed win prompt
Review + UX follow-ups on top of PR #8680:
- Fix (correctness): re-check full eligibility at prompt fire-time, not just
opt-out, so a crash/data-damage recorded after arming still suppresses it.
- Fix: iOS advances the rating cadence only once the native request resolves;
a reject leaves eligibility intact instead of burning a lifetime prompt.
- Fix: Android review-flow failure now logs and abandons instead of opening the
Play Store unprompted (the trigger is an automatic win, not a user tap).
- UX: web/electron/F-Droid now show a calm, non-modal banner that opens the full
rate/feedback dialog on request, rather than a modal shoved in mid-flow.
- UX: 'give feedback' no longer permanently opts out — it starts a long cooldown
(~90 app-start days) so an engaged user can still be asked once more later.
- UX: the win prompt fires a few seconds after the completion, not on the tap.
- UX: dedicated 'Send feedback' entry in the Help menu (GitHub Discussions).
- UX: show the maintainer email as selectable text (mailto: dead-end fallback).
- Refactor: move selectTodayProgress into work-context.selectors (colocation);
idempotency guard on the win subscription.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(rate-dialog): import WIN_PROMPT_DELAY_MS in spec instead of mirroring it
Export the delay constant from the service and reference it in the spec so the
test can't silently drift from the real value. Found by a review pass.
Co-Authored-By: Claude <noreply@anthropic.com>
* feat(rate-dialog): recurring cadence, version-age gate, GitHub star CTA
Growth-focused follow-ups (review found the lifetime cap starves review
recency/velocity, which is what stores rank on):
- Recurring cadence: after the fixed onboarding tiers (32/96 app-start days) the
prompt no longer stops forever — it recurs every ~180 app-start days (~6+
months of real use), well inside Apple's ~3/365 allowance and Play's own
quota. Still calm: win-timed, opt-out/crash/feedback-gated, OS-throttled.
- Version-age gate: hold the prompt for 7 days after the app version changes so a
fresh (possibly regressed) release isn't asked to be rated immediately. Tracked
via two device-local LS keys, checked at arm time and fire time.
- Play tier-burn is now only a deferral, not a lifetime loss (recurring cadence).
- Web/Electron CTA: 'Star us on GitHub' (the desktop-distribution equivalent of
store ranking) instead of the near-zero-conversion how-to-rate doc.
Tests cover recurrence at + beyond the last tier, the version-age gate, and the
updated cadence expectations. 60/60 green; tsc clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(rate-dialog): register iOS plugin, drop version-age gate, cut per-tick churn
Second multi-review pass (7 reviewers):
- CRITICAL (Codex, verified): the iOS StoreReview Capacitor plugin was never
registered — CustomViewController registers WebDavHttpPlugin but not
StoreReviewPlugin, so requestReview() rejected and the native App Store review
card never showed. Register it via registerPluginInstance.
- Remove the version-age gate (4 reviewers): getAppVersionStr() changes every
release and the app ships ~weekly, so the 7-day window kept re-arming on every
update (web SW reload / Electron auto-update) and near-permanently suppressed
the prompt on desktop — the platform where the GitHub-star intent matters most.
It also duplicated the 30-day crash gate, which already covers crashing
regressions. Drops 2 LS keys, a constant, and ~40 LOC.
- Perf: add distinctUntilChanged on the armed win stream so the 1s time-tracking
tick (new {done,total} with identical numbers) no longer re-runs scan/filter
every second for the whole session.
59/59 specs green; tsc clean. Kotlin/Swift can't be compiled here — the iOS
registration needs an on-device/simulator smoke test.
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(i18n): translate new rate-prompt strings into all locales
Add BANNER_ACTION, BTN_STAR_GITHUB (F.D_RATE) and SEND_FEEDBACK (MH.HM) to all
27 non-en locale files — previously they existed only in en.json and fell back
to English for everyone else. SEND_FEEDBACK reuses each locale's existing
feedback wording for consistency; GitHub is kept untranslated.
Deliberately edits locale files beyond the usual en-only workflow, per request.
Best-effort translations — a native check on the less-common languages is
welcome.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(android): size fullscreen markdown/notes dialog above the keyboard (#8508)
The fullscreen markdown editor (project & task notes) is position:fixed;
height:100% but its keyboard rule subtracted --keyboard-overlay-offset, which
is set only on iOS. On Android the rule was a no-op, so with the IME open the
dialog kept full height (content behind the keyboard) or inherited the squashed
sliver, leaving the toolbar + textarea + Close/Save mashed to the top.
Use the resize-detecting --keyboard-height for the Android/mobile-web case
(0 once the window resized, the obscured amount otherwise), mirroring the
add-task bar and .app-container; keep the iOS --keyboard-overlay-offset path as
a second, source-order-later rule (equal specificity, wins on iOS). On a device
that resizes (--keyboard-height == 0) it is identical to the old rule, so it is
never worse than before; it fixes the API >= 30 no-resize/VisualViewport-shrink
case and composes with the SDK < 30 native fix (#8528).
* fix(android): inset the header below the status bar on API < 30 (#8508)
Under enforced edge-to-edge (targetSdk 36) the WebView extends under the status
bar, but on the API < 30 WebView `env(safe-area-inset-top)` resolves to 0 (old
WebViews map only display cutouts into safe-area insets, not the status bar), so
`--safe-area-top` was 0 and the web header overlapped the status bar on Android 9.
The web side cannot tell "edge-to-edge under the status bar" from "already
natively inset" (env() is 0 in both), so a web-only fallback would double-count.
Measure the overlap natively instead: in the existing keyboard layout listener
(gated SDK < 30) compute max(0, rect.top - webViewTopOnScreen) — visible-frame
top (status-bar height, reliable on API 28) minus the WebView's on-screen top (0
when edge-to-edge, == status-bar height once inset) — and publish it as the
`--android-status-bar-overlap` CSS var (physical px -> CSS px, deduped). The web
folds it in with max(env(safe-area-inset-top, 0px), var(--android-status-bar-
overlap, 0px)): never a sum so it can't double-count, and on API >= 30 the var is
never set so `max(env, 0) = env` keeps the verified behavior. JS readers still
parse the token to 0, preserving the #8283 overlay scoping.
Needs on-device validation on API < 30 and a no-op check on API >= 30.
* fix(android): stop double-counting safe-area-top in note dialog height (#8508)
The fullscreen note/markdown dialog subtracted --safe-area-top from its
keyboard-open height while ALSO applying padding-top: --safe-area-top. Since
:host is border-box (global * { box-sizing: border-box }), the padding is already
inside height: 100%, so subtracting --safe-area-top again double-removed the top
inset and left a --safe-area-top-sized gap between the Close/Save controls and
the keyboard.
Invisible while --safe-area-top was 0 on API < 30; it surfaced once the
status-bar fix made it non-zero (and was latent on API >= 30 where env() already
gave a non-zero value). Drop the - --safe-area-top term from the Android rule so
the controls sit flush above the keyboard while the toolbar stays below the
status bar. iOS keeps its own override (different keyboard runtime, unverified on
device) and is flagged for separate checking.
* fix(android): re-publish status-bar overlap after web reload (#8508)
Review follow-ups to the #8508 keyboard/status-bar fixes:
- The native --android-status-bar-overlap lives only as an inline style on
the document, so a web-side window.location.reload() (language change, PWA
update, sync-conflict recovery) wipes it while the dedupe field survives on
the Activity -> the unchanged value is skipped and the header overlaps the
status bar again on the WebView < 140 / API < 30 tail. Reset
lastStatusBarOverlapCssPx in flushPendingShareIntent() (runs on every
frontend (re)load) so the next layout pass re-publishes it.
- Dialog keyboard rule: scope the Android rule with :not(.isIOS). iOS carries
both isNativeMobile and isIOS and sets --keyboard-height non-zero, so the two
equal-specificity rules both matched and iOS correctness depended on source
order; they are now mutually exclusive and order-independent.
- Declare --android-status-bar-overlap: 0px in :root for discoverability,
alongside the other dynamic vars.
* feat(android): migrate edge-to-edge to built-in SystemBars
Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's
built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge
insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35
tail is covered by env() fallback plus a native keyboard shim
(adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does
not fight SystemBars' own IME padding.
- config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the
plugin from the generated gradle registration
- theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them;
the SCSS env() fallback preserves the #8283 top fix); bars are now transparent
with the theme color painted behind them via setWebViewBackgroundColor, since
SystemBars has no bar-color API
- StartupOverlayManager: derive the overlay inset from the system-bar insets
instead of the removed plugin's WebView margin
Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device
behavior NOT yet validated (gradle unavailable in this environment). Requires
`npm install` + `npx cap sync android` and the device matrix in
docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge.
* build(deps): drop @capawesome edge-to-edge from lockfile
Reconcile package-lock.json with the package.json change in the edge-to-edge ->
SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge-
support entry). Generated by `npm install`.
* docs(android): clarify SystemBars band model from implementation review
Comment-only + plan-doc follow-up to the multi-agent review of the migration:
- Correct the band model in the inset comments: SystemBars *injects*
--safe-area-inset-* only on API >= 35; WebView >= 140 is native env()
passthrough (no injection below API 35). The SCSS var(..., env()) fallback
covers every band. (capacitor.config.ts, global-theme.service.ts)
- Fix a stale comment in StartupOverlayManager.show() that still described the
removed @capawesome plugin insetting the WebView.
- Record the device-validation-only findings in the plan doc (API>=35/WebView<140
double-count corner, env vs var consumer split, API 30-34/WebView<140 IME
owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed
(a blind shim extension would risk re-creating #8508).
No behavior change.
* docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause
After #8508 (patch removed in 18.12.0) a user on Android 9 / API 28 reports the
global add-task bar sitting behind the soft keyboard — the device class open
item #4 predicted.
Capture the precise root cause (edge-to-edge forces no system resize; Type.ime()
unreliable < API 30; old WebView VisualViewport doesn't shrink, so
--keyboard-height stays 0) and why a web-side height fallback is the reverted
#8295 trap (obscured ≈ 0 in both the resized and non-resized cases). Record the
correct resize-detecting native fix recipe and the device-matrix gate it needs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): lift add-task bar above keyboard on API < 30 (#8508 follow-up)
On Android 9 / API 28 under enforced edge-to-edge (targetSdk 36) the system
does not resize the window for the IME and WindowInsetsCompat.Type.ime() is
unreliable, so the edge-to-edge plugin never insets the WebView and neither the
window nor VisualViewport shrinks. --keyboard-height stays 0 and the
position:fixed add-task bar sits behind the keyboard.
Add a resize-detecting native inset in CapacitorMainActivity, driven from the
existing keyboard OnGlobalLayoutListener: measure the keyboard via
getWindowVisibleDisplayFrame (reliable on every API level) and, only when the
WebView actually extends behind the keyboard, lift it by the exact overlap via
bottomMargin (matching the plugin), self-correcting as the keyboard height
changes and restoring the captured margin on hide. Gated Build.VERSION.SDK_INT
< 30 so it is a strict no-op on every device #8508/18.12.0 verified.
Web-side height fallback was rejected: obscured = innerHeight - vvHeight is ~0
in both the resized and non-resized cases, so disambiguating requires the
baseline-innerHeight + max(obscured, nativeKb - layoutShrink) math that was
reverted as #8295. Native has the unambiguous geometry.
Needs on-device validation across the matrix in docs/android-edge-to-edge-keyboard.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): harden SDK < 30 keyboard inset after review
Address findings from a multi-agent review of adjustWebViewForKeyboardBelowApi30:
- Bound the feedback loop: clamp appliedKeyboardInsetPx to [0, keypadHeight]
(the lift never needs to exceed the keyboard height) and skip redundant
layoutParams writes, so a WebView height that doesn't respond to the margin
(e.g. the edge-to-edge plugin rewriting it) can't drive an endless
requestLayout loop.
- Ignore stale/pre-layout geometry while the keyboard is open (webView.height
== 0) so an already-applied inset isn't collapsed to 0 for a frame; only the
genuine keyboard-closed path restores the margin.
- Make the dead-band density-relative (8dp) instead of a fixed 16px so it's
visually constant across densities.
The reviews confirmed the central risk is absent: shrinking the WebView via
bottomMargin shrinks both innerHeight and visualViewport.height, so the web
side's obscured stays under its 100px floor and does not add a second lift
(no #8295 double-count). Still pending on-device validation per the docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): resize WebView via height for SDK<30 keyboard inset
On API < 30 under enforced edge-to-edge the system does not resize the
window for the IME, so the add-task bar sat behind the keyboard (#8508
follow-up, Android 9 / API 28).
The edge-to-edge plugin owns webView.bottomMargin and rewrites it to 0 on
every inset dispatch while the IME is visible, so correcting the margin
from a second writer flickers; WebView padding does not move the web
layout viewport; and fully replacing the plugin's inset listener stops it
re-painting its color overlays (white navbar gap).
Instead set an explicit WebView layout height to the keyboard top while
the IME is up and restore the resting height on hide. Height is a
different property than the margin the plugin manages and, for an
explicit-height view, the margin does not change the view size — so the
two never fight and the plugin keeps doing everything else (insets +
color overlays). Gated SDK_INT < 30, so API >= 30 is untouched.
Also documents the root cause, the approaches that failed, and the
upstream status, plus a handover/plan for migrating to Capacitor's
built-in SystemBars.
* fix(android): guard zero-height + reuse loc buffer in keyboard inset
Multi-review follow-ups for the SDK<30 keyboard WebView-height workaround:
- Skip the write when the computed keyboard-top height is <= 0 instead of
coercing to 0. A 0 would collapse the WebView, and the existing
height==0 guard would then latch and stop recomputing until the keyboard
hides.
- Reuse a single IntArray for getLocationOnScreen instead of allocating
one on every layout pass while the IME is up (hot path).
- Document the implicit coupling with StartupOverlayManager: both read/
mutate webView.height from layout listeners and only coexist because the
overlay early-returns once its input bar is visible. Cross-referenced in
both files so the guard isn't removed later.
---------
Co-authored-by: Claude <noreply@anthropic.com>
The adjustResize IME animation could briefly expose the layer behind the
WebView as a white flash. The WebView surface was already painted with the
theme color, but the window decor behind it was not: windowBackground was
@null, falling back to the static Light AppTheme default (~white), and
nothing repainted the decor at runtime.
- styles.xml: wire AppTheme.NoActionBarLaunch windowBackground to the
existing @color/windowBackground (has a values-night #131314 variant), so
the decor tracks system day/night instead of defaulting to white.
- NavigationBarPlugin: also setBackgroundDrawable(ColorDrawable) on the
window alongside the existing webView.setBackgroundColor, so the decor
stays matched to the in-app theme when it diverges from the system setting.
Countdown is the only focus mode that auto-stops with no follow-up
(Pomodoro transitions into a surfaced break; Flowtime only stops on
explicit user action), so completion could pass unnoticed: the SessionDone
screen wasn't reliably shown and there was no cross-platform notification.
On automatic Countdown completion (surfaceSessionDoneOnCompletion$):
- If the focus overlay is open, the reducer's SessionDone screen is already
visible — nothing forced.
- If the overlay is hidden (user working elsewhere), surface a non-modal
banner (BannerId.FocusModeSessionDone) with a 'What's next?' action that
opens the SessionDone screen and self-dismisses once the overlay opens —
instead of seizing the screen.
- Raise an OS notification only when the app is unfocused (skipped while
focused, since the surfaced UI is alert enough and it would fire on
idle-resume) and not on Android, where the native foreground service
already posts its own completion notification (documented in
FocusModeForegroundService.onTimerComplete to keep the contract visible).
Uses the injectable IS_ANDROID_WEB_VIEW_TOKEN (testable). Manual end and
Pomodoro/Flowtime are excluded.
The store v18.10.0 build positions the global add-task bar correctly above
the soft keyboard and resizes normally. #8295 (merged after v18.10.0) is the
only keyboard-logic change since, and it regressed both: on this branch the
bar sits behind the IME and the view no longer appears to resize.
#8295 stacked two extra keyboard-height signals (a native physical-px height
via keyboardHeightPx$ and a baseInnerHeight-tracking VisualViewport path) on
top of the single VisualViewport `obscured` signal that shipped in v18.10.0,
combined as max(obscured, nativeKeyboardHeight - layoutShrink). The sources
fire on separate async events and race; scheduleCommit() resets
baseInnerHeight to the already-shrunk innerHeight mid-IME-animation, which
zeroes layoutShrink and defeats the double-count guard, so --keyboard-height
lands on a wrong value on devices that already handle the IME.
Revert to the known-good single-signal logic. The narrow Android 10 case
#8295 targeted will be re-addressed via a single WindowInsets IME listener
that is verified on a real device matrix.
Reverts #8295. Drops the orphaned calc-keyboard-height spec (its unrelated
#8421 backup-ring/state changes are kept).
* fix(backup): chunk Android backup reads to survive 2MB CursorWindow
KeyValStore.get() read the value via cursor.getString(), which throws SQLiteBlobTooBigException once a row exceeds Android's ~2MB CursorWindow. The on-device backup blob crosses 2MB after roughly a year of normal use, so the backup was written but never readable — surfacing as "Error invoking loadFromDb: Java exception" and silently breaking the #7901 eviction-recovery path. Combined with the data store living in evictable WebView IndexedDB (#7892), this caused total data loss on Android. Electron/iOS use file-based backups and are unaffected.
- KeyValStore.get(): read in 256K-char substr() chunks + try/catch->default; also recovers already-written oversized rows.
- KeyValStoreInstrumentedTest: emulator regression (Robolectric can't reproduce the CursorWindow limit).
- local-backup.service: _loadAndroidDbValueSafe() logs blob size (not content) to the exportable log and degrades a read failure to "no backup" instead of an opaque crash.
Closes#8401
* test(backup): cover Android KV read-degradation + write-path bail (#8402)
Adds CI-runnable Karma specs for the resilience logic from the fix commit: read degrades to null (never throws), and the write path bails without overwriting when the existing-slot read fails (preserving both ring slots). To make the window.SUPAndroid singleton (undefined off-device, no DI seam) testable, the native calls now route through thin _nativeDbLoad/_nativeDbSave methods the specs spy. The real ~2MB CursorWindow limit remains covered by KeyValStoreInstrumentedTest (emulator-only).
* fix(backup): auto-restore on-device backup on blank mobile launch and harden durability (#7901)
Durability follow-ups to #7901 / #7892 (Android total data loss when the
WebView IndexedDB is evicted while the durable on-device backup survives).
- Mobile auto-restore: on a blank/evicted launch (no state cache, but a
usable on-device backup exists) restore the newest usable backup
automatically instead of behind a confirm dialog users routinely dismiss.
Only triggers for a genuinely empty live store (a deliberate in-app delete
leaves a valid empty state cache and is not auto-restored); falls back to
the informed prompt for corrupt/data-less blobs. Reports counts via snack.
- KeyValStore.onUpgrade no longer DROPs the table. It holds the durable
on-device backup (keys backup / backup_prev); a destructive upgrade would
wipe it the moment DATABASE_VERSION was bumped. Upgrades are now additive
(CREATE TABLE IF NOT EXISTS), preserving rows.
- Record the last successful local-backup time (after the meaningful-data
guard) and surface a "Last backup: <date>" line in the mobile Automatic
Backups settings, so no-sync users can see they're protected; the
timestamp also rides along in exported logs for eviction diagnosis.
Docs: correct 3.06-User-Data (Android backup is native app-private SQLite,
not WebView IndexedDB) and document the auto-restore + timestamp behavior.
Tests: auto-restore (usable / import-failure / corrupt-fallback / no-backup)
and getLastBackupTime in local-backup.service.spec; config-page spy updated.
https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA
* fix(backup): gate mobile auto-restore to empty op-log + non-synced backups (#7901)
Hardening from the multi-agent review of the previous commit's mobile
auto-restore. Two gates so silent auto-restore fires only in the
unambiguously safe case (eviction of a local-only user's store):
- Gate A (startup.service): only consider a backup restore when the op-log is
empty (getLastSeq()===0), not merely when the state-cache snapshot is absent.
A null cache alone can still have real ops the hydrator is concurrently
replaying; auto-restoring then was unnecessary and raced the hydrator's
replay against importCompleteBackup's destructive op-log replacement.
- Gate B (local-backup.service): only silently auto-restore a backup that had
NO sync configured. Restoring a synced backup resets lastServerSeq and writes
a clean-slate BACKUP_IMPORT, which can silently drop other devices' concurrent
work, so a synced backup now goes through the informed confirm prompt instead.
Also corrects the misleading comment (cited a non-existent in-app "delete all
data" flow) and adds tests: backupStrHasSyncEnabled units; auto-restore
data-less / synced / disabled-sync cases; config-page last-backup line
shown/omitted cases.
https://claude.ai/code/session_01E5YzMGtfMr33qQLjMM2SGA
* fix(backup): only advance last-backup time on a real write (#7901)
Review follow-ups to the #7901 durability work:
- _backup() recorded LAST_LOCAL_BACKUP unconditionally after the platform
writers, so the per-platform A3 near-empty guard (#7925) skipping a write
still advanced the "Last backup" time — falsely claiming "just backed up"
on exactly the post-eviction boot the guard protects. Platform writers now
return whether they actually wrote; the time is recorded only then. Adds a
spec for the skip case.
- Correct the LAST_LOCAL_BACKUP comment: the value is never passed to Log.*,
so it does not "ride along in exported logs" — it is only surfaced in
Settings.
- Document askForFileStoreBackupIfAvailable's blank-store precondition (empty
op-log) on the method itself, not just inline.
---------
Co-authored-by: Claude <noreply@anthropic.com>
The Android 12+ system splash drew the launcher icon on the launch
theme's colorBackground, which is white because AppTheme inherits the
static Theme.AppCompat.Light.* family (not DayNight). The loading icon
therefore showed a white background even in dark mode.
Set android:windowSplashScreenBackground to @color/windowBackground,
which has a values-night variant (#131314), so the splash tracks system
day/night and matches the WebView surface painted by WebHelper.
Stop forcing LAYER_TYPE_HARDWARE on the WebView in the shared
WebHelper.setupView() (used by both CapacitorMainActivity and the legacy
FullscreenActivity). The forced view-level hardware layer renders the
whole WebView into an off-screen GPU texture that can be released when
the app is backgrounded and is not always repainted on resume — the
likely cause of the blank/white screen reported after switching apps and
returning. GPU acceleration is unaffected: it comes from
android:hardwareAccelerated="true" in the manifest, not from this call.
The original setLayerType(LAYER_TYPE_HARDWARE) was added in 2021's
"feat: enable hardware acceleration" commit, likely under the
misconception that it was required to enable acceleration.
Note: the bug is device/GPU-specific and not locally reproducible, so
this is unverified hardening rather than a confirmed fix. The competing
cause — the WebView renderer process being killed under memory pressure
(no onRenderProcessGone handler) — is a separate, higher-risk follow-up.
Multi-review follow-up to the #8243 chronometer change:
- Write the wall-clock anchor before the accumulated/remaining value in both
services so a torn @Volatile read from the JS bridge thread under-reports
(caught by the keep-app-value recovery path) instead of double-counting the
since-last-anchor gap, which can be hours now that nothing re-anchors per
second.
- Correct the completion-runnable comment (re-arm guards backward clock
changes; Doze always fires with wall-clock remaining <= 0) and the
liveRemainingMs() KDoc that referenced the removed 1s tick.
- Update hasFocusNotificationStateChanged docs: the old rationale (native
ticks every second) is gone; weakening the 5s gate would reintroduce
per-push startForeground work. Cross-reference the two 5000ms thresholds.
- Use named args for the buildNotification boolean trio; drop a spec test
duplicated by the direct helper tests.
Since the foreground services survive task removal (#7818, v18.8.0), their
1-second Handler notification-update loops kept waking the CPU around the
clock after the app was swiped away — reported as battery drain in #8243.
- Tracking/FocusMode notifications now use setUsesChronometer so SystemUI
renders the ticking time; the services post no per-second updates.
- Focus countdown completion fires from a single wall-clock-checked delayed
callback (re-arms if Doze stalled it - same late-fire behavior as before).
- The web-side syncTimeSpentChanges$ effect no longer pushes tick-sized
timeSpent increments to the service; only jumps (manual edit, sync merge,
corrections) re-post the notification.
Known cosmetic trade-off: in deep Doze a finished countdown can briefly show
0:00/count-up until the deferred completion callback fires.
* fix(sync): defer example-op rejection until after hydration succeeds
Move _discardExampleTaskOps to after hydrateFromRemoteSync succeeds in
the file-based snapshot path so a hydration failure no longer drops
pending example-create ops while leaving the user without the remote
snapshot. Cancelled LWW re-uploads now stop the sync and report
UNKNOWN_OR_CHANGED to force a retry, and the snapshot of isNeverSynced
is threaded into retries so the second upload doesn't mis-classify a
still-fresh client. Adds ordering and download-LWW-cancel tests.
* fix(task-repeat-cfg): preserve user-edited overdue instances across more fields
skipOverdue cleanup previously only protected instances with notes or
attachments. Extend the unmodified-template check to also gate on
title, time estimate, tag membership (excluding TODAY_TAG), project,
and subtask templates so a yesterday-instance the user touched in any
of those ways survives until the user deletes it themselves. Adds
regression tests for completed subtasks (via hasSubtaskProgress) and
project moves.
* fix(tasks): preserve user-set deadline reminder across add-task-bar parses
The parser used to overwrite a manually-set deadline with a null
reminder option on every keystroke and the deadline dialog dropped
the previously-selected reminder when opened in select-only mode.
Track whether the active deadline came from short syntax and only
clear it when that syntax is removed, and thread the existing reminder
option through to the dialog so reopening doesn't wipe it. Also
hardens select-only submit against malformed time input and adds
parameterized tests for the bad-time set.
* chore(android): clarify WebView provider wording
Reword the storage-cleanup warning to reference the active WebView
provider app rather than "Android System WebView" so the message
remains accurate on devices where the provider is Samsung Internet,
Huawei Browser, etc.
* fix: address review data-preservation gaps
ForegroundServiceDidNotStartInTimeException was reported on Play Console
(Android 14+, OEM-throttled devices, app in foreground). The stop helpers
stopTrackingService()/stopFocusModeService() used Activity.stopService(),
which bypasses onStartCommand and tears the service down directly. When a
focus/tracking session is started and quickly cancelled, AOSP
bringDownServiceLocked() crashes the process if the service is destroyed
while fgRequired is still true, i.e. before its first startForeground().
Add an isStartPending flag to each service companion (set before
startForegroundService(), cleared after promotion in onStartCommand and in
onDestroy) and route stops through ACTION_STOP via startService() while a
start is pending or the service is running, so onStartCommand promotes the
service before tearing it down. Cold no-op stops still use stopService().
A backgrounded stop (startService disallowed) falls back to stopService()
only when no start is pending; a pending start is left for onStartCommand
to promote and a later foreground sync to stop, so the fallback can never
re-trigger the same crash.
When the Android System WebView provider's own data (e.g. variations seed)
is corrupted, WebView fails to start even though its version is current.
The user-facing fix is to clear the WebView provider app's storage, but the
INIT_FAILURE screen previously pointed only to 'update WebView' / the provider
picker, neither of which resolves this.
- Detail copy now leads with resetting WebView via Storage -> Clear storage and
reassures that Super Productivity tasks are stored separately and are safe.
- The INIT_FAILURE action button now deep-links to the WebView provider's App
Info page (openWebViewAppInfoPage), defaulting to the standard system WebView
package when the provider is unresolved; falls back to the picker, then Play
Store. Renamed the action enum to OPEN_WEBVIEW_APP_INFO_WITH_WARNING.
- Extracted providerPackageOrDefault (reused by webViewUpdatePageUrl) + test.
* fix(android): escape JS bridge args via JSONObject.quote (#7925)
`loadFromDb` interpolated the stored value into a single-quoted JS string
literal passed to `evaluateJavascript`. Beyond the security smell, this is
a real data-loss bug: `JSON.stringify` does not escape apostrophes, so a
backup blob containing one (e.g. a task titled "don't…") terminated the JS
literal and the load returned garbage — silently corrupting any restore
from `KeyValStore`.
Use `JSONObject.quote()` (already established in the file for
`emitForegroundServiceStartFailed`) for all three callback args, so values
containing `'`, `\`, newlines or `</script>` round-trip cleanly.
* feat(startup): log storage-persistence outcome on all branches (#7925)
`_requestPersistence()` was silent on native and on the `false` resolution
of `persist()`, so #7892-style "woke up blank" reports carried no signal
about whether the WebView store was actually persistent.
Always log `{persisted, granted, isNative, isElectron}` — including the
already-persisted branch, the persist-resolved branch (both true and false),
the error branch, and the no-`navigator.storage` branch. User-facing snack
gating is unchanged (still web-only, non-onboarding). Logging-only — no
behavioral change.
This is Track A1 in `docs/sync-and-op-log/sqlite-migration-followup.md`:
the diagnostics that decide whether the deeper protective steps
(near-empty write guard, SQLite migration) are worth the added complexity.
* docs(sync): refresh sqlite-migration followup after #7924 (#7925)
The followup backlog described the local-backup ring as TODO under A2,
but #7924 already shipped the periodic + app-private backup, two-generation
ring, empty-state write guard, and informed restore prompt. Bring the doc
in sync:
- Add the shipped local-backup work to "Where we are now".
- Replace the old A2 ("Periodic local auto-backup") with the narrower
remaining gap: a debounced data-change backup trigger to complement the
5-min timer.
- Add A3 (near-empty write-time overwrite guard) with a concrete starting
threshold and a fail-safe rationale, sequenced after A1 so the
diagnostics tune the threshold before it lands.
- New "Cross-cutting / hardening" section consolidating the items
surfaced by the #7924 review (Kotlin JS-bridge escaping — now done;
backup-date reader bridge for the restore prompt; robust restore on
degraded boot; last-backup visibility; onboarding nudge for no-sync
users).
* feat(local-backup): debounced on-data-change backup trigger (#7925)
The 5-min `interval()` was the only thing that drove `LocalBackupService._backup()`,
so a destructive event in the minutes before a WebView eviction could be lost
from the backup ring even though the live store had it.
Merge a `LOCAL_ACTIONS`-driven trigger into `_triggerBackupSave$` that fires
once after a 30s quiet period. Catches the typical "user made changes then
put phone down" pattern before the next periodic tick. `LOCAL_ACTIONS`
already filters out remote/hydration replays, and the existing empty-state
guard prevents writing a degraded post-eviction snapshot over a good
backup, so this strictly adds backup frequency — never spam.
Logic-level only — `_backup()` continues to early-return on non-target
platforms (web/PWA), so the trigger is safe to subscribe everywhere.
Closes A2 (remaining gap) in
`docs/sync-and-op-log/sqlite-migration-followup.md`.
* docs(sync): mark A1 + A2 shipped in sqlite-migration followup (#7925)
A1 (storage-persistence diagnostics) and A2 (debounced data-change backup
trigger) both landed this round. Update the suggested order and the A2
section so the doc accurately reflects what's left in Track A (just A3,
the near-empty write-time overwrite guard).
* feat(local-backup): near-empty write-time overwrite guard (A3, #7925)
The exact-empty guard in `_backup()` only catches a fully-degraded store.
The residual gap is a post-eviction boot that leaves the store near-empty,
the user adds 1-2 tasks before the 5-min timer fires, and the degraded
state then overwrites the good primary slot. The prev slot is still safe,
but the informed restore prompt only fires on a wholly fresh launch — so
without this guard the user has lost direct access to the better backup
until they uninstall/reinstall.
Add a per-platform near-empty guard in `_backupAndroid` / `_backupIOS`:
read the existing primary, compare task counts (active + young-archived +
old-archived via the new shared `countAllTasks` helper), and bail when a
< 3-task snapshot would clobber a >= 10-task existing backup. Electron is
unchanged — its rotated, timestamped backup chain isn't a single-slot
overwrite.
Threshold rationale: `summarizeBackupStr` counts archived tasks too, so
"near-empty" means the same thing on the read side (the restore prompt)
and the write side (this guard). Fail-safe — skipping a write only delays
capturing a real wipe, never loses data; the guard self-clears once the
store grows back past 3 tasks, so a legitimate bulk-delete is captured
on the next tick.
Marks Track A (#7925 / sqlite-migration-followup.md) complete.
* fix(android): JSONObject.quote() the sibling JS bridge callbacks (#7925)
`saveToDbCallback` / `removeFromDbCallback` / `clearDbCallback` still raw-
interpolated `requestId` into single-quoted JS string literals. The args are
nanoid strings today so it works — but only by caller hygiene. Mirror the
`loadFromDb` fix: quote all three so the bridge contract no longer depends
on what the caller happens to pass.
Compress the `loadFromDb` rationale comment in the process — the file-level
intent now lives on one line near the cluster.
* refactor(local-backup, startup): trim Track A code per multi-agent review
Two independent reviewers flagged the same set of cleanups on the Track A
commits (#7925). Applying the high-confidence ones:
- Drop `_escapeAndroidNewlines` + its two call sites. The Kotlin bridge fix
(#7925, 663d747b4) now JSON-escapes newlines on the way out, so the JS-side
workaround replaces nothing on any post-fix write. Removes the awkward
"raw vs escaped" split in `_backupAndroid`.
- Hoist the A3 skip-and-log block into one private `_guardNearEmptyOverwrite`
helper so the warn template can't drift between Android and iOS. Keep
`_isNearEmptyOverwrite` as the pure predicate (the spec pins it).
- Tighten the A2 comment: `bulkApplyOperations` / `loadAllData` actually do
transit `LOCAL_ACTIONS` (they aren't tagged `meta.isRemote`). Behaviour
is still correct because the empty-state + A3 guards handle degraded data,
but the previous comment overstated upstream filtering.
- Cut the multi-paragraph comment blocks around `DATA_CHANGE_BACKUP_DEBOUNCE`,
the A3 constants, `_isNearEmptyOverwrite`, and the `_backup()` guard. Keep
the issue refs; drop the prose that paraphrased the next line of code.
- Inline the `context` object spread in `_requestPersistence` — three log
calls on adjacent lines didn't need a hoisted bag of fields.
No behavioural change. 21/21 local-backup specs + 18/18 startup specs green.
* fix(local-backup): don't overwrite a good backup with empty state (#7901)
After a WebView IndexedDB eviction the live NgRx store can boot empty.
The 5-min local-backup timer would then clobber the last good copy in
durable storage (Android SQLite KeyValStore, iOS file, Electron file)
with nothing. Gate _backup() with hasMeaningfulStateData() so empty/
degraded state can never overwrite a good backup, mirroring the existing
snapshot/compaction empty-overwrite guard.
* feat(local-backup): keep a second backup generation on mobile (#7901)
A single overwritten row/file is fragile: one bad or corrupt write cycle
can erase the only copy. Add a two-generation ring on the single-slot
mobile platforms (Android KeyValStore key, iOS file) — promote the
current backup to a 'prev' slot before overwriting, and restore from the
newest usable slot, falling back to prev when the primary is corrupt or
empty. Electron already keeps rotated, timestamped backups, so it is left
unchanged.
Adds backup-ring.util (isUsableBackupStr / selectBestBackupStr) with full
unit coverage; the iOS write is split behind _writeIOSFile so the promote
ordering is testable without the Capacitor Filesystem proxy.
* fix(android): store a real timestamp in KeyValStore KEY_CREATED_AT (#7901)
ContentValues binds "time('now')" as a literal string rather than letting
SQLite evaluate it, so every row's KEY_CREATED_AT held a constant text
value instead of a write timestamp. Store System.currentTimeMillis() so
the column is actually usable for ordering/debugging.
* fix(local-backup): harden restore paths from multi-agent review (#7901)
- loadBackupIOS: return '' instead of throwing when no usable backup
exists. askForFileStoreBackupIfAvailable runs from the fire-and-forget
_initBackups() at startup, so a throw became an unhandled rejection; ''
degrades to the existing import-error snack and mirrors loadBackupAndroid.
- Consolidate the Android newline escape into loadBackupAndroid (the single
escape site) and drop the now-redundant re-escape + log line at the call
site, removing a full-string pass on the restore path.
* feat(local-backup): restore fuller generation, show contents (#7901)
Two restore-path improvements (issue #7901 item 4):
- selectBestBackupStr now prefers the ring generation carrying MORE
data when both slots are usable (tie -> primary). After an eviction
the live store can boot near-empty and a 5-min backup may write that
degraded state to the primary; the full copy survives in prev, and
restore must surface the full one rather than the newer-but-smaller.
- The mobile restore prompt loads the backup first and names its task
and project counts (new RESTORE_FILE_BACKUP_MOBILE string), so a user
never blindly discards the only copy of their data. Falls back to the
generic prompt for an unparseable blob.
Note: a 'defer backups until the restore decision resolves' guard was
considered but is already structurally guaranteed -- init() (the only
thing starting the 5-min timer) runs after the synchronous restore
confirm in _initBackups, so the timer cannot fire before the decision.
* fix(local-backup): restore the newest backup, not the larger one (#7901)
Multi-agent review of d1e03e550a flagged the restore-time "prefer the
fuller generation" heuristic as unsafe: a legitimate bulk-archive or
delete makes the newer generation smaller, producing the SAME ring shape
as a post-eviction degraded primary. Preferring the larger slot would
then silently restore the older generation and resurrect removed tasks --
a worse, more common failure than the narrow eviction gap it closed.
- Revert selectBestBackupStr to newest-usable-wins (drop backupWeight and
the size comparison). The prev slot stays a fallback for an empty/corrupt
newest slot only. The informed prompt (below) is what protects the
eviction case now: the user sees a substantial backup and accepts it.
- Fix the prompt counts (summarizeBackupStr): include archived tasks so a
heavily-archived backup doesn't read as empty, and exclude the
always-present INBOX project so it matches hasMeaningfulStateData.
- Reword RESTORE_FILE_BACKUP_MOBILE: drop the self-contradictory "NO DATA
... but a backup with N tasks" framing, and use label-style counts
("tasks: N") so it reads correctly for a count of 1 (no ICU plurals).
Deferred follow-ups from the review: a write-time "suspicious shrink"
guard (needs threshold design) and a backup date in the prompt (needs an
Android bridge change to surface KEY_CREATED_AT; iOS has stat mtime).
The WebView block / init-failure screen was the only activity using a theme
with an action bar (AppTheme / Theme.AppCompat.Light.DarkActionBar), so the app
title bar overlaid the message. Its content also drew under the status bar on
edge-to-edge devices (enforced and not opt-out-able at targetSdk 36), clipping
the title's first line with no way to scroll it into view.
Switch the activity to the existing AppTheme.NoActionBar and inset the scroll
container by the system bars + display cutout so the message is fully visible.
Reported in issue #7840.
* fix(android): recover focus/pomodoro timer after app swipe (#7855)
After #7818 kept the foreground services alive across task removal, the
focus-mode timer still reset and its notification closed on reopen, while
time tracking survived. Two causes:
1. On cold start, syncFocusModeToNotification$ computed wasFocusModeActive
from the startWith(null) seed as (undefined \!== null) === true, so the
first idle emission fired stopFocusModeService() and tore down the
surviving native notification.
2. Focus mode had no native read-back/recovery (unlike time tracking), so
the idle store never re-adopted the still-running native countdown.
Mirror the time-tracking recovery design:
- FocusModeForegroundService: mirror live timer state into the companion
object + liveRemainingMs(); cleared on stop.
- JavaScriptInterface.getFocusModeElapsed() returns JSON or "null"
(task title intentionally omitted — no user content crosses the bridge).
- restoreFocusSessionFromNative action + reducer rebuild the timer
(elapsed = duration - remaining, startedAt = now - elapsed; Flowtime =
duration 0) and derive mode from duration so a restored Flowtime session
is not auto-completed on the next tick.
- recoverFocusSession$ recovers on cold start + resume, gated on an idle
store and settled hydration; the wasFocusModeActive seed bug is fixed.
* fix(android): trigger focus recovery on resume edge only, not store changes
recoverFocusSession$ put selectTimer in its combineLatest trigger list, so
every idle-store emission re-read native state. Ending a session
(cancel/complete) emits an idle store while the native stop
(stopFocusModeService -> stopService -> onDestroy) is still in flight, so
getFocusModeElapsed() saw isRunning === true and re-adopted the session that
just ended -- resurrecting a cancelled session or double-logging a completed
one.
Sample selectTimer via withLatestFrom instead of using it as a trigger, so only
a genuine resume/cold-start edge can trigger recovery.
The Capacitor 8 edge-to-edge migration put the native startup overlay
(android.R.id.content) behind the system navigation bar, so its FAB and
input bar rendered too low vs the web add-task button/bar they hand off
to. Lift them by the navigation-bar inset.
Use the WebView's measured bottom inset (overlay bottom - WebView bottom)
rather than navigationBars(): the latter is a system value that does not
match the inset the edge-to-edge plugin actually applies, and the two
diverge differently on gesture-nav vs 3-button-nav devices (too high on
one, too low on the other). Mirroring the WebView's real geometry is the
same safe area the web content uses, so it is device-independent.
* test(e2e): raise timeouts for two CI-flaky waits
Both timeouts repeatedly hit their limit on saturated scheduled runners
without indicating a real product regression:
- supersync.page.ts:781 — final sync-state icon waitFor went 10s → 30s.
The race above it already consumed a 30s budget, so falling back to
10s here is too tight when the runner is hot (e.g. SuperSync 5/6 in
run 26514574130, "Client A can migrate multiple times" failure).
- repeat-task-day-change #6230 — post-midnight visibility went 30s → 60s.
Even with the focus-event fallback added in 46ac873570, the 1s tick
+ debounce + sync chain can still exceed 30s under contention.
* fix(android): keep foreground services alive across task removal
The focus-mode and tracking foreground services overrode onTaskRemoved
to stop themselves when the app was swiped from recents, which defeats
the purpose of running them as foreground services. Remove the
overrides so the native countdown continues ticking and the notification
persists after task removal.
Refs #7818, #4513
* fix(theme): stabilize Android keyboard-height tracking
Per-event commits during the Android IME open animation sampled
partial keyboard amounts from `window.innerHeight - visualViewport.height`
(layout-viewport adjustResize and visual-viewport resize fire at slightly
different times), parking the global add-task bar mid-page. Debounce the
open path 200ms so only the final value lands; commit synchronously when
the value falls back to zero so the bar drops the moment the IME is gone.
* fix(theme): prevent white flash on Android keyboard resize
The Android WebView surface defaulted to white, so adjustResize keyboard
animations briefly exposed it before the page repainted at the new size —
jarring in dark theme. Paint the surface in the theme background: a
values/values-night color resource provides the cold-start default, and a
new NavigationBar.setWebViewBackgroundColor push keeps it in sync on live
theme switches (the activity is not recreated since uiMode is in
configChanges).
Also promote the full-viewport gradient backdrop to its own compositor
layer as a first-pass mitigation for resize choppiness, pending deeper work.
Not yet verified on-device.
* docs(android): plan to smooth soft-keyboard resize jank
Research + multi-reviewed plan for the remaining keyboard-resize choppiness
(the white-flash half is already fixed in 80b08f0e96). Root cause: adjustResize
resizes the WebView window per frame (WebView is excluded from Chrome 108's
visual-viewport fix). Recommends a KISS core — flip only CapacitorMainActivity
to adjustNothing + reuse the existing visualViewport/--keyboard-height model and
scroll-into-view — gated behind a baseline trace, with VirtualKeyboard API and
CSS containment kept as contingencies behind proven need.
* perf(theme): dedupe Android keyboard-visibility emissions
The native OnGlobalLayoutListener pushes isKeyboardShown$ on every layout pass
(every frame of the IME slide), so the subscriber rewrote <body> classes and
re-triggered change detection each frame. distinctUntilChanged collapses it to
actual show/hide transitions.
* docs(android): correct keyboard-resize plan for min-Chrome-107
Implementation review found MIN_CHROMIUM_VERSION=107, but WebView only
auto-resizes the visual viewport for the IME at ~Chrome 139. So a static
adjustNothing flip would leave Chrome 107-138 with no keyboard-height signal
(inputs silently covered). Corrected the plan: VirtualKeyboard API is required
(not optional), the switch must be runtime-gated via a native setSoftInputMode
method keeping adjustResize as the fallback, and the cheap containment route is
now Phase 1 (try first) with the bigger flip as Phase 2. distinctUntilChanged
win shipped (f486496b7b).
* style(theme): drop no-op keyboard backdrop compositing
The will-change/backface-visibility on body::before was added to reduce
keyboard-resize choppiness, but review showed it's a no-op: the backdrop
resizes every frame so it re-rasterizes regardless of layer promotion, while
the hint allocates an always-on compositor layer on every platform. The
white-flash fix (WebView background color) is unaffected.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
The focus-mode and tracking foreground services overrode onTaskRemoved
to stop themselves when the app was swiped from recents, which defeats
the purpose of running them as foreground services. Remove the
overrides so the native countdown continues ticking and the notification
persists after task removal.
Refs #7818, #4513
* fix(android): restore share title derivation and dedupe shared tasks
Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from
edb102534e, so the share handler stopped sending the page subject and
defaulted the title to the literal "Shared Content". The frontend's
subject -> title -> derived title chain then always fell through to that
placeholder, producing blank-looking shared tasks.
- Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent
so the frontend can derive a meaningful title from the URL or note.
- Ignore empty/blank shared text instead of creating a useless task.
- Skip handleIntent() on Activity recreation (config change) so the same
share Intent isn't re-processed into a duplicate task.
- Extract buildTaskTitle/readableUrl as pure functions with unit tests
and guard the effect against empty payloads.
* fix(snack): scale error/warning snack duration with message length
Long error messages (e.g. multi-sentence sync errors) were auto-dismissed
after a fixed 8s, too short to read. Error/warning snacks now stay visible
proportional to message length (~90ms/char), clamped to 10-30s.
* feat(tasks): skip undo snack when deleting a blank task
A sub task or parent task with an empty title and no data (notes, time,
estimate, attachments, issue link, reminder, repeat, scheduling,
deadline, non-blank sub tasks) no longer shows the undo-delete snack.
* fix(sync): include archive data in REPAIR operations
validateAndRepairCurrentState built the REPAIR op from the synchronous
getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld
(archives live in IndexedDB, not NgRx state). The resulting REPAIR op
carried empty archives, so every other client that applied it
overwrote its archive with nothing — wiping archived tasks on all
devices except the one that ran the repair.
- Use getStateSnapshotAsync() so the REPAIR op carries real archives.
- Extend the empty-archive overwrite guard in
ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair
(previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth.
* test(sync): add archive REPAIR round-trip integration test
Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService
and ArchiveOperationHandler against real IndexedDB to verify archive data
survives the REPAIR-op round-trip:
- getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not
- archive round-trips from client A's IndexedDB through a REPAIR op into a
fresh client B's IndexedDB
- a REPAIR op carrying empty archives no longer wipes a client that has
archive data (empty-archive guard regression)
* perf(sync): skip archive IndexedDB reads when post-sync state is valid
validateAndRepairCurrentState validated the full async snapshot (two
IndexedDB archive reads + structured-clone deserialization) on every
Checkpoint D, even when state was valid and no repair was needed. It now
validates the cheap synchronous snapshot first and only loads the async
snapshot (with archives) when a repair is actually required — the rare
path. The REPAIR op still carries archive data.
Also addresses multi-review follow-ups:
- archive-operation-handler: reword the empty-archive guard comment so it
no longer over-promises reconciliation for REPAIR ops.
- archive-repair-roundtrip test: add isPersistent to the applied-op meta
to match the real applier; scope the file docstring accurately.
* fix(task-repeat-cfg): schedule inbox task for today when made recurring
When an Inbox task (no dueDay) was made repeatable via the dialog with a
recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence
branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from
task.created as a fallback, so a task created today looked already scheduled
and dueDay was never set.
Key the decision on task.dueDay directly. Skip timed tasks and tasks that
already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and
timed scheduling is handled by addRepeatCfgToTaskUpdateTask$.
Closes#7725
* fix(tasks): correct monthly first/last-day recurrence anchoring
The "Every month on the first day" and "Every month on the last day"
quick settings scheduled the first task instance in the past. Both
presets produced a backdated startDate (1st of the current month;
hardcoded January 31), which getFirstRepeatOccurrence returns verbatim
for monthly recurrences.
- MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month
that is today or later.
- MONTHLY_LAST_DAY anchors startDate to the current month's last day
and sets a new monthlyLastDay flag, so the occurrence engine clamps
to month-end every month regardless of startDate's day-of-month.
- _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a
config leaves the preset (CUSTOM mode has no control for it).
Closes#7726
* fix(task-repeat-cfg): re-anchor start date after instance deleted
When the user moved a repeat config's startDate earlier after deleting
its only live task instance, the stale lastTaskCreationDay anchor kept
suppressing every projected/created instance between the new startDate
and the old anchor.
rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay
when a live task instance existed (the #7423 fix) — it returned early
before the re-anchoring when there was none. Hoist the
isStartDateMovedEarlier detection above that early return: when no live
instance exists but startDate moved earlier, re-anchor to the day
before the new first occurrence so it and every following day is
created and projected fresh.
Closes#7724
* test(task-repeat-cfg): cover startDate re-anchor with no live instance
Add coverage for the #7724 fix beyond the effect unit test:
- Selector integration tests: feed a config re-anchored to the day
before the new startDate through selectTaskRepeatCfgsForExactDay and
assert it projects the new startDate and every following day, while
still excluding the anchor day and earlier. Also documents that the
stale anchor suppresses the gap days.
- E2E reproduction (recurring-move-start-date-earlier-no-instance):
create a recurring task, delete its live instance, move startDate
earlier via a transparent projection, and assert the new days appear
in the planner. Verified to fail on pre-fix code.
* feat(sync): move clientId from pf into SUP_OPS for atomic rotation
Migrate the sync clientId out of the legacy `pf` IndexedDB database into
`SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins
the atomic transaction in `runDestructiveStateReplacement`, so destructive
flows (clean-slate, backup-restore) rotate it atomically with
OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database
two-phase commit.
- ClientIdService rewritten: SUP_OPS-backed via an independent connection,
inline one-time pf->SUP_OPS migration, error-aware resolver. Read
failures propagate (getOrGenerateClientId never mints a fresh id over a
transient error — that would orphan the device's non-regenerable
identity); loadClientId never throws.
- Delete withRotation, generateNewClientId and the CAS/rollback machinery.
- Extract pure generateClientId() + isValidClientIdFormat() into
core/util/generate-client-id.ts.
- pf becomes a read-only, one-time migration source (never written/deleted).
Closes#7732
* fix(sync): dedup SUP_OPS connection open in ClientIdService
Address multi-agent review findings on the clientId migration:
- _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise;
concurrent cold-start callers previously each opened their own
SUP_OPS connection, leaking all but the last.
- _putClientIdIfAbsent() collapsed to a single tx.done / exit point.
- db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore
count assertions were stale and failing after the schema bump.
- operation-log-migration.service.ts: correct a misleading comment about
the genesis-op clientId fallback.
* refactor(sync): align ClientIdService SUP_OPS open with in-house idiom
Re-review of the connection-leak fix recommended matching
OperationLogStoreService._ensureInit's pattern:
- _getSupOpsDb() clears the in-flight promise in .catch (failure only)
instead of an unconditional finally — the resolved handle lives in
_supOpsDb, so the promise field is pure in-flight coordination state.
- close/versionchange handlers now also null _supOpsDbPromise, so a
stale (closed) connection is never re-handed-out.
- Add a regression test asserting _openSupOpsDb runs exactly once for
concurrent cold-start callers.
* docs(sync): link the single-connection follow-up to #7735
Reference the tracked follow-up issue from the ClientIdService JSDoc
and the plan's out-of-scope section, so the deliberate trade-off (one
extra SUP_OPS connection) is traceable rather than forgotten.
* test(sync): update ClientIdService spies for getOrGenerateClientId
The op-log capture effect now resolves the clientId via
getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()).
These two specs still mocked only loadClientId, so the effect called an
undefined method, captured no op, and 4 tests failed in the full suite.
- task-done-replay.integration.spec.ts
- operation-log-lock-reentry.regression.spec.ts
* test(sync): open SUP_OPS versionless in e2e read helpers
DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at
the hardcoded old version 5 to read state after the app had already
upgraded it to v6, throwing VersionError. Open versionless instead —
matches the ~10 other e2e files that already do, and is future-proof
against the next schema bump.
- migration/legacy-data-migration.spec.ts (x2)
- sync/supersync-legacy-migration-sync.spec.ts
- sync/webdav-legacy-migration-sync.spec.ts
- recurring/invalid-clock-string-bug-7067.spec.ts
Merge an internal wake-up subject into GlobalTrackingIntervalService.tick$
so a tick can be force-emitted with a cap that prevents over-crediting
when the WebView was suspended past session end. Wire the capped tick
into focus-mode pause/complete bridge callbacks, plumb a wall-clock-correct
completedDuration onto completeFocusSession, and have the pause reducer
freeze elapsed via updateTimer.