* Lock the sync baseline to the server-confirmed hash
Editing a scene through the editor stamps `lastEdited` (an autosave side
effect), and the sync conflict baseline was being re-derived from local
state at mark-dirty time. Because `lastEdited` had already moved, the
recorded baseline disagreed with what the server stored — so a single
client editing its own scene and resyncing raised a phantom conflict on
every sync, with no other device involved.
Stop re-deriving the baseline. The conflict baseline (`originalHash`) is
now the hash the server last confirmed it holds: recorded only on a
successful transfer (the exact bytes uploaded or downloaded) and read
back when an entity next goes dirty. A field that mutates locally after
a sync can no longer taint the baseline.
- ProjectSynchronizationData gains `syncedHashes` (per-entity confirmed
hash). EntityOriginalState.originalHash is nullable: null means the
server never confirmed a hash (new entity, or first edit after upgrade)
so the server skips the conflict check and the baseline self-heals on
that upload.
- SyncJournal.markEntityAsDirty reads the baseline from syncedHashes
rather than taking a recomputed hash; recordSyncedHash sets it.
- EntitySynchronizer.uploadEntity reports the accepted hash via a
callback; the transfer records it on upload and download.
- The upload sources `originalHash` from syncedHashes at transfer time,
not the dirty entry's frozen copy — so a partial sync (some entities
uploaded, a later one failed) can't leave a stale baseline that forges
a phantom conflict on retry.
- FinalizeSync preserves syncedHashes written mid-transfer and prunes
deleted ids (including server-driven deletions) from the map.
- Repositories no longer recompute an entity hash to mark for sync.
Adds an integration regression test: a single client editing a scene
through the editor and resyncing must not raise a conflict (red before
this change, green after).
* Add e2e sync resync scenarios and stabilize the integration harness
High-value round-trip coverage for the sync baseline beyond the
content-edit regression, plus the test-harness fixes the new tests
needed to be reliable.
Tests:
- SyncHashStabilityTest: after a sync, the client's hash for a scene
equals the hash the server stored (baseline and server agree).
- ResyncBaselineScenariosTest: a single client must never raise a
phantom conflict on resync regardless of how the baseline was set —
metadata-only edit, rename, and an edit after a download establishes
the baseline. Each asserts the server's stored hash actually changed,
so a resync that uploaded nothing can't pass vacuously.
Harness (RoundTripTestBase):
- Bind the client IO/Default dispatchers to the same single-threaded
dispatcher as Main. FakeFileSystem is not thread-safe (its open-files
list is a plain MutableList), so the real multi-threaded dispatchers
let concurrent opens across the repos' background scopes corrupt it,
throwing intermittently from findOpenFile — the root of the suite's
order-dependent flakiness.
- newClient() registers each client for teardown-close, so a test that
throws before its own cleanup can't leak a project scope (and its
background coroutine scopes) into the next test.
- Shared syncNoConflict()/serverEntityHash() helpers replace per-file
copies.
- Close the per-test HttpClient in EndToEndTest.tearDown; it was created
every test and never closed, leaking engine threads.
* Add per-entity-type sync resync matrix
The lock-the-baseline fix lives in the shared sync layer, so it must
hold for every entity type. EntityTypeResyncMatrixTest exercises
create → sync → edit → resync (assert no phantom conflict) for notes,
timeline events, and encyclopedia entries; a create → sync → resync for
immutable scene drafts; and a create → sync → delete → resync that
confirms the entity is removed from the server (covering the deletion
path and the synced-hash pruning).
* Fix timeline store race on sync finalize
TimeLineRepository.storeTimeline() read timelineFlow.replayCache.first(),
which throws NoSuchElementException when the timeline was never loaded.
finalizeSync runs it on every sync — including syncs of a project that
never touched the timeline — so whether the async timeline load had
emitted yet was a race that could fail the whole sync. Guard with
firstOrNull (nothing loaded means nothing to store), matching the
pattern correctEventOrder already uses. Surfaced as order-dependent
flakiness once the e2e suite grew.
* Add sync fuzz/property test
A single client driving a seeded random interleaving of editor content
edits, metadata edits, renames, and syncs must never raise a conflict —
there is no other device, so any conflict is a phantom. Catches ordering
edge cases the hand-written scenarios miss (a sync landing between a
content edit and its debounced autosave, a rename between edits). Seeds
are fixed and printed on failure so a failing sequence can be replayed.
* Backfill sync baselines on the first post-upgrade sync
A client upgrading to the lock-the-baseline version has an empty
syncedHashes map — the field did not exist in the old sync.json. With no
baseline a dirty entity uploads with originalHash = null, and the server
skips the conflict check (ServerEntitySynchronizer only conflicts when
originalHash != null), silently overwriting a concurrent edit from
another device.
FetchLocalData now establishes a baseline for every entity that still
lacks one, before any upload reads it: the current local hash for an
in-sync entity (it equals the server's hash for an agreed entity), or
the frozen pre-edit hash carried over from the old dirty list for an
entity that was already dirty at upgrade time. The local-hash source is
safe — if local has silently diverged from the server, the backfilled
baseline is the old hash, so the next edit's upload is correctly flagged
as a conflict rather than a silent overwrite. Existing recorded baselines
are never touched, so it is a one-time, idempotent migration.
Residual: an entity edited for the very first time post-upgrade, before
any sync has run the backfill, still has no recoverable baseline.
* Remove dead SceneDraftRepository.markForSynchronization
Drafts are immutable after creation, so this was never called (its own
comment said so). Removing it leaves syncJournal unused in the repo, so
drop that field and its import too.
* Persist + assert scene content in SceneTimestampsTest
The test set content via onContentChanged + an immediate storeSceneBuffer.
onContentChanged registers the editor buffer asynchronously, so the
storeSceneBuffer found no buffer ("no buffer present"), no-op'd, and the
scene reached the server empty. The test only asserted timestamps, so it
passed while never validating that content survives the round-trip.
Persist via the synchronous storeSceneMarkdownRaw path, and assert the
server-stored entity's content equals the uploaded content so the blind
spot can't silently return.
The library's tableOfContents() prints page numbers, which forces a full
dry-run layout of the whole book to resolve them before the real render —
doubling export time. Replace it with a hand-built contents page using
anchor/linkToAnchor: clickable chapter entries with no page numbers (matching
EPUB), so a single layout pass suffices. Chapter bookmarks still drive the
reader outline.
Path resolution re-walked the entire scenes directory from disk on every
call, so a single move or import triggered many full recursive scans —
saturating disk I/O and freezing the UI for minutes on large projects.
Cache the recursive scan in SceneDatasource, invalidated on every
structural mutation (move/create/delete). The scan runs inside a
non-reentrant lock so an invalidation can't interleave and resurrect a
stale list.
Also bound the shutdown temp-save join with a timeout so a wedged save
can't block process exit.
(cherry picked from commit b1e0391c6fde997745f7bb0a8330a47dca44fba8)
The Codacy catch-narrowing pass (8c15ab68) changed EncyclopediaDatasource.loadEntry
from catch(Exception) to catch(IOException)+catch(SerializationException). The
follow-up fix (f779996f) migrated the other narrowed TOML read sites to
readTomlOrNull but missed this one.
tomlkt doesn't route every decode failure through SerializationException: a corrupt
or hand-edited entry with a non-integer id throws NumberFormatException
(IllegalArgumentException), and parser errors throw IllegalStateException. Those
escaped loadEntry raw instead of being wrapped as EntryLoadError, so
BrowseEntriesComponent.loadEntryContent's catch(EntryLoadError) graceful-degrade
path no longer caught them and the load crashed.
Add IllegalArgumentException and IllegalStateException branches that wrap as
EntryLoadError, matching the readTomlOrNull exception set. Add a test covering a
corrupt entry id.
Restores the "store projects in public storage" feature, gated to F-Droid builds (the required MANAGE_EXTERNAL_STORAGE permission is disallowed on Google Play).
- Expose the build channel at runtime via BuildConfig.FDROID in the common module.
- Declare the storage permissions only in src/fdroid/AndroidManifest.xml, swapped in for F-Droid builds.
- Restore the storage-location toggle + file-access UI, gated on BuildConfig.FDROID; reconcile the toggle with the real location on open.
- Build the GitHub release APK as the F-Droid flavor.
- Extract the directory move into a tested FileSystem.moveDirectory() helper (fixes the same-path data-loss crash; runs off the UI thread).
- Read the fdroid flag consistently across settings.gradle.kts and module scripts.
- Document the F-Droid build flag in DEVELOPMENT.md.
SerializationException, dropping the IllegalArgumentException branch that
readJsonOrNull already has. tomlkt doesn't route every decode failure
through SerializationException: a stale stats.toml (old schema, e.g. a
non-integer key in Map<Int, Int>) throws a raw NumberFormatException.
That uncaught exception killed the parallelMap worker for the affected
project, nulled its result slot, and filterNotNull() dropped it from the
project selection list — while sync, a different path, still showed it.
Add a readTomlOrNull helper mirroring readJsonOrNull that absorbs the full
set tomlkt can throw on bad input (SerializationException,
IllegalArgumentException incl. NumberFormatException, IllegalStateException
from parser errors, IOException), with an onError callback so callers keep
their site-specific logging. Schema-version checks run after decode, so they
can't rescue a decode that throws first.
Migrate every TOML read site to it: ProjectStatisticsCacheReader,
StatisticsDatasource, ProjectDataDatasource, ProjectsListComponent,
ReferenceIndexDatasource, WritingActivityDatasource, and
SceneMetadataDatasource (which previously had no error handling at all).
Stale caches are now treated as a miss and recalculated.
Add ReadTomlOrNullTest covering each exception family and a
ProjectStatisticsCacheReaderTest reproducing the original crash.
* Fix flaky NotesWorkflowTest create-note race
createNoteThenOpenIt clicked the create confirm button immediately after
typeIntoEditor. The markdown editor reports text changes through an async
editOperations flow that waitForIdle() doesn't await, so noteText could
still be empty at confirm time -> createNote("") returns NoteError.EMPTY,
the create screen never dismisses, and the browse grid's note-card- nodes
never reappear -> 10s timeout.
Wait for the word/char counter to reflect the typed body before confirming.
Tag the counter (NOTES_CREATE_META_TAG) and add a textOf() harness helper to
read it. Verified on emulator (4/4 runs green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Guard project list load against a concurrently-deleted project
loadProjectList lists project dirs then reads each one's metadata in a
parallelMap. If a project is deleted between the listing and the read
(another window, or a refresh racing a delete), loadMetadata's recovery
path tries to recreate project.toml in a directory that no longer exists
and throws FileNotFoundException from inside its own catch block, failing
the entire list load (and crashing the instrumented test that exposed it).
Catch the per-project load failure and skip the vanished project - this
also makes the previously-dead `if (metadata != null)` branch live, which
was the original intent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read/decode loaders now catch IOException and SerializationException
instead of Exception (matching readJsonOrNull); filename parsers pass
the original exception as the cause of the typed filename error.
Sync, background jobs, API/email send, and UI boundaries legitimately
catch broadly; annotate each with @Suppress and a reason, and log the
exception where it was previously swallowed.
- protected open val projectDef — the ProjectDef("Test", HPath(...)) that was copy-pasted in all four component tests now lives once in the base (open, so a test can override if needed).
- setupComponentKoin(module) — wraps setupKoin and auto-registers the relaxed TagIndexService that every ProjectComponentBase component injects. This removes the per-test boilerplate and permanently fixes the "easy to
forget" inconsistency the review flagged (SceneEditor had been missing it).
EncyclopediaRepository reached sideways into StatisticsRepository and
ReferenceIndexRepository (markDirty / markEntryDeleted on entry writes).
Hoist that orchestration into a new EncyclopediaService, mirroring how
SceneEditorService owns the same side-effects for scenes. The repository
is now pure data with no sibling-repo deps; the three write call-sites
(CreateEntryComponent, ViewEntryComponent, ClientEncyclopediaSynchronizer)
route writes through the service, reads stay on the repository.
SceneRepository drops its sibling-repo deps and emits a tree-only flow; SceneContentRepository exposes a dirtyBufferIds StateFlow; SceneEditorService composes the scene list (combine) and owns init orchestration. Dirty markers now update reactively.
* Rename IdRepository to IdAllocator
It is a special foundational primative
* Rename SyncDataRepository to SyncJournal
It is a special foundational primative
* Rename GlobalSettingsRepository to GlobalSettingsStore
It is a special foundational primitive
* docs: add Foundation primitives tier to the architecture doc
Document IdAllocator, SyncJournal, and GlobalSettingsStore as a fixed set of
stateful, cross-cutting primitives that the whole data layer may depend on —
acyclic leaves (GlobalSettingsStore <- SyncJournal <- IdAllocator) that named
the dependency reality instead of treating it as a no-sibling violation.
GlobalSearchRepository was a stateful class masquerading as a Repository while
fanning out across six repositories — really cross-repo coordination with UI
state bolted on. Split it to match the layering:
- SearchProjectUseCase: stateless cross-repo search (data layer)
- GlobalSearchState: component-layer holder (state + debounce), retained on
ProjectRootComponent via InstanceKeeper so search survives the modal being
dismissed/reopened and config changes; stateKeeper carries query+filter
across process death
- GlobalSearchComponent: thin presenter delegating to the holder
Search state now lives in the component layer instead of a project-scoped
singleton, and MutableValue updates run on the main thread (the use case
offloads its fan-out to the default dispatcher).
Split the monolithic SceneEditorRepository into single-responsibility pieces:
- SceneRepository — scene tree, structure, ordering, paths, on-disk layout
- SceneContentRepository — in-memory buffers, autosave, dirty tracking
- SceneMetadataRepository — per-scene and project metadata
- SceneEditorService — the component-facing facade that orchestrates the three
and applies the cross-cutting side-effects (statistics, writing activity,
reference index) the repositories deliberately don't reach up to perform
Components and synchronizers now talk to SceneEditorService; the underlying
repositories hold no sibling-repo dependencies. Tests are reorganized to match
the new boundaries, and the scene-editing domain API is documented in
docs/DESIGN_PATTERNS.md.
ViewNote/ViewTimeLineEvent come back from process death with isEditing=true
and the restored draft, but the note/event isn't loaded yet (async, or cold
cache). In that window isEditingAndDirty() returned false, so a back-press or
close took the silent discardEdit() path and wiped the restored text.
Treat "editing with no loaded baseline" as dirty so those paths route to the
confirm-discard dialog instead. Behavior is unchanged once loaded.
ProjectLifecycleTest launches the real ProjectSelectActivity, creates a project,
waits for it to appear in the list, and confirms opening it launches
ProjectRootActivity (via ActivityMonitor). Cleans up in @After.
- Wire jetbrains-compose ui-test-junit4 into the androidTest source set.
- Tag the create-project affordance (CreateProjectButtonTestTag); "Create
Project" otherwise appears as three separate on-screen texts.
- Run the instrumented suite on an emulator in CI (android-emulator-runner) with
AVD snapshot caching.
- Convert HashTest from JUnit Jupiter to JUnit4 so the AndroidJUnit4 runner can
execute it on-device (it had "no runnable methods" otherwise); use assertEquals
since assert() is a no-op when assertions are disabled on a device. It now
verifies EntityHasher's golden vector on Android ART.
- Fix a scope-close crash: getSceneBufferDirectory used a non-recursive
createDirectory, so closing a project whose scenes/ dir is absent threw on the
teardown path and crashed the process. Use createDirectories (matching its
siblings) and order the test teardown so it doesn't delete the project mid-close.
ZipUtils.unzipBytesToDirectory used fileSystem.sink(target).buffer(),
where okio's Sink.buffer() failed to resolve for the iOS/native target
(receiver type mismatch), cascading into a 'cannot infer R' error on the
following .use{} and failing the iOS archive. Compiled fine for JVM, so
it only surfaced in the iOS publish workflow.
Use okio's multiplatform FileSystem.write(target) { } helper, which
yields a BufferedSink directly and handles buffering + closing. Verified
with :common:compileKotlinIosArm64.
Resolves the "Entity X not found for reId" failure that wedged every
project sync. Three related fixes:
- EntitySynchronizers.reIdEntry now skips a newId with no backing entity
(logged) instead of throwing. The throw aborted sync before finalize,
and finalize is the only place newIds is cleared, so a single phantom
deadlocked every future sync. Skipping lets sync finish and self-heal.
- SyncDataRepository.recordIdDeletion prunes the id from newIds when the
entity was never synced, instead of only appending to deletedIds. This
stops create-then-delete from leaving a permanent phantom newId behind.
- IdConflictResolutionOperation seeds new-id assignment from
max(serverLastId, localMaxId). If the server ever reports a lastId
below the client max (server-side data loss), seeding from serverLastId
alone handed out ids that collided with and clobbered real local
entities. Now logs a WARN and assigns above the local max.
Tests reproduce each failure first, then pass after the fix.
The settings updates collector rebuilt component state without copying
syncAutomaticSync or syncAutoCloseDialog, so toggling either checkbox
persisted the change but never updated the UI. Also removes leftover
debug logging.
New ProseDiff module: word-level Myers diff (kotlin-multiplatform-diff)
over markdown text, with syntax stripped via the JetBrains parser and
offsets mapped back to source. Merges adjacent edits into single hunks,
emits anchors at each boundary for scroll sync, and includes a git-style
slider that aligns pure insert/delete blocks to line boundaries. Covers
plain-text diffing, prepared/reusable inputs, and an OffsetMap that maps
a position on one side to its counterpart.
Draft compare: highlights DELETED (red/strikethrough) and INSERTED
(green/underline) spans via non-destructive RichSpans in each pane's
rendered coordinate space, recomputed off-thread on a 500ms debounce.
Synchronized scrolling (compose-texteditor 2.0.7) drives either pane from
the other off raw scroll pixels with an echo guard. Both editor states are
hoisted so highlights and sync work in compact and expanded layouts and
survive theme changes. A DIFF toggle gates highlights and sync.
Show an explanatory welcome dialog before the projects-directory picker so
first-time users understand why macOS is asking them to choose a folder.
Route all user-facing strings in the sandbox first-run flow through StrRes
so they're translatable via Crowdin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>