Commit graph

371 commits

Author SHA1 Message Date
Johannes Millan
c18055a341
fix(android): stop the widget labelling a stale list as Today (#9098) (#9118)
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.
2026-07-17 16:06:06 +02:00
Johannes Millan
519ea02167
fix(android): keep material icons aligned with system font scaling (#8992)
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
2026-07-14 12:38:12 +02:00
Johannes Millan
2bc3641cfa 18.14.0 2026-07-10 17:23:35 +02:00
Johannes Millan
bf710637d2
feat(android): add home screen widget for today's tasks (#8737)
* 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>
2026-07-03 18:32:34 +02:00
Johannes Millan
5c9c0c2131 18.13.1 2026-07-02 16:41:38 +02:00
Johannes Millan
78e546ff6a 18.13.0 2026-07-02 15:25:39 +02:00
Johannes Millan
b692eb72ce
feat(rate-dialog): calm, recurring, win-timed store rating prompt (#8704)
* 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>
2026-07-02 13:52:10 +02:00
Johannes Millan
87846ad83d
fix(android): keyboard + status-bar follow-ups for edge-to-edge (#8508) (#8548)
* 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.
2026-06-23 11:57:49 +02:00
Johannes Millan
bb3ed74480
feat(android): migrate edge-to-edge to built-in SystemBars (#8543)
* 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.
2026-06-22 16:07:06 +02:00
Johannes Millan
db6b17d25b 18.12.1 2026-06-22 13:37:54 +02:00
Johannes Millan
ad149e138c
docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause (#8528)
* 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>
2026-06-22 13:27:45 +02:00
Johannes Millan
350536e7ed 18.12.0 2026-06-20 17:17:50 +02:00
Johannes Millan
75da17ec44 fix(android): paint window decor behind WebView to kill keyboard white flash
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.
2026-06-20 15:36:23 +02:00
Johannes Millan
0150ee23b3
feat(focus-mode): surface session-done + notify on countdown completion (#8475)
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.
2026-06-18 17:33:02 +02:00
Johannes Millan
2cf67cee71 18.11.0 2026-06-18 13:37:47 +02:00
Johannes Millan
43444e6b53 fix(android): revert #8295 keyboard tracking that mispositioned add-task bar
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).
2026-06-16 17:00:55 +02:00
Johannes Millan
d6475999e2
fix(backup): chunk Android backup reads to survive 2MB CursorWindow (#8402)
* 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).
2026-06-15 17:51:50 +02:00
Wadim
7d4c4b1877
fix(android): keep add-task input above keyboard on Android 10 (#8295)
* fix(android): keep add-task input above keyboard on Android 10

* fix(android): keep add-task input above keyboard on Android 10 - address comments
2026-06-15 14:11:50 +02:00
Johannes Millan
b12f72899f
fix(backup): auto-restore on-device backup on blank mobile launch and harden durability (#7901) (#8388)
* 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>
2026-06-15 14:05:23 +02:00
Johannes Millan
5d2abbf847 18.10.0 2026-06-12 13:11:09 +02:00
Johannes Millan
f4e91399b4 fix(android): give launch splash a day/night background
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.
2026-06-12 12:57:00 +02:00
Johannes Millan
6c45246750 fix(android): prevent WebView white screen on resume
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.
2026-06-10 20:39:23 +02:00
Johannes Millan
df27a1311d fix(android): bias torn bridge-thread reads safe, fix stale timer docs
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.
2026-06-10 11:54:45 +02:00
Johannes Millan
e428e402ce fix(android): render timer notifications via chronometer, drop 1Hz loops
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.
2026-06-10 11:32:27 +02:00
felix bear
803942f3ef
fix(android): support Vanadium WebView #8005 (#8082)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 11:27:41 +02:00
johannesjo
1f24d5572d 18.9.1 2026-06-05 22:45:35 +02:00
Johannes Millan
920071f5ce 18.9.0 2026-06-05 20:30:11 +02:00
Johannes Millan
09437a7e7a
Preserve user edits across sync, repeat-cleanup, and deadline-parse paths (#8001)
* 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
2026-06-04 23:44:23 +02:00
Johannes Millan
7c948a4d51
fix(android): prevent FGS start-timeout crash from start/stop race (#7991)
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.
2026-06-03 17:45:17 +02:00
Johannes Millan
aade607e81
fix(android): guide users to clear WebView storage on init failure (#7978)
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.
2026-06-03 16:03:00 +02:00
Johannes Millan
941f4f28b2 fix(android): route hardware back through Capacitor 2026-06-03 15:58:07 +02:00
Johannes Millan
64d3219d3a
feat(local-backup): Track A safeguards for #7925 (#7932)
* 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.
2026-06-02 11:58:36 +02:00
Johannes Millan
7483ba241b
feat(local-backup): harden mobile backups against eviction (#7901) (#7924)
* 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).
2026-06-01 21:40:10 +02:00
Johannes Millan
3d1e173f67 fix(android): drop action bar and inset system bars on WebView block screen
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.
2026-06-01 16:33:18 +02:00
Johannes Millan
90288b885e
fix(android): recover focus/pomodoro timer after app swipe (#7855) (#7866)
* 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.
2026-06-01 16:15:56 +02:00
Johannes Millan
24725c9360 18.8.0 2026-05-30 01:19:30 +02:00
Johannes Millan
2baf5fc512 fix(android): align startup overlay FAB/bar with web via WebView inset
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.
2026-05-29 14:55:22 +02:00
Johannes Millan
3c01d585ca fix(android): auto-recover from transient WebView init failures
- refactor(android): unify automatic and manual WebView relaunch paths
- fix(android): add retry action to WebView init-failure screen (#7840)
- refactor(android): extract shared WebView recovery helper
- fix(android): auto-recover from transient WebView init failures
2026-05-29 14:55:22 +02:00
Johannes Millan
9259e0051c build(android): register edge-to-edge support plugin in gradle 2026-05-28 23:54:51 +02:00
Johannes Millan
f10d0e9d48
Android soft-keyboard: fix dark-theme white flash on resize (#7839)
* 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>
2026-05-28 16:56:21 +02:00
Johannes Millan
cc4776a715 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
2026-05-28 13:58:19 +02:00
Johannes Millan
334b14aa26 feat: migrate to capacitor 8
- fix(sync): unblock @sp/sync-core/@sp/sync-providers typecheck under vitest 4
- chore(mobile): pin @capacitor/keyboard to 8.0.1
- chore(mobile): migrate to Capacitor 8
- Merge branch 'feat/issue-7749-3341d1'
- docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook
- test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW
- chore(plugins): re-bundle document-mode and document Stage A path
2026-05-23 20:42:00 +02:00
Johannes Millan
4212ed4b0d 18.7.0 2026-05-23 12:53:32 +02:00
Johannes Millan
508998c6a1
Improve on sync (#7736)
* 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
2026-05-22 17:49:25 +02:00
spike
9a9f2c116e fix(android): credit wall-clock time on focus-mode bridge events
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.
2026-05-16 23:46:59 +02:00
johannesjo
605e5f6d52 18.6.0 2026-05-16 14:26:17 +02:00
johannesjo
518ceb6f20 fix(android): prevent restored legacy launch mode 2026-05-14 12:41:09 +02:00
Johannes Millan
2ae4666955 fix(android): handle WebView init failures safely 2026-05-11 20:32:12 +02:00
Johannes Millan
348dc47e3e fix(android): handle foreground service failures 2026-05-11 14:10:51 +02:00
Johannes Millan
2b06d0b886 18.5.0 2026-05-09 20:27:05 +02:00