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.
Adds HdNavRail/HdNavRailItem (icon + hover-tooltip, secondaryContainer
selected pill) and HdBottomBar/HdBottomBarItem (slim 56dp icon-only bar
with the same pill treatment, navigation-bar inset handled). Swaps the
desktop ProjectEditorWindow rail and Android CompactNavigation
NavigationBar to the new components — the phone bar reclaims about 24dp
of vertical space versus the M3 default.
Drops the redundant HdFormat.formatThousands in favour of the existing
Int.formatDecimalSeparator across all callers. Merges HdMetadataRow
into HdInlineStat with a valueStyle parameter. Removes the unused
HammerTheme object and the dead HdSwatch overload, hoists Theme.kt
Shapes and the 2dp/16dp RoundedCornerShape allocations to top-level
constants, and trims signature-restating KDoc throughout.
Tightens ProjectStatsUi recompositions: remembers the chapter bar-chart
items + min/max in a single ChapterStats holder, the encyclopedia
totalEntries and Inhabitants header summary, the top-appearances
HdAttributionItem list, and the per-device bar-chart items. Pulls
Random.nextInt out of the EncyclopediaDonut animation tween and into
remember, replaces the in-body hasAnimated flip with LaunchedEffect,
and accepts the encyclopedia counts as parameters so the same sum
isn't computed twice. ProjectHomeMenu now takes hasServer from its
caller instead of subscribing to the same Decompose Value a second
time.
Renames ProjectTheme.color1/color2 to primary/secondary and pipes them
into a Material 3 ColorScheme via material-kolor's dynamicColorScheme.
The user's secondary seeds both the Material 3 secondary and tertiary
palettes, the Rainbow palette style keeps them in distinct hue lanes
instead of collapsing toward the primary, and surfaceTint is redirected
to secondary so elevated surfaces (cards, dialogs, top bars) pick up
the accent.
The override is hoisted to the platform entry points
(ProjectEditorWindow.AppContent on desktop, ProjectRootActivity.Content
on Android) so a single ProjectThemeOverride wraps the NavigationRail,
ProjectRootUi, FAB stack, and confirm-close dialogs. ProjectRootUi and
ProjectRootFab no longer self-wrap.
All five project FABs (notes, encyclopedia, timeline, scene group,
scene) explicitly draw containerColor/contentColor from
secondaryContainer so the second color is the obvious accent on the
heaviest visible chrome.
The install id used to live on ServerSettings, regenerating on every
server reconfig and absent for solo users. It now lives on GlobalSettings
as a stable identity used both for server auth and for the
writing-activity device id. ServerSettings keeps its final clean shape;
the legacy server.json is read once via a private ServerSettingsOld
mirror in MigrateInstallIdToGlobal to copy the value forward.
Adds a GlobalMigration interface alongside the existing per-project
Migration so DataMigrator can run global one-shots before iterating
projects. handleDataMigration() becomes suspend; app entry points wrap
it in runBlocking.
Add client-side validation for email, password, and URL, along with shared validation utilities
Server error messages can now properly bubble up to the UI