* Add per-project language setting (#754)
An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.
Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.
The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.
* Fix review findings in the project-language feature
createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.
The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.
* Enforce single-owner persisted formats
The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.
The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.
* Burn down the last raw TOML I/O outside datasources
ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.
With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.
* Pass seedDefaultLanguage in the Android instrumented-test harness
* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
Reuse clickUntil for the navigational and menu clicks in the timeline,
scene-list, scene-editor and project-lifecycle tests. Also waits for the
add-menu to open before picking an item, which previously ran with no wait
at all.
Mutating confirms are left as single clicks: those buttons stay on screen
across the async create, so a retry would persist a second item.
* Make clickUntil prove the target existed before reporting success
clickUntil skips the click while its matcher resolves to nothing, so a landed
condition that is already true returned success having clicked nothing, and a
target that never rendered failed as "click never took effect".
Track whether a click was injected: never return without one, and report a
target that never appeared as its own failure. Drops the per-site pre-waits
that duplicated this.
createEntryThenOpenIt intermittently timed out waiting for the create FAB
to disappear: the tap on the new entry card never became an onClick, so the
browse list never navigated.
The card is present, displayed and correctly positioned when this happens.
Its centre resolves to the card's own clickable rather than the type stamp
or a tag chip, the outgoing create screen is gone well before the card
appears, and the card's bounds are stable from the frame it renders. Only
the software renderer CI uses reproduces it, at roughly one run in six.
clickUntil re-injects the click until the expected outcome shows up, the
same way typeIntoEditor already re-injects keystrokes. It stops re-clicking
once the matcher resolves to nothing, so a slow but successful navigation
does not get a second click on the next screen.
The instrumented tests delete their project directory in teardown, but
finish() and waitForIdleSync() only drain the main thread. The Koin
project-scope close flushes scene buffers on IO coroutines after that, so a
straggler write can land between deleteRecursively emptying the directory and
removing it. The rmdir then fails with "failed to delete <project dir>".
Seen on develop in both ProjectLifecycleTest and SceneEditorWorkflowTest, so
it is the shared teardown rather than any one test.
There is no signal to await for that scope close, so retry the delete until
the flushes stop. Each attempt removes whatever reappeared, and it still fails
loudly after 15s rather than hiding a real leak.
ProjectLifecycleTest now uses the same helper instead of deleting inline.
Crowdin's Custom Post-Export Processor only receives a file's name, not its
path, so it could not tell the Android resources apart from the Compose ones
while both were called strings.xml. Everything Crowdin manages under
android/src/main/res/ now carries the _android.xml suffix, which the export
snippet uses to leave AAPT's quote escaping intact.
AAPT does not care what the file is called, and crowdin.yml already globs
strings*.xml, so neither needed changing.
Remove the HTTP/HTTPS protocol picker and all ssl plumbing from the
client. Persisted server settings now always resolve to HTTPS, so a
legacy ssl=false server.json is upgraded on load. ServerSettings.ssl is
kept only as an internal seam for the plain-HTTP integration-test server.
Android: drop the permissive network_security_config so cleartext
traffic uses the secure platform default (blocked).
Server keeps its plain HTTP connector for reverse-proxy deployments. In
--dev with no sslCert configured, it now generates and persists a
self-signed keystore (hammer_data/dev-selfsigned.jks) and serves TLS on
a non-privileged port (8443 by default). The desktop --dev client trusts
that cert for loopback hosts only; remote hosts still get full cert and
hostname validation.
The suspend FileKit dialog API resolves the ActivityResultRegistry from a
global set only by FileKit.init(). It was never called on any activity, so
the picker in ProjectRootActivity (encyclopedia) either targeted a stale
registry (result never delivered) or threw and was silently swallowed.
Call FileKit.init(this) in both activities' onCreate.
Once the picker returned a file, readExternalFile crashed: stageIntoCache
hands it a plain cache file path, but AndroidExternalFileIo forced every
path through ContentResolver (content:// only). Branch on URI scheme so
file paths are read directly, matching desktop/iOS.
Concise alternative if you prefer a shorter body:
Fix Encyclopedia image selection on Android
- Call FileKit.init(this) in both activities so the suspend picker API has
a live ActivityResultRegistry; without it the encyclopedia picker failed
silently.
- AndroidExternalFileIo.readExternalFile now handles plain file paths (not
just content:// URIs), fixing the crash on the staged cache path.
Quick-capture story ideas as tagged markdown blobs in a new Project
Selection tab, stored one file per idea in .ideas/ and promotable into
a project. Offline-first; syncs as a phase inside the account sync
session (shape-agnostic server storage, hash-baseline conflicts,
tombstone/outbox deletion, ideasStateHash skip for unchanged sets).
Unifies idea + project tag suggestions behind AccountTagService.
Log a single identifying line as the first line on every platform:
version plus OS/runtime, and on desktop the display server (Wayland/X11
+ DE) and Skia renderApi. Makes user-submitted logs self-describing.
Banner is expect/actual in :common, wired into the desktop, Android, and
iOS startup entry points. Also adds INFO breadcrumbs across desktop
startup so a stall localizes to a stage.
Adds RTF as an import format alongside Markdown. RTF has no headings, so
the importer collects each paragraph with its formatting and splits into
scenes by one of three user-chosen strategies: formatting (outline level
/ font size / bold), a chapter-heading regex, or a single scene.
Shared scene/group folding is extracted into ImportStructure so both
importers reuse it, and a StoryImporterRegistry routes by file extension.
Importers now take ByteArray so RTF bytes aren't decoded prematurely.
Markdown gains an Auto detection mode (the new default) that reads a lone
leading heading as the project title and picks the chapter level by
frequency. The create-dialog help affordance becomes an icon button.
Relocate "Import Story" from the Project Home screen to the Create
Project dialog. An "Import" action in the create dialog opens the file
picker and import dialog; confirming creates the project (name derived
from the file name, editable and validated) and splits the file into
scenes by running the existing ImportStoryUseCase in a temporary
project scope.
- Move import state/handlers from ProjectHome(Component) to
ProjectsList(Component)
- Move ImportStoryDialog + ImportFilePicker (expect/3 actuals) to the
projectselection package; picker is now callback-based
- Add an optional mastheadAction slot to FormDialog for the Import button
- Add an editable, validated project-name field to the import dialog
- Add ProjectsListComponent tests for the import handlers and the
create-failure path
AddNoteActivity was exported with no permission, so any installed app could pop the Add Note dialog over the user pre-locked to a chosen project; a tapjacking overlay tricking a Save tap would write a note and push it to the sync server. It is only ever launched in-process (ProjectSelectActivity) or via the home-screen widgets' PendingIntents, neither of which needs export. Set android:exported=false in both manifests and filter touches delivered while the window is obscured.
ProjectRootActivity was exported and resolveProjectDef deserialized a caller-supplied ProjectDef whose free-form path was used verbatim for all project file I/O, letting any installed app root the editor at an attacker-controlled directory (confused deputy, CWE-926/CWE-22). Resolve the target project by name against on-disk projects only, drop the serialized EXTRA_PROJECT path extra, and mark the activity exported=false; it is only launched in-process via explicit intents and PendingIntents from widgets and shortcuts.
The markdown/scene editor consumes hardware key events and reports edits
through an async flow. sendStringSync could fire before the field had focus
(or before the editor's editOperations collector started), silently dropping
the keystrokes so the observed change never happened and waitUntil timed out.
A longer timeout can't recover dropped input.
typeIntoEditor now re-focuses and re-injects until a caller-supplied
propagated() lambda confirms the change, or a deadline throws a clear error.
NotesWorkflowTest and SceneEditorWorkflowTest pass their existing change
signal and drop their redundant waitUntil blocks.
Bump compileSdk/targetSdk to 37 and AGP to 9.1.1 (9.0.x maxes out at
API 36.1 and can't resolve the minor-versioned android-37.0 platform).
Handle the two breaking behavior changes for API 37 targets:
- Cleartext traffic: usesCleartextTraffic is now ignored without a
network security config. Self-hosted servers can be plain HTTP
(ServerSettings.ssl = false), so add network_security_config.xml
permitting cleartext and reference it from both manifests.
- Local network access: declare ACCESS_LOCAL_NETWORK and request it at
runtime via a new expect/actual RequestLocalNetworkPermission,
triggered when the server-setup dialog opens so the grant resolves
before any LAN connection. No-op on desktop/iOS.
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.
* 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>
* 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.
Add a reusable EditorTestHarness (seed a project via Koin, launch straight
into ProjectRootActivity, navigate, tear down without racing the scope flush)
and one happy-path instrumented test per feature area: navigation smoke,
scene list/editor, notes, encyclopedia, timeline, project home, and global
search.
To make the UI addressable, add optional testTag params to shared design-system
components (FormField, MarkdownEditField, HdHairlineField/TagField/SearchField/
TypePicker, HdBottomBar/HdNavRail) and colocated testTag consts on the relevant
screens. The custom text editor consumes key events rather than Compose SetText
semantics, so the harness types into it via injected keystrokes.
All 9 new tests pass on an api-34 emulator.
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.
buildDebug never touched the androidTest source set, so a stale instrumented
test could - and did - rot unnoticed: HashTest stopped compiling after `tags`
was added to EntityHasher.hashNote.
- Add a :android:assembleDebugAndroidTest step: a fast, emulator-free gate that
compiles + packages the androidTest source set on every PR.
- Fix the stale HashTest to match the current hashNote signature.
- Exclude the duplicate META-INF/LICENSE files the test deps ship so the
androidTest APK packages.
The log-consumer coroutine could write to appendBuffer after another
thread (the logging framework / shutdown) had already closed it, e.g.
during CoroutineScope cancellation, throwing IllegalStateException: closed.
Make the single consumer coroutine the sole owner of the sink: it writes,
flushes, and closes appendBuffer (the latter in a finally block so it runs
on normal channel close and on cancellation alike). close() now just closes
the message channel, which ends the consumer; flush()/close() no longer
touch the sink from foreign threads. As a bonus, queued messages are
drained before the sink closes. Applies to both desktop and android.
The log-consumer coroutine could write to appendBuffer after close()
closed it from another thread (e.g. during scope cancellation on
shutdown), throwing IllegalStateException: closed.
Guard all sink access with a lock and a closed flag, and close the
message channel on close() so the consumer terminates cleanly. Applies
to both the desktop and android FileLogger.
iOS now runs the same shared Compose UI as Android and Desktop. The
existing Decompose component graph (IosRoot, ProjectSelection,
ProjectRoot) is reused; the Swift app shrinks to an AppDelegate, a
Koin bootstrap, and a UIViewControllerRepresentable that hands off to
a Kotlin ComposeUIViewController. The SwiftUI starter under
/ios/ios/ui and /ios/ios/DecomposeHelpers is deleted.
Hammer.framework moves from :common to :composeUi so the framework
ships the Compose entry point; Xcode's Run Script invokes
:composeUi:embedAndSignAppleFrameworkForXcode. iosArm64 and
iosSimulatorArm64 targets are declared with libbacktrace source-info
so K/N crashes report file:line. 17 expects across composeUi get iOS
actuals (file pickers via filekit, image loading via Coil3,
LanguageUtil via NSLocale.preferredLanguages, etc).
The adaptive nav scaffolds — bottom bar at compact width, side nav
rail otherwise — are extracted from the Android activities into
shared ProjectSelectScaffold and ProjectRootScaffold in
composeUi/commonMain, along with their Modifier helpers and the
close-confirm dialogs. Android activities are now thin shells that
delegate to these; iOS calls the same scaffolds, so phone/tablet
layout is unified across all three platforms.
App icon and CFBundleDisplayName set so the home screen reads
"Hammer" with the brand logo instead of the blueprint placeholder.
Lifts version check into VersionCheckRepository + GithubVersionCheckDataSource.
On launch, shows an update dialog when a newer release is available and the
tag hasn't been dismissed (tracked in GlobalSettings). Falls back to the
annotated tag message when the release body is empty. AboutApp's VersionCard
gains a button to open the same dialog on demand.
Adds `created` and `lastEdited` timestamps to SceneMetadata, stamped
on every autosave tick in SceneEditorRepository.recordSceneActivity.
StatisticsService rolls them up into a new "Last Edited" tile on the
project home dashboard, and the Android widget reads the same field
from the stats cache.
The timestamps ride the sync wire on ApiProjectEntity.SceneEntity and
participate in EntityHasher.hashScene. On download, local values are
preserved when the server didn't ship them. No conflict resolution —
last write wins.
EntityHasher hash functions had their defaults removed so call sites
can't silently miss a newly added field; this caught a latent bug in
the second markForSynchronization hash call that was relying on
defaults for archived/confirmedReferences/dismissedReferences.
Resizable single-project stats widget (4x1 / 2x2 / 3x2 / 3x3+) showing
total word count, cadence-goal progress, today/streak, a 7-day sparkline,
and Open/Note actions. Adds a config activity for project selection.
Bumps Glance to 1.2.0-rc01 and wires up providePreview + setWidgetPreviews
so all three widgets render previews on Android 15+ pickers. Adds the
project name to the Add Note widget header. Extracts parseDailyWordTotals
and formatWidgetWords as shared helpers.
Lists projects with cached word counts; row tap opens the project, header opens the project picker. Lifts shared mono text styles into WidgetDesignSystem and extracts ProjectRootActivity.createIntent to dedupe the deep-link builder.