* 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.
Show which detection path produced the BLOCK (USER_AGENT, PACKAGE,
UNKNOWN, or INIT_FAILURE) on the WebView block screen so user-submitted
screenshots for issue #7518 are actionable. Tag forced non-version
blocks with INIT_FAILURE so they no longer falsely report a
version-detection source. Adds tests for parseMajorVersion(String?).
Builds on the prior authoritative-vs-fallback fix with three layered
recovery mechanisms so users hit by a false BLOCK are never locked out
of their data:
- Last-known-good auto-recovery: persist the highest WebView version
that has ever loaded the app on this device. A later transient
mis-read that drops below MIN_CHROMIUM_VERSION is downgraded to WARN.
- "Try anyway" override: third button on the block screen opens an
AlertDialog with an explicit risk acknowledgment (crashes, render
failures, possible data loss). Confirming persists an override and
relaunches the app. Hardened against tapjacking via
filterTouchesWhenObscured on both the activity and dialog window.
- Override auto-clears once a healthy version is detected, so a future
genuine block is not silently bypassed.
Also tightens the UA regex (drops the misleading Safari Version/X
fallback that always reads "4.0" and would falsely block) and adds
diagnostic logging gated by Log.isLoggable for field debugging.
Tests: 12 unit tests covering statusForVersion branches, all
applyOverrides paths, and parseMajorVersion edge cases.
Refs #7229
* fix(issue): prevent crash from orphan issueProviderId (#7135)
The Jira image-headers effect in task-detail-panel subscribed to
selectIssueProviderById without an error handler, so a task with an
issueProviderId pointing at a deleted provider (e.g. after sync
convergence where taskIdsToUnlink didn't cover all local tasks)
propagated the selector throw to Zone.js as a crash dialog. Wrap the
inner selector observable in catchError that logs and falls back to
of(null); the downstream jiraCfg?.isEnabled guard handles the fallback.
Also drop IssueLog.log(issueProviderKey, issueProvider) from the
throwing variant of the selector: providers may carry credentials
(host, token, apiKey) and IssueLog history is exportable.
* fix(focus-mode): sync tray countdown with in-app timer during breaks
Tray title was rebuilt from a cached currentFocusSessionTime that only
refreshed when CURRENT_TASK_UPDATED fired. addTimeSpent is gated on an
active current task, so during focus-mode breaks or task-less focus
sessions the cache froze while the in-app timer kept ticking.
Add the tick action to taskChangeElectron$ so the cache refreshes every
second whenever the focus timer is running.
Fixes#7278
* fix(ci): restore GitHub Actions SHA pins undone by 0e9218bd68
Commit 0e9218bd68 silently reverted PR #7212 (github-actions-minor group
bump) along with its stated sync/client-id work. This restores the 15
workflow files to their pre-revert state.
Actions restored to newer pinned SHAs:
- actions/upload-artifact v7.0.0 -> v7.0.1
- step-security/harden-runner v2.16.1 -> v2.17.0
- softprops/action-gh-release v2.6.1 -> v3.0.0
- signpath/github-action-submit-signing-request v2.0 -> v2.1
- anthropics/claude-code-action v1.0.89 -> v1.0.93
- docker/build-push-action v7.0.0 -> v7.1.0
- easingthemes/ssh-deploy v5.1.1 -> v6.0.3
* fix: restore i18n, UI, and docs work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following work alongside its stated
sync/client-id changes. Files where later master commits (fec7b25f23, etc.)
already re-applied the reverted work are intentionally left untouched.
Restored:
- #7232 docs/long-term-plans/location-based-reminders.md (513 lines)
- #7199 Romanian i18n phase 3 (ro.json + ro-md.json, ~1168 lines)
- #7049 Polish translation improvements
- #7143 planner component styling (4 scss files)
- #7211 add-task-bar preserve time estimate when typing title
- #7208 task.reducer roll-up estimates for added subtasks
- #6767 focus-mode pomodoro reset button
- #7205 plugin-dev github-issue-provider TOKEN description
- #7231 mobile-bottom-nav FAB fix
- a4fe03272 iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown)
- 309670db3 ShortSyntaxEffects undefined guard
- 667a7986f Dropbox PKCE auth comment/behavior
* fix(electron): restore electron + e2e work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following electron/e2e work.
Files already re-fixed by later master commits are left as-is:
- e2e/tests/sync/supersync-archive-conflict.spec.ts (de33234976 + 191d129ff3)
Restored:
- e8a3e156eb fix(electron): Linux autostart IDB backing-store recovery
(re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring)
- 5ce78a5b63 fix(electron): macOS Cmd+Q / Dock > Quit hang
(setIsQuiting + before-quit delegate to close-handler)
- 46e0fa2d01 fix(sync): FILE_SYNC_LIST_FILES IPC contract
(electronAPI.d.ts + local-file-sync + preload + ipc-events)
- ea1ef16307 fix(android): session-only SAF permissions on OEM devices
- 8865dc0a50 test(e2e): supersync parallel-worker stampede guard
(SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop)
- af7c7687e2 test(e2e): block WS-triggered downloads in non-WS specs
- 265b44db5d test(e2e): premature waitForURL on daily-summary
(this is literally the fix the bad commit's message claimed to add)
Conflict resolutions:
- e2e/utils/supersync-helpers.ts: kept the refined getDoneTaskElement
checks from d64014d086 (later than c558bcab5e) while restoring the
goto retry loop from 8865dc0a50.
- electron/start-app.ts: unioned imports (setIsQuiting +
clearStaleLevelDbLocks from theirs, fs from ours).
* fix(sync): restore sync-core work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted parts of several sync fixes. Most
sync-core reverts have already been re-addressed differently on master
by later commits (1f5184f6e7, 05cd875dd6, 09f5ced2c9, 7df43358ab,
d9158d6adb, 32dbc95ed9, 8c3b08e016, f89fe1ebc3) — those files are
intentionally left untouched to avoid reverting master's newer work.
This PR restores only the pieces that are genuinely still missing:
- e8a3e156eb fix(electron): IDB backing-store autoreload (in-app piece)
operation-log-hydrator.service.ts + .spec.ts (the electron/clear-
stale-idb-locks.ts piece was restored in the prior commit)
Plus three low-risk documentation/cleanup restorations:
- operation-sync.util.ts — add "Nextcloud" to isFileBasedProvider JSDoc
- dropbox.ts — restore improved _getRedirectUri JSDoc (667a7986fb)
- dialog-get-and-enter-auth-code.component.ts — restore isNativePlatform
comment explaining why manual code entry flow is used (667a7986fb)
- file-adapter.interface.ts — remove stray "// NEW" comment (46e0fa2d01)
Intentionally NOT restored (master's newer work covers or supersedes):
- sync-trigger.service.ts / sync.effects.ts (05cd875dd6)
- sync-wrapper.service.ts (1f5184f6e7)
- sync-errors.ts (1f5184f6e7 re-added LegacySyncFormatDetectedError)
- file-based-sync-adapter.service.ts + spec (1f5184f6e7 + d9158d6adb)
- file-based-sync.types.ts (1f5184f6e7)
- operation-log.const.ts (32dbc95ed9 bumped IDB_OPEN_RETRIES to 5)
- dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
* test(e2e): update Show/hide task panel button label
The TOGGLE_DETAIL_PANEL translation was renamed from "Show/Hide
additional info" to "Show/hide task panel" in dd789d2, but e2e
selectors still referenced the old string, causing every test that
opens the task detail panel to time out.
https://claude.ai/code/session_011mX4Pr24S42svmA5j7fRux
* 18.2.1
---------
Co-authored-by: Claude <noreply@anthropic.com>
Both FocusModeForegroundService and TrackingForegroundService declare
android:foregroundServiceType="specialUse" in the manifest. The 2-arg
startForeground() overload already falls back to the manifest type on
Android 14+, but passing the type explicitly via ServiceCompat is
Google's documented best practice and removes OEM-dependent fallback
ambiguity.
Extract the Build-gated call into a Service.startForegroundSpecialUse
extension in ForegroundServiceExt.kt so a future third FGS picks up
the API-version gating automatically. Callers shrink to one-liners.
WebView.getCurrentWebViewPackage() is documented to return null on
devices without an installed provider, but in practice it can throw
AndroidRuntimeException, MissingWebViewPackageException, or NPE on
devices with a corrupt or updating provider. An unhandled throw there
short-circuits WebViewCompatibilityChecker.evaluate() before
FullscreenActivity's recovery path (added in 5a5c3bca89) has a chance
to run, re-crashing the app on launch.
Wrap the call in a narrow catch for RuntimeException so the documented
throwables fall through to the PackageManager lookup while Error
subclasses still propagate. Log at debug level so broken-provider
telemetry survives.
The recent WebView, FGS, and JS-dialog guards each caught Exception,
which hid every thrown type behind a log line. Play Console only
surfaces uncaught throws, so a future regression inside any of these
paths would be silently swallowed.
Narrow to the known recoverable types:
- FullscreenActivity.initWebView: AndroidRuntimeException (incl.
MissingWebViewPackageException) + IllegalStateException.
- FullscreenActivity.onJsAlert/onJsConfirm: WindowManager.BadTokenException
+ IllegalStateException; JsResult.cancel() still fires via the
handled flag so the WebView JS thread never deadlocks.
- TrackingForegroundService / FocusModeForegroundService
ensureForegroundNotification: IllegalArgumentException (Samsung One UI
/ Android 14 blank-title case) + SecurityException. Anything else,
including ForegroundServiceStartNotAllowedException, now propagates.
Observed on Play Console: AndroidRuntimeException thrown from
WebViewFactory.getProvider during FullscreenActivity.initWebView on
devices where the system WebView package itself is broken (seen with a
WebView Beta build throwing "Already registered a list of actions in
this process" from WebViewChromiumFactoryProvider.<init>). The activity
had no recovery - the constructor threw and the app died at launch.
Mirror the existing CapacitorMainActivity pattern: catch around the
WebHelper().instanceView(this) call, and on failure present the existing
WebViewBlockActivity using compatibility.copy(status = BLOCK) so the
block screen keeps the detected provider/version, then finish().
Also harden lifecycle callbacks that dereference the lateinit webView /
javaScriptInterface fields so the new failure path can't trigger an
UninitializedPropertyAccessException via OEM-specific lifecycle quirks:
::isInitialized guards in onSaveInstanceState, onRestoreInstanceState,
onBackPressed, and callJSInterfaceFunctionIfExists (the last matching
the existing guard in CapacitorMainActivity).
Every Context.startForegroundService() call creates a per-call FGS token
that must be answered with startForeground() within the system timeout,
even if the service is already a foreground service. Earlier fixes closed
specific branches one at a time and left gaps — most notably ACTION_START
with null taskId while already tracking, which returned without calling
startForeground().
Satisfy the contract once at the top of onStartCommand in both
TrackingForegroundService and FocusModeForegroundService, so every branch
below (known and future) already has the token answered. Add
stopForegroundAndSelf() on the previously silent no-op branches
(ACTION_UPDATE / ACTION_STOP when idle) so the guard does not leak the
service as foreground with a placeholder notification.
The placeholder notification carries a content title — Samsung One UI can
render title-less notifications as blank and a few Android 14 builds throw
IllegalArgumentException from startForeground() without one, which would
be swallowed by the guard's try/catch and re-trigger the very timeout we
are preventing. Matches the crash's heavy Samsung/Lenovo-tablet bias.