Compare commits

..

214 commits

Author SHA1 Message Date
Adam Brown
59a788cddd
New translations from Crowdin (#1014)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-10-18 17:19:08 -07:00
Adam Brown
1ba66394ab
New Crowdin updates (#1013)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin
2026-10-18 00:40:53 -07:00
Adam Brown
c896edf379
Delete projects off the UI thread (#1011)
The delete dialog called deleteProject from its click handler, so the
metadata read, recursive delete and sync data write all blocked the UI
thread. Run them on the IO dispatcher.

---------

Co-authored-by: Sean Li <22557931+sean0407@users.noreply.github.com>
2026-10-17 23:34:11 -07:00
Adam Brown
073921c22b
New Crowdin updates (#998)
* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin

* New translations from Crowdin
2026-10-17 17:36:51 -07:00
Adam Brown
78060dfe88
Fix spell check in the Linux snap (#1003)
* Give the snap libhunspell and dictionaries so spell check works

The strict snap sees core24's /usr, which has neither, so spell check never
loaded a dictionary. Stage libhunspell, mount the hunspell-dictionaries
content snap over /usr/share/hunspell, and point JNA at the staged library.

* Test that markdown editors underline misspellings in loaded text

* Point the snap's spell check at its dictionaries through DICPATH

platform-spellcheckerkt 1.4.0 searches DICPATH, so the /usr/share/hunspell
layout is no longer needed.
2026-10-17 17:36:38 -07:00
Adam Brown
05b1608bd4
Drop the scene editors' manual spell check after loading content (#1004)
SpellCheckingTextEditor re-checks on whole-document replacement since
ComposeTextEditor 2.4.7, so the extra full check only repeated that work.
2026-10-17 17:06:23 -07:00
Adam Brown
db2131cab5
Rename wear pairing strings to _android.xml and drop [ci skip] from Crowdin commits (#996) 2026-10-14 21:39:13 -07:00
Adam Brown
daa86badef
Escape apostrophe in French wear pairing string (#994)
Some checks failed
Build CI / build (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
2026-10-12 16:00:20 -07:00
Adam Brown
9013716d0d
New Crowdin updates (#993)
* New translations strings.xml (French)

[ci skip]

* New translations strings_wear_pairing.xml (French)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations strings_scene_editor.xml (French)

[ci skip]

* New translations strings_scene_list.xml (French)

[ci skip]

* New translations strings_reader_mode.xml (French)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations full_description.txt (French)

[ci skip]
2026-10-11 21:35:50 -07:00
Adam Brown
f7605f984d
ComposeTextEditor 3.1.0, and format bar fixes (#992)
* Format bar: a quote that saves, toggles as the editor has them, and disabled states

The quote button toggles the editor's quote block, written as "> ", where its inline
style exported as nothing. Bold, italic and strikethrough use the editor's toggle and
show active only when the whole selection has the style. In a table cell the block
buttons are disabled and the rule button does nothing; undo and redo show disabled
when there is nothing to undo or redo.
2026-10-11 21:10:57 -07:00
Adam Brown
81424d94ec
Fix invisible pagination button text on hover (#990)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
base.css fills hovered pagination buttons with the accent color, which the
authors and feed outline styles didn't override. Disabled buttons also picked
up the filled hover.
2026-10-05 18:47:43 -07:00
Adam Brown
83f9497937
Find update digest sidecars in artifact subdirectories (#989)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
upload-artifact keeps each installer's format directory, so writeUpdateMetadata
missed every sidecar and failed the v3.12.0 release.
2026-10-05 18:23:16 -07:00
Adam Brown
366e5b6a6f Prepared for release: v3.12.0
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-10-05 08:21:53 -07:00
Adam Brown
66e9920306
Keep unsaved scene buffers when the project scope closes (#983)
* Keep unsaved scene buffers when the project scope closes

Scope close deleted every temp buffer, so an Android activity destroyed in the background lost unsaved edits. Only clean buffers are cleared now, Discard discards explicitly, export reads unsaved buffers, and the Android back handler observes dirty state.

* Discard the buffer of an archived or deleted scene without reloading

* Save the buffer of an archived or deleted scene without throwing

* Drop a scene's unsaved buffer and temp file when it is deleted
2026-10-05 08:15:52 -07:00
Adam Brown
beab5e4271
New Crowdin updates (#972)
* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (Turkish)

[ci skip]

* New translations strings.xml (French)

[ci skip]

* New translations strings.xml (Spanish)

[ci skip]

* New translations strings.xml (German)

[ci skip]

* New translations strings.xml (Italian)

[ci skip]

* New translations strings.xml (Ukrainian)

[ci skip]

* New translations strings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings.xml (Turkish)

[ci skip]

* Update source file strings.xml

[ci skip]

* New translations strings_scene_editor.xml (French)

[ci skip]

* New translations strings_scene_editor.xml (Spanish)

[ci skip]

* New translations strings_scene_editor.xml (German)

[ci skip]

* New translations strings_scene_editor.xml (Italian)

[ci skip]

* New translations strings_scene_editor.xml (Ukrainian)

[ci skip]

* New translations strings_scene_editor.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_scene_list.xml (Italian)

[ci skip]

* New translations strings_scene_editor.xml (Turkish)

[ci skip]

* New translations strings_scene_list.xml (Turkish)

[ci skip]

* New translations strings_scene_list.xml (French)

[ci skip]

* New translations strings_reader_mode.xml (French)

[ci skip]

* New translations strings_scene_list.xml (Spanish)

[ci skip]

* New translations strings_reader_mode.xml (Spanish)

[ci skip]

* New translations strings_scene_list.xml (German)

[ci skip]

* New translations strings_reader_mode.xml (German)

[ci skip]

* New translations strings_reader_mode.xml (Italian)

[ci skip]

* New translations strings_reader_mode.xml (Turkish)

[ci skip]

* New translations strings_scene_list.xml (Ukrainian)

[ci skip]

* New translations strings_reader_mode.xml (Ukrainian)

[ci skip]

* New translations strings_scene_list.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_reader_mode.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_reader_mode.xml

[ci skip]

* Update source file strings_scene_editor.xml

[ci skip]

* Update source file strings_scene_list.xml

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Turkish)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Turkish)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Turkish)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Turkish)

[ci skip]

* New translations strings_account_settings.xml (Turkish)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]
2026-10-05 08:11:46 -07:00
Adam Brown
a90b72d291
Gate sync behind the unsaved-work dialogs and make Discard really discard (#987)
* Gate sync behind the unsaved-work dialogs and make Discard really discard

Sync flushed every dirty scene buffer in prepareForSync, so an automatic sync on
close committed scenes the user had just chosen to discard, and manual syncs
silently saved everything. startProjectSync now builds the same confirmation set
as requestClose (scenes, notes, encyclopedia, timeline) and ends in Sync instead
of Complete, so the existing dialogs run before the sync modal opens.

The Discard buttons previously only cleared the confirmation; they now revert the
scene buffers to disk and drop in-progress entity edits (or pop a create screen)
through a new Router.discardUnsaved hook.

* Close the remaining ungated sync paths and harden discard

Route the Home screen's Sync action through startProjectSync so it hits the
gate, ignore startProjectSync while a close request is pending, and stop the
scene synchronizer from persisting dirty buffers in prepareForSync and
finalizeSync: upload content and hashes already come from disk, and every
interactive sync is now confirmed up front, so the flush only ever committed
buffers nobody was asked about (crash-recovery buffers during the startup
account sync, for one).

ProjectRoot.discardUnsaved is now a suspend call that reverts scene buffers off
the main thread, and the entity components check and discard across their
whole stack rather than only the active destination.
2026-10-05 08:10:43 -07:00
Adam Brown
1dfc2bf663
ComposeTextEditor 3.0.3 (#985) 2026-10-05 08:09:43 -07:00
Adam Brown
ff622e82d8
Refresh open views when their entity changes underneath them (#984)
* Refresh open views when their entity changes underneath them

Note, timeline event, encyclopedia entry and scene metadata views loaded
once and went stale after sync. They now follow their repository flows,
keeping an in-progress edit and only moving its baseline.

* Address review findings on open-view refresh

- An untouched open edit follows the synced change; only a dirty edit keeps its draft
- Entry reloads are serialized, survive I/O errors, and only run for the open entry
- A note deleted elsewhere closes its view unless a dirty edit is open
- Synced scene metadata is no longer marked dirty or written back by the panel
- Sync keeps the local draft name; reference use cases name their update source

* Catch specific exceptions in the entry refresh
2026-10-05 08:09:05 -07:00
Adam Brown
28b4df9b4a
Offer in-app updates on the GitHub desktop builds (#978)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Offer in-app updates on the GitHub desktop builds

AppUpdater in :common with a no-op for every store build, and a Nucleus
backed NucleusAppUpdater in :desktop that checks shortly after launch and
every six hours while the new automaticUpdateChecks setting allows it. One
action, update(), downloads, verifies and hands the installer over; MSI,
EXE, DEB, RPM and AppImage installs relaunch on the new version, macOS
installs are told and sent to the release page until the signed-zip work
lands. A notice strip above the project list, a status row on the About
screen, and a settings toggle, each with previews and render tests.

* Keep an offered update on screen while it is re-checked

A check only shows Checking when nothing better is known, so the six-hourly
re-check no longer blinks the banner away. The updater's shared fields are
volatile, the banner and the About row share one state-to-text mapping,
the download test hands the installer over through a CompletableDeferred,
and the desktop platform settings component gets a test for the new toggle.

* Lay the update notice on the page and allow an environment override

The banner now renders inside the project list under the masthead, inset
to the page gutter, instead of as a strip above the whole content pane
butting against the nav rail. HAMMER_UPDATE_BASE_URL stands in for the
system property so an installed build can be pointed at a local server,
which is how the update from a 3.10.99 test build to the published 3.11.0
MSI was exercised end to end. The download test cleans up its staging
directory, as the real installer script does.

* Give the Android project selection preview fake the update members
2026-10-05 03:09:55 -07:00
Adam Brown
645225e3ac
Pin the Kotlin 2.4.20 fix for responds in value position instead of expecting the old cast (#988) 2026-10-05 01:39:01 -07:00
Adam Brown
de23cbec43
Update the compose-preview plugin to 2.34.0 so previews render against Skiko 0.150 (#986) 2026-10-04 23:26:10 -07:00
Adam Brown
385a6f28d2
Fix backup restore nesting the project inside itself (#982)
* Fix backup restore nesting the project inside itself

Backup archives are rooted at the project's directory name, so unzipping
into the project directory left it without a project.toml at its root and
the project vanished. Restore now extracts to a staging directory and
swaps the unwrapped project into place, leaving the existing project
untouched if extraction fails.

The Backup Manager also lists backups of projects that no longer exist,
and reports a failed restore.

* Harden backup restore swap and missing-project listing

Move the existing project aside instead of deleting it so a failed swap
puts it back, serialize restores that share the staging directory, and
list a missing project once when it has both legacy and current backup
names.
2026-10-04 23:11:57 -07:00
Adam Brown
2b4b1698b3 Version bumps 2026-10-04 22:54:10 -07:00
Adam Brown
4f2004431f
Stamp builds with their package format and publish update metadata (#977)
* Stamp builds with their package format and publish update metadata

-Pformat=<token> is resolved in buildSrc, baked into BuildMetadata, and
printed in the startup banner next to the channel; every packaging step
passes its own. The MSI UpgradeCode is pinned to the value every installed
copy already carries. writeUpdateMetadata renders latest*.yml from the
attached installers and the release workflow uploads them to the draft
before it publishes. HammerReleaseProvider points the Nucleus updater at
releases/latest/download so partial-release tags cannot break downloads.

* Hash installers where they are built instead of re-downloading them

Each platform job now writes a <installer>.sha512 sidecar beside what it
attaches and hands the sidecars to the final job as a workflow artifact.
That job assembles latest*.yml from them and attaches the yml with the same
release action every other job uses, so nothing is downloaded back from the
draft and nothing depends on looking a draft release up by tag.
2026-10-04 22:45:07 -07:00
Adam Brown
56e6e82b82
Fix PDF export cutting off lines at page breaks (#981)
pdfkmp cannot split rich text, rows or decorated containers across
pages, so prose is wrapped into lines up front and laid out one node
per line.
2026-10-04 22:36:24 -07:00
Adam Brown
215fbf6537
Add export options for chapter numbering, font and blank lines (#980)
Three new options in the Export Story dialog, from user feedback on Word, EPUB and PDF exports.

- Number chapter titles: off by default for every format. On prefixes each title with its position in the headings and the Contents page. Word, RTF, PDF and Markdown used to number always; EPUB never did and now can.
- Font: Georgia, Times New Roman, Garamond, Arial, Verdana or Courier New for Word, RTF and EPUB. PDF keeps its built-in face.
- Keep blank lines between paragraphs: on by default. Off drops a lone blank line so the indent is the only separator; a run of two or more, and the boundary between scenes, still leaves one break.
2026-10-04 22:17:45 -07:00
Adam Brown
2ad0322067
Derive the Mac App Store build mode from the distribution channel (#975)
* Derive the Mac App Store build mode from the distribution channel

The App Store build was detected two ways: Gradle read -PmacOsAppStoreRelease
for packaging and passed -Dhammer.app.store=true so IS_APP_STORE could gate
runtime behaviour. The channel enum already knows the same fact from
-Pchannel=mac-app-store, which build-appstore.sh passed alongside.

isAppStoreRelease now resolves from the channel token, the JVM property and
IS_APP_STORE are gone, and the three runtime readers (sandbox startup, the
bookmark-backed file access binding, the in-app changelog gate) branch on
DistributionChannel.current like every other per-vehicle rule.

* Share the channel resolver across build scripts and name the App Store check

Project.distributionChannel() in buildSrc owns the -Pchannel and F-Droid
detection, so :base and :desktop cannot disagree on the channel.
DistributionChannel.isMacAppStore replaces three spelled-out comparisons,
and supportsInAppChangelog is a pure function of the channel with a test
pinning the table. Docs corrected: section 1 is done, GitHubProvider only
uses the REST API for pre-release channels, and the App Store build also
needs -PbuildNumber.
2026-10-04 22:11:24 -07:00
Adam Brown
ba28b01b82
Add OLED black theme (#979)
Dark theme with a pure black background and base surface. Applies to project color themes and Android dynamic color too.

Offer the OLED theme on Android and iOS only
2026-10-04 17:04:36 -07:00
Adam Brown
d884591bf6
Add Reader Mode (#976)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Read the live story as prose, one chapter at a time, from the story
editor details pane. Opens from the scene list footer, the scene editor
top bar, or the editor menu.

- Scenes in a chapter are stitched together with an ornament between
  them; a toggle shows scene headings instead.
- While the reader is open the scene list works as a table of contents,
  and its highlight follows the scene being read.
- Reader font size is its own setting. Text is selectable.
- Chapter and scene position survive rotation and process death.

Chapter collection is now shared with export, and the outline's chapter
pieces move into the design system. MarkdownView gains opt-in
importDuringComposition and isSelectable. The footer's outline button
uses a new short "Outline" string.
2026-10-03 23:59:24 -07:00
Adam Brown
ca22ec262d
ComposeTextEditor 3.0.2 and a render-time editor text size (#973)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Update ComposeTextEditor to 3.0.2

Styles move to TextEditorState.richTextStyles, headings use the block API so
they export as headings, and shortcuts match on layoutKey. Markdown pins
ParagraphSeparator.NEWLINE to keep the 2.8 on-disk format.

* Make the editor text size a render property

Rich text styles are a fixed set with em-relative headings, so the body size
lives on the editor text style and changing it rewrites no document. The
size steps through a ladder, shows its value in the format bar menu, and
answers Ctrl+Plus, Minus and 0.
2026-10-03 13:36:53 -07:00
Adam Brown
5d363ee7c4
Add a "Surprised me" kudos reaction (#971)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-10-02 21:47:14 -07:00
Adam Brown
4edff36e95
New Crowdin updates (#968)
* New translations messages_en.properties (Turkish)

[ci skip]

* New translations strings.xml (Turkish)

[ci skip]

* New translations strings_about_app.xml (Turkish)

[ci skip]

* New translations strings_account_settings.xml (Turkish)

[ci skip]

* New translations strings_desktop.xml (Turkish)

[ci skip]

* New translations strings_drafts.xml (Turkish)

[ci skip]

* New translations strings_encyclopedia.xml (Turkish)

[ci skip]

* New translations strings_notes.xml (Turkish)

[ci skip]

* New translations strings_project_home.xml (Turkish)

[ci skip]

* New translations strings_project_navigation.xml (Turkish)

[ci skip]

* New translations strings_project_select_navigation.xml (Turkish)

[ci skip]

* New translations strings_projects_list.xml (Turkish)

[ci skip]

* New translations strings_scene_editor.xml (Turkish)

[ci skip]

* New translations strings_scene_list.xml (Turkish)

[ci skip]

* New translations strings_sync.xml (Turkish)

[ci skip]

* New translations strings_timeline.xml (Turkish)

[ci skip]

* New translations strings_android.xml (Turkish)

[ci skip]

* New translations full_description.txt (Turkish)

[ci skip]

* New translations short_description.txt (Turkish)

[ci skip]

* New translations title.txt (Turkish)

[ci skip]

* New translations strings_globalsearch.xml (Turkish)

[ci skip]

* New translations strings_ideas.xml (Turkish)

[ci skip]

* New translations strings_wear_pairing.xml (Turkish)

[ci skip]
2026-09-29 23:24:46 -07:00
Adam Brown
569b6c0297
New Crowdin updates (#965)
* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations strings_wear_pairing.xml (Italian)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_navigation.xml (Italian)

[ci skip]

* New translations strings_android.xml (Italian)

[ci skip]
2026-09-27 21:04:01 -07:00
Adam Brown
1cdcdad657
Recover from an undecryptable auth token keyset instead of crashing (#960)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
* Recover from an undecryptable auth token keyset instead of crashing

Exclude the encrypted token prefs from backups, and attach the R8 mappings
to GitHub releases.

* Reset the Keystore master key along with the token prefs
2026-09-27 17:43:25 -07:00
Adam Brown
13b078f26c
Add core-splashscreen to the Android phone app (#964) 2026-09-27 17:41:58 -07:00
Adam Brown
a39b1e7fd4
Update ComposeTextEditor to 2.8.0 for Compose 1.12 IME APIs (#961)
2.6.0 was built against Compose 1.11 and lacks the IME members 1.12 made
abstract, so focusing an editor on iOS threw IrLinkageError on
ImeComposeStateAdapter.text and killed the app.
2026-09-27 16:22:06 -07:00
Wavesonics
3f038d836f
Fix keyboard options on non-prose text fields
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
URL, numeric, tag, word-list, and delete-confirmation fields inherited
sentence capitalization and autocorrect, which inserted a space after
'.' in server URLs.
2026-09-25 23:52:26 -07:00
Adam Brown
1a433d78f9
New Crowdin updates (#955)
* Update source file full_description.txt

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]

* Update source file full_description.txt

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* Update source file Messages_en.properties

[ci skip]
2026-09-25 23:40:09 -07:00
Wavesonics
04de488fb0
Treat any-script letters, marks, and digits as word characters in reference matching
Names no longer match inside words containing accented or non-Latin letters ("Ana" in "Anaïs", "Иван" in "Иванов").
2026-09-25 17:51:38 -07:00
Adam Brown
1c22fdb926 Prepared for release: v3.11.0
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-09-24 23:47:29 -07:00
Adam Brown
5be23d0d0a Merge dependency updates and window size class replacement 2026-09-24 23:39:00 -07:00
Adam Brown
9ccb3d6216 Replace the Material 3 window size class dependency with our own
The desktop implementation of calculateWindowSizeClass reads an AWT window,
which does not exist under the Tao backend, and its LocalWindow reference was
removed in Compose 1.12. Size classes now derive from layout constraints.
2026-09-24 23:36:44 -07:00
Adam Brown
675fcafe17 Update Compose, Ktor, Glance, pdfkmp, kmp-zip and WorkManager 2026-09-24 23:33:27 -07:00
Adam Brown
47a2ac7c27 Update colorpicker-compose to 1.3.0 2026-09-24 22:19:44 -07:00
Adam Brown
cb4f7d0875 Update Coil to 3.6.3 2026-09-24 22:19:12 -07:00
Adam Brown
7bddcea632 Update SQLDelight to 2.4.0 2026-09-24 22:18:33 -07:00
Adam Brown
30d75760f1 Update Cairn to 0.4.0 2026-09-24 22:06:43 -07:00
Adam Brown
1a26dee270 Update Nucleus to 2.5.18 2026-09-24 21:56:05 -07:00
Adam Brown
277b1555c1 Update FileKit to 0.16.0 2026-09-24 21:55:24 -07:00
Adam Brown
a501bd791e Update Wear Compose to 1.7.0 2026-09-24 21:54:44 -07:00
Adam Brown
e3c1b62a5d Update ComposeTextEditor to 2.6.0 2026-09-24 21:50:29 -07:00
Adam Brown
9dcf462178
Keep the Wear pairing capability from resource shrinking (#958) 2026-09-24 21:49:16 -07:00
Adam Brown
e895ed3a8e Bump low-risk dependencies to their latest patch releases 2026-09-24 21:38:29 -07:00
Adam Brown
61618061f6 Keep the Wear pairing capability from resource shrinking 2026-09-24 21:09:26 -07:00
Adam Brown
5001376e78
Add reader kudos to published stories (#957)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Signed-in readers can leave kudos at the end of a publicly published
story: up to four craft chips and one reaction. There are no comments
or ratings, so there is nothing to moderate.

- Kudos are anonymous. Authors see per-chip totals on their story page;
  the public page shows a chip's name, never a count, once three readers
  pick it.
- The reader card loads as its own HTMX fragment on the last page, so
  the story page's ETag never depends on kudos. Private shares never
  show it.
- Authors can turn kudos off per story; existing kudos are kept.
- New story_kudos and story_kudos_opt_out tables (migration 9.sqm).
  Chip keys are a code enum, rows are purged in both directions on
  account deletion, and soft-deleted givers drop out of the counts.
- Story URL resolution is shared between the story page, the read
  beacon, and the kudos routes.
2026-09-23 23:57:36 -07:00
Wavesonics
fcbfa1bee5
Exclude standalone Markdown markers from word counts
Only count runs of non-whitespace containing a letter or digit, so
horizontal rules, heading, bullet and blockquote markers are not words.
Move countWords to :base and use it on the server too.
2026-09-23 23:55:53 -07:00
Wavesonics
e79859d091
Upload store screenshots and images on production release 2026-09-23 19:08:46 -07:00
Wavesonics
d8835b118c
Enable R8 obfuscation for Android and Wear; upload mapping files to Play 2026-09-23 18:00:47 -07:00
Wavesonics
61b3b21ab9
Prepared for release: v3.10.1
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-09-23 07:53:53 -07:00
Wavesonics
ab759feae1
Tighten Wear list items after review
Headers take the library's own transformation, free text gets a side
inset and sync log entries are capped at five lines so they stay inside
a round bezel. The capture splash holds until the project list loads,
list content modifiers are remembered per item, and one app theme is
shared by the manifest and the splash style.
2026-09-23 07:51:13 -07:00
Wavesonics
e771482c1a
Fix Wear OS Play rejection: splash, listing text, round screens
Branded launch: core-splashscreen theme shows the launcher icon on black
for MainActivity and CaptureActivity.

Play listing names the Capture tile and the complication.

Every TransformingLazyColumn item now uses the Material 3 scroll
transformation so buttons, headers and text shrink and fade at the
bezel instead of being clipped on round displays. The custom
screenContentPadding helper is dropped; the scaffold's padding already
includes the horizontal inset.
2026-09-23 00:34:13 -07:00
Adam Brown
4f047fdd7e
Add Wear OS screenshots and listing text (#954)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
* Rename Microsoft Store display name to Hammer: Story Editor

* Add Wear OS screenshots and a Wear OS section to the Play listing
2026-09-20 21:21:01 -07:00
Adam Brown
d1dc121639
Split release store notes into Google Play and Apple tabs (#953) 2026-09-20 21:04:29 -07:00
Adam Brown
6394005f60 Prepared for release: v3.10.0
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-09-20 10:16:01 -07:00
Adam Brown
48fd14000c
New Crowdin updates (#926)
* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_encyclopedia.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_wear_pairing.xml (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_wear_pairing.xml (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_wear_pairing.xml (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_wear_pairing.xml (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_wear_pairing.xml (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_wear_pairing.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file strings_wear_pairing.xml

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_scene_editor.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (Spanish)

[ci skip]

* New translations strings_scene_editor.xml (Spanish)

[ci skip]

* New translations strings_encyclopedia.xml (German)

[ci skip]

* New translations strings_scene_editor.xml (German)

[ci skip]

* New translations strings_encyclopedia.xml (Italian)

[ci skip]

* New translations strings_scene_editor.xml (Italian)

[ci skip]

* New translations strings_encyclopedia.xml (Ukrainian)

[ci skip]

* New translations strings_scene_editor.xml (Ukrainian)

[ci skip]

* New translations strings_encyclopedia.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_scene_editor.xml (Portuguese, Brazilian)

[ci skip]
2026-09-20 10:02:03 -07:00
Adam Brown
cbc88944a4
Add a project-scoped user spelling dictionary (#939)
Add a project-scoped user spelling dictionary (#939)

Writers can whitelist words per project: "Add to dictionary" on a flagged word in
the scene editor and focus mode, plus an add/remove word list in Project Settings
under Spell checking.

Words live in ProjectData.dictionaryWords so they sync with the project. A conflict
confined to the word list merges both sides by union with no resolver; other
conflicting fields still go through the existing resolver with the dictionary
unioned. The union is verbatim, so a word a newer build stored under laxer rules
is never deleted server-side. Sync writes also re-apply any project-data edit made
after the phase snapshot, so an edit landing mid-sync is no longer clobbered.

ProjectDictionaryService now feeds user words to the checker alongside encyclopedia
words, independent of the encyclopedia toggle. HdHairlineTagField and the new
HdHairlineWordListField share an extracted HdHairlineChipInput.
2026-09-20 09:45:19 -07:00
Adam Brown
66875346d0
Widen the character set for encyclopedia and draft names (#952)
Encyclopedia entry titles and scene draft names were still on the old
restricted set while project and scene names had moved to the shared
ProjectNameValidator. Both now use that validator, so punctuation like
`. , ! ? : ( ) & - "` works, and draft names accept non-Latin letters.

Both filename formats move to the `~` delimiter, since `-` is now a legal
name character:

  encyclopedia  type~id~name.toml, images type~id~image.ext
  drafts        sceneId~draftId~name~timestamp.md

Names round-trip through encodeForFilename/decodeFromFilename, so
OS-forbidden characters become lookalikes on disk. Legacy patterns are
kept for reads, and path resolution falls back to the legacy filename,
because unlike scenes these two rebuild the filename from the def rather
than scanning the tree.

Migration2_3 renames existing files and PROJECT_DATA_VERSION goes to 3.
It canonicalises names through the encoder, otherwise a name ending in a
space (which the old rules allowed) would migrate to a file that no
rebuilt path could ever match. Draft names are now trimmed at the save
and sync boundaries the way entry names already were.

A synced name containing a path separator is no longer rejected outright;
it is encoded to a lookalike and stays one path segment, matching how
scene names already behave. Containment rests on the isWithin guard. The
reserved `~` delimiter is still rejected.
2026-09-20 09:01:26 -07:00
Adam Brown
97e5a2d196
Ship the watch app to Google Play's Wear OS tracks (#951)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The Android lanes build and upload the wear bundle after the phone's, and prepareForRelease writes release notes under the wear version code too.
2026-09-19 20:27:02 -07:00
Adam Brown
6738235141
Wear OS app: capture notes and story ideas from the wrist (#950)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
A standalone watch client that dictates notes into subscribed projects
and story ideas, then syncs them on its own session.

- New :wear module: capture activity, tile and complication, project
  subscriptions, sync log, and a WorkManager periodic plus
  after-capture sync behind a single SyncCoordinator
- Pairing over the Wearable Data Layer: the phone confirms the request
  and mints a session for the watch's install via the new
  /api/account/pair_install endpoint. Play Services builds only; the
  F-Droid build ships without it
- Manual sign-in on the watch, with an Android 17 local network
  permission check before contacting a LAN server
- Plaintext sync is opt-in: typing an http:// URL selects it, with a
  warning in server setup. HTTPS stays the default, and legacy
  settings always restore as HTTPS
- Account sync extracted into SyncAccountUseCase, shared by the phone
  project list and the watch
- temporaryProjectTask ref-counts concurrent users so one task no
  longer closes a scope another is still using
- Wear version codes are offset from phone codes so both can ship in
  one Play listing
- Crash handler and FileLogger moved into common for reuse
2026-09-19 10:58:19 -07:00
Adam Brown
4189d272c4
Point iOS downloads at the unified App Store listing (#949)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
The iOS build now ships under the same App Store record as macOS, so the
home page and README link to id6770841038.
* Offer the iOS app with a smart app banner on the home page
2026-09-15 17:06:39 -07:00
Adam Brown
3df7f27617
Say why a login failed instead of answering a bare 401 (#937)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Say why a login failed instead of answering a bare 401

A failed login gave nobody anything to work with. StatusPages matched status 401
and re-responded with a bare status for API calls, discarding the HttpResponseError
the route had just written, and the client's own 401 branch discarded whatever
body did survive in favour of a generic string. Two layers erasing the same
message, so a self-hoster saw "401 Unauthorized" in the log, an empty body on the
wire, and a generic message in the app.

StatusPages now leaves API responses alone, since the routes answer 401 with
their own body, and the client parses the body for every status and only falls
back to a generic message when the server sent nothing usable.

On top of that:

- HttpResponseError carries an optional errorCode from a shared ApiErrorCode
  vocabulary. It is optional and the shared serializer ignores unknown keys, so
  it is compatible with servers and clients on either side of this change.
- Account creation failures use accurate statuses rather than a blanket 409:
  400 for a policy or email violation, 409 for a real conflict, 403 for the
  whitelist. Login answers 403 when the whitelist was the problem, since that is
  not a credential failure.
- The login path logs which failure occurred. The response still cannot
  distinguish an unknown account from a wrong password, because that would let
  anyone enumerate users, but the operator's log now can.

The docs gain the password policy (8 to 64 characters, no complexity rule,
nothing stripped or truncated) and the log lines to look for, which is what the
reporter of #835 asked for.

* Allow the round-trip test accounts now that Allowed Users is always on

Only the first account on a server is exempt, so every later address the test
creates has to be on the list or account creation answers 403.

* Point the login-failure note at the Allowed Users section

The section was renamed, so the anchor was dead.

* Cover the account error statuses and the client's failure body

AccountErrorCodeTest walks the create and refresh failures a client branches on:
existing email, email pending deletion, malformed email, short password, unknown
refresh token. ApiFailureBodyTest covers the other end, where a server message
has to survive instead of being replaced by the generic one.

RecordingStrRes moves to its own file so both API tests can use it.

* Mark the password error codes as non-secrets for semgrep
2026-09-14 19:05:25 -07:00
Adam Brown
404d386d12
Add a Copy Diagnostics action to the About screen (#940)
* Add a Copy Diagnostics action to the About screen

Puts the startup banner (version, channel, OS/JVM) and the last 200 lines
of the current log on the clipboard, so a bug report can carry its own
diagnostics instead of asking the reporter to find, zip, and attach a log.

The blob always includes the banner: a missing log costs the reporter the
log, not the whole report.

The buttons move into a FlowRow so three of them wrap on a narrow window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Include the latest crash dump in Copy Diagnostics

* Only report the XDG session in the desktop banner on Linux

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:36:51 -07:00
Adam Brown
1bc1e37460
Fix the Microsoft Store build, and stamp builds with their distribution channel (#938)
* Switch the desktop Ktor engine to OkHttp

`java.net.http.HttpClient`'s constructor opens an NIO `Selector`, whose
Windows wakeup pipe is an AF_UNIX socket. The MSIX sandbox rejects the
connect with EINVAL, so every Microsoft Store build hard-crashes on the
first network call, before any request is sent (JDK-8312215, open since
Java 17 with no fix in sight). OkHttp uses blocking socket IO and is
unaffected.

Ships unconditionally rather than gated on the distribution channel:
OkHttp works everywhere, and a second engine would be a second code path
to test forever. Android already uses it.

Includes the unchecked-IO mapping from `fix/unchecked-io-network-errors`,
so a client that cannot be built at all surfaces as a network error
rather than taking down the app.

* Add an Export Logs button to the desktop About screen

Opening the log directory hands the shell a path that does not exist under
an MSIX container: the app's writes to %LOCALAPPDATA% are redirected into
the package's LocalCache, but reads fall through, so the app sees the
directory and Explorer does not. Snap confinement will do the same on
Linux. Exporting a zip to a location the user picks works on every
distribution vehicle, which makes it the one support instruction that
never needs a per-channel caveat.

Also walk up to the nearest existing ancestor before handing a directory
to the shell, so the open button degrades instead of erroring.

* Bake the distribution channel into the build

Per-vehicle rules (self-update, store payment policy, "get the app" links,
sandbox-aware paths) were tracked by hand. `-Pchannel=<token>` now resolves
to a `DistributionChannel` constant, defaulting to DEV, and an unknown
token fails the build rather than quietly shipping a store binary as DEV.
Every release pipeline passes its own channel; the existing F-Droid build
flags map to FDROID without their call sites changing.

Flat enum rather than capability flags: every channel-conditional branch is
a greppable `when` on the type. Flags can emerge later from actual
duplication.

The startup banner and all three crash dumps now carry the channel. That
line pays for itself immediately: this investigation started from a crash
log that did not say which build produced it.

* Un-redirect container paths before handing them to the shell

MSIX filesystem redirection is asymmetric: the app's writes under
%LOCALAPPDATA% land inside the package container, but reads fall through,
so File.exists() is true from inside the container and the app is
satisfied. Explorer runs outside it and correctly reports nothing at the
literal path, which is the "location is not available" dialog the
open-logs button produced on the Store build.

Rewrite the path into the container for the Microsoft Store channel, both
where it is shown and where it is handed to the shell. The package family
name is hardcoded: Windows derives its hash suffix from the publisher ID,
so it is not in AppxManifest.xml and cannot be computed from it, and it
only changes if the Store identity does.

`Windows.Storage.ApplicationData.Current.LocalCacheFolder` is the native
answer but means a WinRT dependency on a KMP desktop target for one path
lookup. Worth revisiting only if more packaged-app APIs are needed.

* Cover the About log section with a render test, document the channel

* Suppress TooGenericExceptionCaught on the unchecked IO catch
2026-09-14 15:38:15 -07:00
Adam Brown
4b2dff8c23
Map unchecked IO failures to a real error message (#933)
* Map unchecked IO failures to a real network error message

The JDK builds its `java.net.http.HttpClient` lazily on the first request,
and `HttpClientImpl`'s constructor wraps a failed `Selector.open()` in an
`UncheckedIOException`. On Windows that selector is a loopback socket pair,
so a machine with a firewall, VPN filter driver, or exhausted ephemeral port
range fails with `java.io.IOException: Unable to establish loopback
connection` before a request is ever sent.

`UncheckedIOException` is a `RuntimeException`, so it slipped past every
catch in `Api.makeRequest` and surfaced in the sync log as a raw Java class
name. Route it through the same mapping, with a dedicated message: nothing
reached the network, so this must not read as a server or connectivity
problem.

* Only log "Sync complete!" when the sync actually succeeded

`handleSyncFailure` calls `onComplete`, which logged the success line
unconditionally, so a failed sync ended with an error immediately followed
by "Sync complete!". Thread the outcome through `onComplete` and gate the
log on it; the progress reset still runs either way.

* Suppress TooGenericExceptionCaught on the unchecked IO catch
2026-09-14 15:38:11 -07:00
Adam Brown
ca909f4a6b
Add a shared network JSON serializer that tolerates unknown keys (#934)
`SYNCING-PROTOCOL.md` makes adding a field to a synced model a client-only
change: the server stores content as an opaque blob, and a peer that predates
the field is expected to drop it on decode. That only holds if the decode
ignores unknown keys.

Every `ContentNegotiation` install used a bare `json()`, which is Ktor's
`DefaultJson`: `encodeDefaults` and `isLenient`, but no `ignoreUnknownKeys`. So
instead of dropping the field, an older peer failed the whole sync. A 3.6.0
client hitting project data written by 3.9 got:

    Sync failed: Illegal input: Unexpected JSON token at offset 72:
    Encountered an unknown key 'language' at path: $.data

`createJsonSerializer` already sets `ignoreUnknownKeys`, but it is meant for
human-facing files: `prettyPrint` would put tabs and newlines in every request
body, and `coerceInputValues` would silently coerce bad values on the wire.
Split the two: files keep that one, machine-facing content gets
`createNetworkJsonSerializer`, resolved through `NetworkJsonQualifier`.

Applies to the three `ContentNegotiation` installs and the four classes that
decode wire content with an injected `Json`: `ServerProjectApi` (entity
payloads), `ProjectDataApi` and `ServerIdeasApi` (conflict bodies), and
`GithubVersionCheckDataSource`. The file-facing injections are unchanged.

This cannot reach already-shipped builds; 3.6 through 3.9.x keep failing
against projects a newer client has touched.
2026-09-14 15:33:32 -07:00
Adam Brown
375d4553c6
Stop credential fields from auto-capitalizing what is typed (#936)
* Stop credential fields from auto-capitalizing what is typed

HdHairlineField defaults to KeyboardCapitalization.Sentences, and neither the
server setup dialog nor the reauthentication dialog overrode it on their email or
password fields. An IME that honours CAP_SENTENCES on a password variation
capitalizes the first character as it is entered, so the account is created with
a password the user cannot reproduce in a browser, where nothing capitalizes
anything. The failure looks exactly like a rejected password.

HdPasswordField and HdEmailField pin the keyboard options that credential entry
needs (no auto-capitalization, no autocorrect) so a screen cannot forget them.
HdPasswordField also absorbs the show/hide toggle that both dialogs had inlined
identically, and carries the 8 to 64 character rule as a field hint, formatted
from PasswordValidator so it cannot drift from what the server enforces.

* Add a design-system preview for the credential fields

Covers the password field masked with its length hint, revealed with an error,
and the email field beside them.
2026-09-14 15:33:17 -07:00
Sam Goldman
dd00aa12bf
Fix Community Pagination Links (#942)
* Fix previous and next page links on community authors page

* Fix previous and next page links on community feed page
2026-09-14 15:29:01 -07:00
Adam Brown
d0331c45d7
Fail fast on App Store Connect validation errors (#941)
deliver_with_retry treated every error as transient. It exists for App Store
Connect's eventual consistency around build lookup and attachment, but a
validation failure will fail identically forever — the first 3.9.7 iOS submit
spent all six attempts and five minutes restating "App screenshot missing"
before surfacing it.

Errors matching a narrow set of validation patterns now raise immediately. The
list is deliberately narrow: anything unmatched still retries, since a new
permanent error costs a few wasted attempts while a misclassified transient one
costs a failed release. Matching is on message text rather than exception class
because Spaceship raises UnexpectedResponse for transient faults too.

Permanent errors now print the whole message. App Store Connect puts the
summary on line 1 and the actual reasons underneath, and the retry logging only
ever printed the first line, which is part of why the real cause was hard to
see.

submission_landed? still runs first, so the case where deliver raises after the
submission already landed is unaffected.
2026-09-14 15:28:37 -07:00
Wavesonics
59d1537950
Prepared for release: v3.9.8
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-09-12 00:54:08 -07:00
Wavesonics
1c2bdfafd5 Lay the iOS screenshots out the way deliver actually reads them
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
Setting screenshots_path was still not enough. deliver globs
<screenshots_path>/<locale>/*.png flat and does not descend into per-device
subdirectories — only appleTV and iMessage are special-cased. The images were
in en-US/APP_IPHONE_65/ and en-US/APP_IPAD_PRO_3GEN_129/, one level too deep,
so deliver kept finding an empty set and reporting success for uploading none.

Those APP_* folder names are App Store Connect API display types, which deliver
never reads: it derives the device from the image's pixel dimensions. So the
files now sit flat in fastlane/screenshots/ios/en-US/, out of the metadata tree
entirely, and screenshots_path points there.

Filenames avoid "app_ipad_pro_129" and the "12.9"/"2nd generation" pair,
because 2732x2048 is ambiguous between the 2nd- and 3rd-gen iPad Pro and
deliver disambiguates on the path. Without those strings it resolves to
APP_IPAD_PRO_3GEN_129, which is what App Store Connect asks for.

Verified against deliver 2.237.0's own glob and resolution table: 15 images
discovered, 7 APP_IPHONE_65 and 8 APP_IPAD_PRO_3GEN_129, none unmapped.
2026-09-04 23:12:03 -07:00
Wavesonics
003cf0a013 Point deliver at the iOS screenshots so the submit can pass
Turning off skip_screenshots was not enough on its own. deliver keeps
screenshots_path as a separate option from metadata_path and defaults it to
./fastlane/screenshots, which this repo does not have — the images live beside
the metadata under fastlane/metadata/ios/<locale>/APP_*.

So deliver looked at an empty set, reported "Successfully uploaded all
screenshots" having uploaded none, and the v3.9.7+ios-app-store submit failed
with "App screenshot missing (APP_IPHONE_65)". The iOS platform newly added to
the macOS record starts with no screenshots, so there was nothing already in
App Store Connect to fall back on.

overwrite_screenshots is set because deliver_with_retry can call deliver
several times, and each attempt would otherwise add another copy of every
screenshot to the version.
2026-09-04 22:59:23 -07:00
Wavesonics
72304dfa87 Prepared for release: v3.9.7 2026-09-04 20:08:54 -07:00
Adam Brown
53ecfb4780
Publish iOS under the shared com.darkrockstudios.apps.hammer app ID (#935)
iOS and macOS were shipping as two separate App Store listings under two bundle
IDs. The supported model is Universal Purchase: one app record, one bundle ID,
one platform entry per OS. The binaries stay separate — each platform is still
built and uploaded independently with its own version stream and screenshots.

Points the iOS target and all four fastlane iOS identifiers at
com.darkrockstudios.apps.hammer, adds the for_platform :ios block the Appfile
was missing, and drops the hyphen from the iOS listing name, which only existed
because the unhyphenated name was taken by our own macOS record.

Stops skipping screenshot upload on the iOS lane. That was safe while iOS had
its own record and screenshots carried forward between versions; the iOS
platform newly added to the macOS record starts empty, and App Store Connect
refuses to submit a version with no iPhone screenshots.

Adds a shared concurrency group to the two App Store publish workflows. Both now
target the same App Store Connect record, and a full release tag starts them in
parallel. This is mutual exclusion rather than a needs: edge — neither waits on
the other's result, and a targeted single-platform tag skips the other job
before it reaches the gate.
2026-09-04 19:23:28 -07:00
Wavesonics
e833d57f8d Prepared for release: v3.9.6
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-09-03 20:25:54 -07:00
Wavesonics
405c3b0ae6
Add an auto-publish option to the release dialog
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
The dialog gets an "Auto-publish the GitHub release" checkbox, off by
default. When checked, prepareForRelease appends an `Auto-Publish: true`
trailer to the tag message; set-release-body strips it from the release
body and, once every build job passes, marks the release as the latest
production release, which starts the store publish workflow.

The tag name could not carry the flag: a `+` suffix there reads as a
partial release and would gate off every store. Publishing uses
RELEASE_PAT rather than GITHUB_TOKEN, because GitHub does not start
workflow runs from events triggered by the default token.

The dialog grew a section, so the frame is taller to keep the changelog
editor its original height.
2026-09-02 00:02:42 -07:00
Wavesonics
9fd796c342
Prepared for release: v3.9.5 2026-09-01 20:51:00 -07:00
Wavesonics
f17dd53b4e
Scrub git env vars before shelling out to git in release tasks
The pre-commit hook runs Gradle, so git's hook environment leaks into the
daemon. Gradle blanks rather than unsets those vars on later builds, and a
blank GIT_INDEX_FILE makes git read an empty index: releasePreFlightChecks
saw every tracked file as deleted and aborted the release.
2026-09-01 20:46:59 -07:00
Wavesonics
4ad20162f4
Squash merge no-in-app-changelog-on-apple into develop 2026-09-01 20:27:32 -07:00
Adam Brown
aa51c311dc Prepared for release: v3.9.4
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-08-22 17:48:02 -07:00
Adam Brown
92c0aaa3da Prepared for release: v3.9.3
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-22 10:00:40 -07:00
Adam Brown
d1ba0c6456 Prepared for release: v3.9.2
Some checks are pending
Build CI / iOS UI tests (push) Waiting to run
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-21 23:50:57 -07:00
Adam Brown
a6a17fb1e4 Keep ActionCallback constructors so widget taps work in release
Glance instantiates ActionCallback implementations reflectively, but its
consumer rule only keeps the class, not its members. R8 stripped the no-arg
constructor, so every widget tap threw NoSuchMethodException in minified
builds.
2026-08-21 23:25:05 -07:00
Wavesonics
893e29106d
Test sync webhook
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-20 23:07:38 -07:00
Adam Brown
bec73ea070
Stop submitting the GitHub changelog link to the Apple stores (#928)
prepareForRelease has appended a "Full changelog:" footer pointing at the
GitHub release page to every store's notes since v3.7.0. App Store review
rejects release notes carrying that link — it offers the app outside the
App Store — and iOS and macOS submissions from v3.7.0 on have come back as
invalid metadata. v3.6.1, the last release to ship, predates the footer.

formatStoreNotes and storeNotesLength now take a nullable footer URL, and
the Apple path passes null. Google Play keeps the link: its 500 character
limit means the notes really are truncated there, so the footer is the only
way to reach the full text.

Also strips the stale v3.9.1 footer out of the two committed
release_notes.txt files, so a resubmission of the current version does not
ship it while waiting on the next prepareForRelease.
2026-08-20 21:48:25 -07:00
Wavesonics
9ead030ab9
Extract a generic interface for user gating
Simplify allowed user management.
2026-08-20 21:17:45 -07:00
Adam Brown
d424a346ed Prepared for release: v3.9.1
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-08-18 00:02:23 -07:00
Adam Brown
66e02d9e6e
Render shared stories in scene tree order (#923)
renderPaginated never walked the scene tree, so share pages came out in
entity id order and any project not written front to back rendered
shuffled. Scenes now flatten depth first, siblings by order, with
unreachable ones appended rather than dropped.

A scene-limited share reads only its own scenes and the groups above
them, found via each scene's path, and its validator covers those groups
so a chapter swap no longer serves a stale order. Bumped RENDER_VERSION,
without which cached shares would keep serving the old order.

Chapter headings render as the author wrote them, unnumbered.
2026-08-17 23:58:25 -07:00
Adam Brown
ba70218a44
Support configuring the server time zone (#882) (#922)
Adds a `timezone` config setting, with `HAMMER_TIMEZONE` and `TZ` as
environment-variable fallbacks, applied at startup so both rendered page
timestamps and log lines use it.
2026-08-17 23:20:03 -07:00
Wavesonics
bdc2ea6966
Count probe-skipped projects in the overall sync progress bar
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-17 21:49:57 -07:00
Adam Brown
be04256597
New Crowdin updates (#911) (#920)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (Spanish)

[ci skip]

* New translations strings_encyclopedia.xml (German)

[ci skip]

* New translations strings_encyclopedia.xml (Italian)

[ci skip]

* New translations strings_encyclopedia.xml (Ukrainian)

[ci skip]

* New translations strings_encyclopedia.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-17 21:43:18 -07:00
Adam Brown
5c3d90ac7f Fix PDF export crash on desktop (#789)
ProGuard stripped org.apache.commons.logging.impl.LogFactoryImpl, which
LogFactory resolves by name, so PDDocument's static init threw
ExceptionInInitializerError on every PDF export from a packaged build.
Only pdfkmp-jvm pulls in PDFBox, so Android is unaffected.

Export failures also went uncaught in ExportDirectoryPicker on all three
platforms, killing the app instead of showing the existing failure toast.
2026-08-17 21:28:46 -07:00
Wavesonics
540392a7a7 Move the sandbox first-run flow onto native AppKit
The Mac App Store build's first-run folder picker ran through AWT, which
claims NSApp before Tao installs its event loop — the app then runs on
with no window and no crash. Move the picker and its alerts into the
Objective-C helper so nothing touches AWT before nucleusApplication().

Localized dialog text now comes from SandboxStrings, which reads the
packaged Compose Resources .cvr value files directly; getString() can't
be called pre-Compose because resolving a ResourceEnvironment
initializes AWT for the density qualifier. Value files layer English
under each locale so a key Crowdin hasn't round-tripped falls back to
real text rather than its own name. SandboxStringsTest guards the format
coupling, since a Compose upgrade would otherwise only surface as raw key
names on an App Store first launch.

Also declare the clang flags as an input on compileMacosBookmarksLib so a
flags-only edit can't leave a stale dylib for signMacAppResources to sign
into the bundle, and document the sandboxed config and data paths in
SUPPORT.md.
2026-08-17 20:42:00 -07:00
Adam Brown
d39819fbe6 Prepared for release: v3.9.0
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-16 22:38:10 -07:00
Adam Brown
8d5899bd46 Address review findings on encyclopedia dictionary
- Conflict resolution: onEncyclopediaEntryConflict now carries aliases and
  excludeFromDictionary into the local entity; "keep local" was stripping
  both, and with the flag in the entity hash that meant a conflict that
  could never converge (the alias drop predates this feature)
- ViewEntryComponent: one saveEntry(transform) helper replaces six
  hand-threaded updateEntry call sites and loads content from disk when
  the async state load hasn't landed, so a save can no longer revert
  aliases or the exclusion flag to defaults
- SpellCheckRepository: SupervisorJob + guarded checker calls so a
  platform failure can't kill the settings collector; appliedCandidates
  recorded only after a successful apply so failures retry
- ProjectDictionaryService: initialize() instead of init side effects,
  cancel-before-clear plus an isActive check to keep a racing rebuild
  from re-adding a closed project's words, spellCheckSettings.enabled
  added to the rebuild gate, injectDefaultDispatcher(), and
  loadEntriesImperative() now returns the defs
- temporaryProjectTask scopes skip the dictionary service, ending the
  checker churn during bulk sync and import
- HdHairlineToggleRow gains an enabled param (semantic disable, no
  ripple) replacing three hand-rolled Box+alpha idioms
2026-08-16 20:50:41 -07:00
Adam Brown
5766d2b532
Connect scenes to every encyclopedia entry type (#917)
ReferenceIndexConfig.default() now enables all five EntryTypes, so Thing,
Event, and Idea entries get linked to the scenes that mention them. The
metadata panel already bucketed refs into Characters/Locations/Other, so
the new types land in Other with their own glyphs.

Add-reference search takes a type set and applies it before the result
cap, so one tab can no longer starve another out of the slice. The
appearances chart filters to PERSON to stay true to its label; the
separate connections total still spans all types.
2026-08-16 20:47:47 -07:00
Adam Brown
4f2c8fb503
Encyclopedia names feed the spell check dictionary (#918)
Entry names and aliases become session-only words (AppLocal scope, never
the OS dictionary) while their project is open, cleared on close. Words
are tokenized to single words and filtered against the base dictionary
so only unknown spellings are added.

Three levels of control, all live-reactive:
- Global toggle in Spell Check settings (SpellCheckerSettings, default on)
- Per-project toggle (ProjectData.encyclopediaDictionary, synced;
  hashed only when false so existing hashes are stable)
- Per-entry exclusion (EntryContent.excludeFromDictionary, synced;
  hashed only when true, same zero-bytes-at-default rule as aliases)

SpellCheckRepository holds session words keyed by ProjectDef and emits a
fresh checker instance whenever the effective word set changes, so open
editors re-run their full scan; the same path re-applies words on locale
change and re-enable. ProjectDictionaryService (ProjectDefScope, eagerly
started in initializeProjectScope) rebuilds the word set from the
encyclopedia on entry changes, debounced, and clears it on scope close.
2026-08-16 20:20:31 -07:00
Adam Brown
5d02c09109
Drop the redundant per-call English message fallback (#916)
#909 installs EnglishFallbackMessageResolver, so every call.t/Msg.r site
already resolves an untranslated key against English. The helper added in
#915 duplicated that for three call sites, and its comment described a
hazard that no longer exists.

The German-locale rename conflict test stays; it passes through the
resolver instead.
2026-08-16 16:06:35 -07:00
Adam Brown
6d7f53db04
Fix renaming a project onto a deleted project's name (#862) (#915)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Account sync pushed renames before deletions, so renaming a project into
a name still held by one queued for deletion violated the server's
UNIQUE(name, user_id). Phases now run delete -> rename -> create.

The violation also escaped as a 500: ProjectDao maps SQLSTATE 23505 to a
ProjectNameTaken failure and /rename answers 409 Conflict.

Also in here:

- Renames queued against a server-tombstoned project id are dropped.
  They could only 404 and requeue every session.
- API error messages fall back to English. The locale bundles have no
  base Messages.properties to parent through, so a key present only in
  English threw MissingResourceException and made the response a 500 for
  every other locale.
- Rename's ProjectNotFound reported "Invalid project name".
2026-08-16 12:04:35 -07:00
Adam Brown
f73c8354ff
Add scene selection to story export (#914)
* Add scene selection to story export

Mirrors the web scene-limited shares: ExportOptions.sceneIds (null = entire
story, empty fails closed), filtered at the single chapter-building point so
all five formats renumber automatically. The export dialog is now fully
controlled by the retained component state, so in-dialog edits survive
rotation (#885 class).

* Address export scene-selection review findings

- beginProjectExport reads the scene tree off the click handler
- Dialog options freeze while the non-blocking file picker is open, and all
  three platform pickers snapshot options before their suspend
- Cancel discards in-dialog edits again; restore happens in onDismissed so
  nothing flashes during the close animation
- A full selection confirms as no limit, so empty chapter groups still export
- Sceneless projects hide the limit toggle; empty groups get no checkbox
- Options column scrolls on short screens instead of pushing the buttons off
- Group rows are toggleable with checkbox semantics for TalkBack
- Picker list/row lifted into the design system (HdPickerList/HdPickerRow),
  now shared with MoveSceneDialog

* Add a scrollbar to HdPickerList

Overlays the standard MpScrollBarList inside the picker list, covering both
the export scene selector and the move-scene destination list.
2026-08-16 11:55:10 -07:00
Adam Brown
72e1a2d214
Bump cairn to 0.3.0 (#910)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-16 00:38:21 -07:00
Adam Brown
18b10609bf
New Crowdin updates (#905)
* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-16 00:35:54 -07:00
Adam Brown
a94d5c783e
Fall back to English when a locale bundle is missing a key (#909)
ktor-i18n's ResourceBundleMessageResolver threw MissingResourceException
for any key a translation had not picked up yet, since the locale bundles
have no parent bundle to inherit from. Every Msg.r/call.t site turned that
into a 500, most visibly on /signup: rejecting a not-allowed email needs
api_allowedusers_rejected, which Crowdin had not yet delivered to de.

Fixes #883 (registration half)
2026-08-16 00:34:56 -07:00
Adam Brown
7e9bd46c26
Replace the native Argon2 binding with a pure-JVM implementation (#908)
Argon2 hashing went through JNA, which extracts libjnidispatch.so at runtime.
On Linux JNA ignores java.io.tmpdir and uses $XDG_CACHE_HOME, falling back to
<user.home>/.cache, and the Docker image sets user.home=/data. That puts the
shared object on the data volume, so hosts mounting it noexec cannot load it,
and signup and login returned a 500 (#884).

Argon2PasswordHasher derives with BouncyCastle instead, reading the variant,
version, and cost parameters back out of the stored PHC string. Its output is
byte-identical to libargon2 for the same inputs, so existing hashes keep
verifying; the tests pin that against hashes generated by the C implementation.

JNA is still on the runtime classpath via the CLI's terminal library, so the
image also points XDG_CACHE_HOME at its own layer.
2026-08-16 00:26:01 -07:00
Adam Brown
9cde66bef6
Fix AltGr shortcuts closing the desktop app (#859) (#907)
Windows reports AltGr as Ctrl+Alt, so typing @ on a Turkish-Q or German
QWERTZ layout matched the loose Ctrl+Q quit test and closed the app.

Window shortcut matching moves into WindowShortcuts.kt, built on the
exact-modifier matchesShortcut helper, which also tightens Ctrl+W and
Ctrl+Shift+F.

* Move save-all off Ctrl+Alt+S to Ctrl+Shift+S

Ctrl+Alt+S is itself an AltGr chord on Windows, so it fired and ate the
character on layouts that map AltGr+S, such as Polish (s with acute).
Rebound on desktop, Android, and iOS, and Android's global search now
matches its modifiers exactly like the rest.

No shortcut uses Alt now, which is what keeps AltGr keystrokes reaching
the editor.

* Keep Koin hint classes out of test detection
2026-08-16 00:00:10 -07:00
Adam Brown
8c871b344e
Search allowed users by email (#906)
Add email search to the Allowed Users admin page

Filter the list by full or partial email, matched case-insensitively anywhere in
the address. The query survives sorting, paging, remove and both edit dialogs.

The input sits in the page shell, not the swapped fragment, so it keeps focus and
caret while typing; the fragment renders its view state as hidden inputs that the
search box and add form pull in via hx-include.

Also fixes three faults on this path: paginated queries ordered on date_added
alone, letting LIMIT/OFFSET repeat and skip rows that share a timestamp; "Page 1
of 0" from a mustache section on a boxed Integer; and a dead htmx:afterSwap
handler reading an always-empty query string.
2026-08-15 23:53:54 -07:00
Adam Brown
d0d4d667ba
New Crowdin updates (#902)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]
2026-08-15 23:09:51 -07:00
Adam Brown
7c51f8a83f Bump koin plugin 2026-08-15 22:55:51 -07:00
Adam Brown
2f59a614f5 Bump Nucleus, lots of nice improvements 2026-08-15 22:55:51 -07:00
Adam Brown
f0372c885d Update discord links 2026-08-15 22:55:51 -07:00
Adam Brown
2ec30600ac lib updates 2026-08-15 22:55:51 -07:00
Adam Brown
ae3a65f774 Upgrade FileKt
this contains the fix for the bug we reported, so we can remove our hack
2026-08-15 22:55:51 -07:00
Adam Brown
bcda76de3d
Fix ClosedScopeException on scene metadata panel destroy, clarify sync log strings (#901)
On project close the Koin project scope closes before Compose disposal
destroys components, so SceneMetadataPanelComponent.onDestroy crashed on
its first lazy resolve. Resolve onDestroy dependencies eagerly via a new
projectGet() helper so the final metadata flush survives project close.

Also: the account sync log claimed a server create failed when only the
local project id save failed (new dedicated string), and the local
create failure string read backwards and lacked its placeholder.

* Address review findings on the metadata flush and sync log strings

- Skip the onDestroy metadata flush until the initial load has completed, so an early destroy cannot overwrite stored metadata with the empty default
- Run the reference scrub off the lifecycle thread and catch flush errors
- Resolve ProjectRootComponent.syncJournal eagerly (read in onStop)
- Correct the projectGet KDoc: a post-close resolve can also silently hit a fresh uninitialized scope
- Pass serverProject.name (not toString) to the local-create failure log, and rename its key so stale translations get re-queued
- Narrow the id-save try block so an updateSyncData failure no longer logs a wrong failure line after the success line

* Narrow the destroy-flush catch to IOException and cover the failure branches
2026-08-15 22:02:55 -07:00
Adam Brown
e9c21b9fb6 Bump appcompat
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-15 21:26:55 -07:00
renovate[bot]
794b0d9abd
Update dependency node to v24 (#610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-15 20:11:19 -07:00
renovate[bot]
ba520c5a1e
Update actions/setup-node action to v6 (#609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-15 20:10:55 -07:00
renovate[bot]
0ef8eef0a2
Update dependency ruby to v3.4.10 (#705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-15 20:10:42 -07:00
renovate[bot]
b3d9c917e6
Update ruby/setup-ruby action to v1.316.0 (#606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-15 20:09:57 -07:00
Adam Brown
6a01fa451c
New Crowdin updates (#900)
* Update source file strings_scene_list.xml

[ci skip]

* New translations strings_scene_list.xml (French)

[ci skip]

* New translations strings_scene_list.xml (Spanish)

[ci skip]

* New translations strings_scene_list.xml (German)

[ci skip]

* New translations strings_scene_list.xml (Italian)

[ci skip]

* New translations strings_scene_list.xml (Ukrainian)

[ci skip]

* New translations strings_scene_list.xml (Portuguese, Brazilian)

[ci skip]
2026-08-15 20:07:20 -07:00
Adam Brown
633f669f57
Fix scenes becoming unreadable in a directory of exactly ten children (#896)
Order numbers are a contiguous 0-based sequence, so the zero-padded order
field must be as wide as the highest order, not as wide as the child count.
Those two disagree at every power of ten: adding the tenth child to a
directory left single-digit names on disk while every path computation
padded to two, so nothing in that directory could be read back.

The damage was silent. Reads returned "" (loadSceneMarkdownRaw swallows the
IOException), so an affected group exported as blank and opened as a blank
editor. A save then wrote to the padded name, creating a rival file with the
same scene id next to the real one. Nested scenes were hit hardest, since a
mis-padded group takes out its whole subtree.

- countScenes is the last order index; all three padding sites use it, and
  the misnamed SceneDatasource.getLastOrderNumber (raw count) is gone.
  rationalizeChildren follows the same convention.
- getGroupChildPathsById keeps the name-first path instead of associateBy's
  last-wins, so rationalizeTree, validateScenePaths and ScenePathIndex agree
  on which file owns a duplicate id. The sync pass no longer rewrites the
  directory around the stale copy and discards the newer one.
- Content is addressed by resolveSceneContentPath: resolved by id from disk,
  computed only for a scene with no file yet.

Recovery of projects already carrying a stray duplicate is left to a
follow-up migration.
2026-08-15 20:05:28 -07:00
Adam Brown
dd30fb9245
Scene-limited private shares (#897)
* Add per-share scene restriction table and DAO plumbing

* Carry scene restrictions through ProjectAccessRepository

* Filter public story renders to a share's scene set

* Add scene selection to the private share dialog

* Mount share and publish-warning dialogs outside the sticky sidebar

* Keep the share dialog open on a rejected create and let expired shares' passwords be reused

* Drop scene path fixes swept in from another branch

These three files belong to the order-padding fix in #896, not to the
share work. They were picked up by a broad add and are reverted here so
this PR carries only the share changes.

* Harden scene-limited shares per code review

Atomic duplicate-password check under a project-row lock, shared scene-set validation that rejects group ids (review requests included), Set-based fail-closed restriction model, one clock for expiry, live scene counts that surface dead shares, symmetric child-row cleanup, surfaced tree-load failures, api_error_unknown fallback, and dedup of the sheet-dialog CSS.
2026-08-15 19:50:40 -07:00
Adam Brown
74c06a5c92
Keep unsaved edits through a device rotation (#899)
* Keep unsaved edits through a device rotation

Fixes #885. Rotating an Android device rebuilds the composition while the
Decompose component survives (both activities use retainedComponent), so any
draft the UI kept in a plain remember was gone by the time the new composition
ran.

Story Ideas editor: the draft (title, body, tags, uncommitted tag token, edit
mode, and the saved baseline) moves into StoryIdeasComponent and is stashed in
the stateKeeper, so it also survives process death. createIdea/saveIdea give
way to saveDraft(), which reads the draft, folds in a typed-but-uncommitted
tag, and re-baselines off what was actually stored.

Also fixed, same failure mode:
- Encyclopedia entry view re-seeded its rememberSaveable draft from the last
  saved content on every fresh composition, which undid the restore.
- Tag lists on the note, timeline event, and encyclopedia entry create screens.
- The uncommitted token inside HdHairlineTagField.
- The date field on the timeline event create screen.
- The draft name in the scene save-draft dialog.

* Address review findings on the rotation state fix

- Encyclopedia entry: only skip the seed once the fields already hold the
  entry, so starting an edit before the async load lands can't save an
  empty name and body over it.
- Story ideas: a save that finishes after the editor moved on no longer
  stamps its result onto whichever draft is open now.
- Ideas browse: sort mode and tag filter survive a configuration change,
  like the search text already did.
2026-08-15 19:02:42 -07:00
Adam Brown
d054ae3caf
Let the editor take input before the spell check finishes (#898)
The editor was passed enabled = hasReceivedInitialBuffer, and that flag was
only set after runFullSpellCheck() returned. enabled = false makes the editor
read-only and unfocusable, so opening a scene left it rendering and scrolling
but refusing clicks until the whole document had been checked.

Set the flag as soon as the content is loaded. Spell check spans are
decoration and never emit edit operations, so the edit-operation collector
that flag also gates still starts against populated content.

* Bump ComposeTextEditor to 2.4.3

Picks up the spell check scan running off the caller's dispatcher instead of
resuming on it once per word.
2026-08-15 18:03:46 -07:00
Adam Brown
fe8e04a1d5
Make scene tree nesting legible (#895)
Nested groups were indented with an off-by-one that put a depth-2 group at
the same start as its parent, and groups and scenes used different indent
formulas so nothing shared a grid.

- One indent vocabulary in the design system: HdIndentStep, hdIndentFor,
  and Modifier.hdIndentRails for per-ancestor hairline guides.
- Groups get a chevron at every depth, not just the top level, plus a
  scene-count greeble.
- Section rules hang at the group's own indent; top level chapter breaks
  still cut the full panel.
- The drag insert line uses the same indent metric as the rows.
2026-08-15 17:14:08 -07:00
Adam Brown
7424aad1cb
New Crowdin updates (#880)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_sync.xml

[ci skip]
2026-08-15 13:09:31 -07:00
Adam Brown
761bdca19f
Give the review dialog's scene list room to breathe (#894)
The scene picker was a scroller inside another scroller, capped at 260px, so
it stayed cramped no matter how tall the window was. The dialog body is now
the only scroll region and the list renders at full height inside it.

The "All scenes / Select all" row was position:sticky inside that inner
scroller, with side margins and container padding around it, so rows showed
through the gap above it and the gutters beside it. It is now a plain header
bar above the list, outside the scroller.

The dialog also sat inside the story sidebar, which is position:sticky and
therefore a stacking context its z-index could not escape, leaving it painted
under the site header. Moved the container out to a sibling of <main>.

Short viewports get dvh units, trimmed dialog padding, and a full-bleed sheet
under 600px wide.
2026-08-15 13:01:34 -07:00
Adam Brown
976c9b3468
Let users widen the editor on wide screens with a drag handle (#893)
* Fix desktopTest compile: coVerify for suspend setProjectId

* Let users widen the editor on wide screens with a drag handle

The scene editor and focus mode share a persisted editorMaxWidth
(700-1400dp, GlobalSettings) set by dragging a new HdResizeHandle
gutter on the column's start edge. Rendered width stays
min(window, userMax); the handle only appears when the window is
wide enough, and double-click resets. Live drag width is UI-local
via rememberHdResizeHandleState and persists once on drag end.
2026-08-15 12:32:12 -07:00
Adam Brown
19cc9c0b32
Clear a project's sync baseline when it moves to a new server project (#890)
The sync journal (sync.json) and StoredProjectData.lastSyncedHash record what one
specific server confirmed, but nothing scoped them to that server. Once a project
pointed at a different server project, an untouched project still read as "already
in sync", so ProjectDataSyncOperation took its fast-forward branch and overwrote
local project data with the new server's empty copy: author name and project
settings gone, and nothing uploaded.

ProjectsRepository now clears the baseline whenever the server project id actually
changes, covering cross-server merge, create-account, removeServer, and the 410
stale-id path in FetchServerDataOperation.

The clear is deliberately narrow. The journal is rewritten rather than deleted:
syncedHashes, cachedProjectHash, hashAlgoVersion, currentSyncId and lastSync are
reset, while dirty, newIds, deletedIds and lastId stay, since they are local work
the new server has never seen; dirty entries drop originalHash so an upload cannot
forge a conflict against a server that never agreed to it. Project data keeps its
data and loses only lastSyncedHash, which is what makes the next sync upload
instead of fast-forward. For an open project the clear goes through
ProjectDataRepository under its lock, because clearing the file alone would leave
the stale hash live in memory and write it straight back out. The whole clear runs
before the new id is written, so a failure between the two leaves the project
pointing at the old server rather than at the new one with a stale baseline.

removeProjectId and setProjectId are suspend now, which makes
AccountSettings.removeServer() suspend too. User content is never touched.

Writing-activity logs are also server-scoped and also survive a move; tracked
separately in #892.
2026-08-15 10:06:14 -07:00
Adam Brown
4617b543e0
Adopt server projects by name before uploading local ones (#889)
syncCreatedProjects ran createProjectsOnServer before
createLocalProjectsFromServer, so a local project sharing a name with a server
project was uploaded first and only then had its id overwritten by the name
match. The server dedupes createProject by name, so this usually handed back the
existing uuid, but where the local name was a sanitized form of the server name
it left an orphan duplicate for every other device to download.

Adoption now runs first and reports the names it claimed, so the upload pass
skips them and a queued creation the server already holds under that name is
withdrawn instead of retrying the duplicate on every future sync.

Adoption itself is no longer name-only. A local project is claimed only when its
id is absent from the live server set, so one already bound to a live server
project is never re-pointed at a same-named one, and two server names that
sanitize to a single local name are first-wins rather than last-wins. Tombstoned
ids stay out of that set, so a project whose server copy was deleted elsewhere
can still be claimed.
2026-08-15 09:59:48 -07:00
albanobattistella
62da7c22e9
Add Italian translation (#891)
* Translate full description to Italian

* Translate strings to Italian
2026-08-15 09:56:03 -07:00
Adam Brown
03863ea420
Ask Merge vs Replace on login instead of "Remove Local Content?" (#888)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Server setup popped an unskippable "Remove Local Content?" confirm on both the
Create-account and first-time Log In paths, and ran the delete before the network
call, so a typo'd password or a declined ToS still wiped every local project.

Only prompt when the answer matters: logging in to a different server while
holding real local work. Create-account never deletes, re-auth against the
configured server never prompts, and a login holding only the bundled example
drops it silently. Replace requires a second destructive confirmation.

The wipe now runs in the Success branch, so a failed setup costs nothing. The
component owns the decision and re-reads the projects directory when it acts;
the UI only ever asks for the non-destructive setup.

Also releases the pending setup before the wipe suspends, so a close landing in
that window cannot delete the freshly obtained tokens, and guards the
example-project drop with a same-server check so a token-expiry re-auth keeps it.
2026-08-14 23:45:54 -07:00
Adam Brown
df35452429
Render story prose the way the editor shows it, on the web and in every export (#887)
A page of dialogue reached readers as one packed block. CommonMark reflows
prose: single newlines become spaces and any run of blank lines collapses to
one paragraph break, so what an author sees while writing was not what anyone
else saw. Prose is now laid out as it was typed, on every surface that renders
it: every newline starts a new line, every blank line is a blank line.

- base/markdown/ProseHtml.kt holds that layout as AST-level generating
  providers, shared by the server's pages and the client's EPUB export. Lists,
  tables, code and headings keep markdown's own layout by construction, so the
  old fence-tracking preprocessor is gone.
- parseProseMarkdown does the same for the DOCX, RTF and PDF exports, with a
  ProseBlock.Blank for an authored blank line. A body paragraph carries space
  after it only where prose ends, so lines that run on sit tight and the
  indent parts them.
- Quoted passages keep their lines, and a quote's '>' markers no longer leak
  into the text of a continuation line in the document exports.

Walking every construct the flavour can emit turned up three more faults. A
stripped element keeps its text, so tables (which the sanitizer allowed none
of) arrived as their cells run together; they are allowed and styled now,
column alignment included. An ordered list starting at 5 rendered as 1. Line
endings went unnormalized once the preprocessor that had done it incidentally
was removed.

The story's declared language now reaches the prose on the author's own page
as well as the public one, so a French story is not hyphenated by the rules of
whatever locale the author reads Hammer in.

RENDER_VERSION goes to v4 and moves into the fingerprint: it keyed the disk
cache but not the ETag, so a bump alone would have served stale prose to
anyone holding the old validator.
2026-08-14 23:41:47 -07:00
Adam Brown
84f7223a77
Fix sync failure when order padding width changes during rationalizeTree (#886)
* Fix sync failure when order padding width changes during rationalizeTree

rationalizeTree computed each destination path with per-segment order
padding read from live disk counts, which its own moves mutate mid-pass.
When a sync dropped a directory across a digit boundary (10 -> 9 items),
later items computed ancestor names that did not exist on disk yet and
atomicMove failed with FileNotFoundException.

The pass now derives padding from the tree's child counts and moves
parents before children, building each destination from the parent's
already-finalized path, so the layout is computed once and every
destination directory exists.

* Pad rationalizeTree from disk-present children and reuse path snapshot

Padding now counts only children that resolve on disk, keeping written
names recomputable by getSceneFilePath when a tree child is missing.
Snapshot each directory's child paths once instead of rescanning after
every move, and extract the shared childScenePath helper.

* Tighten rationalizeChildren doc comment
2026-08-14 23:04:30 -07:00
Adam Brown
e25d80c051 Prepared for release: v3.8.2
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
2026-08-11 01:05:31 -07:00
Adam Brown
da41f56998 Revert "New Crowdin updates (#879)"
This reverts commit c529bd063b.
2026-08-11 00:51:47 -07:00
Adam Brown
c529bd063b
New Crowdin updates (#879)
* Update source file strings_sync.xml

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-11 00:05:45 -07:00
Adam Brown
de9d0defec
New Crowdin updates (#878)
* Update source file strings_about_app.xml

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]
2026-08-10 23:31:01 -07:00
Adam Brown
8c83c964ea
Always-on Allowed Users list + public signup page (#875)
Always-on Allowed Users list and public signup page

The whitelist toggle is gone: every server now enforces the allowed users
list, and the web UI, routes, and message keys are renamed to "Allowed
Users". The Kotlin, database, and REST names are unchanged.

Adds a public /signup page so an allowed user can create their own account
from the web instead of only through the app. It goes through
AccountsComponent.createAccount, so the allowed users list and the Terms of
Service challenge are enforced, and the POST shares the login rate limit.
A not-allowed email is audited like a failed login.

Wires the dormant DataMigrator into startup with one-time completion markers
in server_config, and adds a migration that seeds the list with every
non-deleted account.
2026-08-10 23:30:46 -07:00
Wavesonics
d9598e225f Fix MacOS crash 2026-08-10 21:00:05 -07:00
Adam Brown
a33f42d9a2
New Crowdin updates (#871)
* New translations messages_en.properties (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* Update source file Messages_en.properties

[ci skip]
2026-08-10 20:48:49 -07:00
Adam Brown
e33e4bf24e
About: promote Dark Rock Studios attribution, hoist Cairn overlay above nav (#877)
* Move Dark Rock Studios attribution above the community section on About

Replaces the "Manuscript" section with a "Studio" section carrying the
made-by attribution and the button that opens the Cairn overlay, giving
it top billing above Community.

Hoists the Cairn overlay from AboutAppUi up to the nav shell
(ProjectSelectScaffold on Android/iOS, ProjectSelectionWindow's Content
on desktop) so its full-screen ceremony covers the nav rail/bottom bar
too, not just the About content pane.

* Force x11 until Nucleus fixes it's bug
2026-08-10 20:48:31 -07:00
Adam Brown
4099195eda
Render the admin server message as markdown (#876)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The instance band now runs the message through MarkdownService, so admins can
use links, emphasis, and lists in it. Output is sanitized by the same OWASP
policy the About page uses, and a message that sanitizes down to nothing no
longer paints an empty band.
2026-08-10 01:15:31 -07:00
Wavesonics
6a3e2e22b5
Remove the JBR from snap build 2026-08-10 00:10:03 -07:00
Wavesonics
54d5b8ba49
Add Cairn about screen 2026-08-10 00:07:29 -07:00
Adam Brown
5eebd1b4e6
Drop the JetBrains Runtime requirement (#873)
Nothing in the desktop app needs the JBR anymore, so go back to OpenJDK
(Temurin 21) everywhere:

- Remove the JETBRAINS vendor pin from the desktop toolchain and from the
  launcher that jpackage bundles as the app runtime
- Replace the setup-jbr composite action with actions/setup-java; the
  retry logic only existed to survive rate limits on the jetbrains
  distribution's release listing
- Swap the JBR SDK tarball for Temurin 21 in the snap and flatpak builds,
  and verify the snap download's checksum
- Standardize the remaining 'adopt' setup-java steps on 'temurin'
2026-08-09 23:18:56 -07:00
Adam Brown
146919556d
Record web sign-ins in the security audit trail (#874)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Record web sign-ins in the security audit trail

The web login form never called recordLoginAttempt, so only app clients
hitting /account/login showed up on the Security monitoring page.

Wire /login in, auditing what the server actually allowed: a whitelist
rejection is recorded as a failure, matching the API path. Move the
monitoring gate into SecurityRepository so both routes honor the
loginTrackingEnabled and storeLoginIp settings by construction, store
blank emails as null to keep them out of the per-account brute-force
queries, and log rather than propagate a failed audit write so it can't
break the sign-in. Rate limit the web login POST like the other two
login entry points.

* Suppress TooGenericExceptionCaught on the audit write

The catch is deliberately broad: no failure mode of recording an attempt
may break the sign-in that triggered it.
2026-08-09 18:23:17 -07:00
Adam Brown
51f79a8631 Prepared for release: v3.8.1
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-08 14:34:38 -07:00
Adam Brown
2f45ed108d lib bumps 2026-08-08 11:43:55 -07:00
Adam Brown
725d75250d
New Crowdin updates (#870)
* New translations messages_en.properties (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]
2026-08-08 00:18:37 -07:00
Adam Brown
03b6f38f6f
Keep block indents through a font size change (#863)
Some checks failed
Build CI / build (push) Has been cancelled
Build CI / static-analysis (push) Has been cancelled
Build CI / android-instrumented-tests (push) Has been cancelled
Build CI / iOS compile & test (push) Has been cancelled
Build CI / iOS UI tests (push) Has been cancelled
PublishInternal / publish-google-play (push) Has been cancelled
updateMarkdownStyles rebuilds every line from its raw text, which drops
the ParagraphStyle that indents lists and blockquotes. Each font size
step therefore flattened them, and the gutter markers are positioned
against that indent.
2026-08-07 08:19:12 -07:00
Adam Brown
4df8802d43
Render strikethrough on the web (#864)
CommonMark has no strikethrough, so `~~struck~~` reached every server
rendered page as literal tildes while bold and italic worked. The editor
that writes the content parses GFM, so the two disagreed.

MarkdownService now parses GFM, matching the editor. GFM emits
strikethrough as `<span class="user-del">`, which the sanitizer would
unwrap for having no allowed attributes, so it is rewritten to `<del>`
and `del` is allowed through. Bumps RENDER_VERSION so cached story pages
regenerate.

The review page had the same gap for its own reason: parseInlineMarkdown
only branched on `*` and `_`. It now handles `~~` and threads the flag
through to both render sites.
2026-08-07 08:18:23 -07:00
Adam Brown
7a6c5191fc
Bump ComposeTextEditor to 2.4.2 (#866)
Carries the fix for typed text losing its body style when nothing
neighbours the caret, which left every newly typed paragraph and list
rendering at the default size instead of the reader's chosen one.
2026-08-07 08:18:06 -07:00
Adam Brown
1c856dc32b
Fix the F3 sync and Ctrl+Alt+S shortcuts, and give them to Android and iOS (#865)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Handle F3 and Ctrl+Alt+S on the project window

Both shortcuts hung off a Modifier.onPreviewKeyEvent in ProjectRootUi, so
they only fired when focus sat inside the project subtree and did nothing
from the home screen. Move them to the window onKeyEvent that already owns
Esc, Ctrl+W, Ctrl+Q and Ctrl+Shift+F.

F3 now goes through ProjectRoot.startProjectSync(), which opens the sync
modal only for server-linked projects, restoring the gate the old sync menu
item had.

* Match shortcut modifiers exactly and run them pre-focus

F3 and Ctrl+Alt+S were hand-rolled in the window's when-chain, which
dropped the exact-modifier matching onKeyShortcut enforced: Ctrl+F3 and
Ctrl+Alt+Shift+S both fired. Extract that predicate as
KeyEvent.matchesShortcut and use it for both.

Move the two to onPreviewKeyEvent as well. onKeyEvent only runs when
nothing on the focus path consumed the key, so a focused editor could
swallow them; the docs claimed otherwise. The other window shortcuts stay
on onKeyEvent so a focused component can still handle Esc first.

* Give Android and iOS the project shortcuts back

Moving F3 and Ctrl+Alt+S to the desktop window left the other platforms
with nothing. Add ProjectShortcutHost, which ProjectRootScaffold binds
while the project UI is composed, and drive it from each platform's own
key hook: Activity.dispatchKeyEvent on Android and UIKit key commands on
iOS. Both are focus independent, which the Compose modifiers were not.

The iOS container lives in Swift because keyCommands is an Objective-C
category member and Kotlin cannot override those.

* Bind the project shortcuts in one place

Desktop kept its own copy of the save-all action while Android and iOS went
through ProjectShortcutHost. Move the binding down to ProjectRootUi, the one
composable all three platforms render, so each host only detects keys and
calls the host object.

That also lets the window drop the snackbar state and coroutine scope it had
hoisted purely to run the action.
2026-08-07 00:45:59 -07:00
Adam Brown
bd2e3a9666
Give rendered stories real book typography (#858)
* Give rendered stories real book typography

Lists sat at the prose margin with their items packed together, thematic
breaks ran the full column width, and any run of blank lines an author
typed collapsed to a single paragraph gap.

- MarkdownService emits a break for each blank line past the first, so
  deliberate white space survives CommonMark's collapsing. Runs are
  capped and fenced code is left alone.
- StoryRendererService separates sibling scenes with a blank line;
  without it the last paragraph of one scene and the first of the next
  parsed as a single paragraph.
- story.css styles every element markdown can emit: indented lists with
  accent markers and breathing room, a centered scene-break rule with an
  ornament, a tinted blockquote panel, code and links, h3-h6, and ragged
  right prose below 600px where justification opens rivers.

* Indent every prose paragraph, including a page's first

Each page opens with the scene's heading, so the flush-left rule for a
paragraph following a heading swallowed the indent on the first line of
every page. Drop the rule entirely: paragraphs after a scene break, list,
or quote now indent too, so the prose is uniform.

* Keep blank-line spacing to the prose that asked for it

Review of the branch turned up four ways the blank-line work reached
further than intended.

- markdownToSafeHtml takes preserveBlankLines, defaulting off. Bios, the
  About page, the privacy policy and the review frontend render with
  CommonMark's collapsing again; only story rendering opts in.
- A code fence is now tracked by its delimiter and length, so a ~~~ line
  inside a ``` block no longer ends it and leaks a literal <br /> into the
  code. A blank run between two indented lines is left alone, which covers
  indented code blocks and fences nested in list items.
- The chapter heading no longer carries a leading newline; with
  appendScene's trailing blank line it made a run long enough to render a
  break above every heading.
- RENDER_VERSION goes to v2 so cached story pages re-render.

* Fold the group word count into the scene walker

buildGroupMarkdown had its own copy of writeGroupChildren, differing only
by a word-count accumulator, so every change to how scenes are separated
had to be made twice. writeGroupChildren now returns the count and the
single-group export uses it.

Also brings the Story Prose table in the design system doc back in line
with the rules story.css actually carries.
2026-08-06 23:43:34 -07:00
Adam Brown
79bd7ca2ef
New Crowdin updates (#860)
* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_sync.xml

[ci skip]
2026-08-06 23:38:14 -07:00
Adam Brown
46c22fd1b8
Recreate projects whose cached server id is dead (#857)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Recreate projects whose cached server id is dead

A local project keeps the server project id it was assigned on a previous
sync. Account sync only created a project on the server when that id was
missing, so a project holding an id the server has no record of was skipped
and per-project sync went straight to begin_sync with it, failing 410 Gone
with no path back.

Treat a cached id the server neither lists nor has tombstoned as dead and
recreate the project. A tombstoned id still means a real server-side delete
and keeps its existing handling.

* Judge project id liveness on raw sync data, and heal on 410

Two gaps in the stale-id recovery:

Liveness was judged against the server list processProjectSyncData had
already filtered, which drops projects queued for deletion. A project
whose folder came back after a local delete looked unknown to the server
and was recreated, undoing the deletion. Judge against the raw begin_sync
response instead, where a queued-for-deletion project is still listed.

The per-project pipeline only created a project when its id was missing,
so syncing from inside a project still failed 410 forever. Discard the id
when begin_sync answers 410, which returns the project to the never-synced
state so the next sync recreates it.

Document both in the syncing protocol.

* Withdraw sync queue entries that can never succeed

A queued rename keyed on an id the server never issued failed every
session forever, writing a red line into the sync log each time with no
way to clear it. Drop it without sending: recreation already covers the
rename, since it creates the project under its current local name.

A project deleted before its first sync left its name queued for
creation, so the next sync created an empty project on the server and
pushed it to every device, then failed resolving a definition for the
deleted directory and repeated forever. Withdraw the queued creation on
delete, and drop queued names with no local project so existing bad
state heals.
2026-08-06 01:26:34 -07:00
Adam Brown
b92b0c3eb5
Migrate desktop window decoration to Nucleus 2.1.9 TAO backend (#854)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Holy crap this is so much better!
2026-08-05 23:22:38 -07:00
Adam Brown
39e7e913bb
Load scene content as markdown so block structure survives (#856)
Horizontal rules, images and code fences live in the editor's rich span
manager rather than in the AnnotatedString, so seeding an editor from
getInitialEditorContent dropped every block. Import the markdown instead.

Loading this way exposed several things that depended on the old seed:

- The spell checker's one-shot scan is keyed on the checker, not the
  content, and importMarkdown emits no edit operations, so a scene opened
  once the dictionary was cached was never checked. Run a full check after
  loading.
- updateMarkdownStyles remapped heading styles before the extension's
  config setter rebaked them, leaving the rebake unable to strip the old
  style and appending a duplicate span per heading per config change.
- Block lines render as a lone space, which the prose diff could not tell
  from a blank line, so a scene break added or removed between draft and
  current went unmarked. sceneDiffText swaps in length-preserving
  sentinels to keep the diff in the editor's coordinate space.

Also hoist the Draft Compare editors above the width breakpoint so a
resize no longer resets the merge pane while mergedContent points at the
torn-down editor, give both panes the markdown config effect so they
restyle together, and move the markdown export off the main thread.
2026-08-05 21:54:54 -07:00
Adam Brown
fcd94201ef
Trust proxy forwarding so a proxied server sees real client addresses (#855)
Adds trustProxyForwarding, which reads each request's client address and scheme from the X-Forwarded-* headers so the login rate limiter, the login audit trail and story reader counts see real clients rather than the proxy.

Reads the last X-Forwarded-For entry rather than Ktor's default first entry, which arrives with the request and can be forged to claim a fresh rate limit bucket per request.
2026-08-05 21:20:40 -07:00
Adam Brown
43c25d9c1d
New Crowdin updates (#853)
* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]
2026-08-05 18:36:40 -07:00
Adam Brown
eda6a40a1a Fix tag suggestion click adding the typed prefix
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Suggestion chips were focusable, so pressing one pulled focus off the
tag input and fired commit-on-focus-loss, which added the raw draft and
cleared it. That emptied the strip mid-gesture, so the release never
reached onSelect.
2026-08-05 00:56:49 -07:00
Adam Brown
8897620f1f Add keyboard shortcuts doc
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-05 00:06:33 -07:00
Adam Brown
c1fac8ff1e Prepared for release: v3.8.0 2026-08-04 21:47:41 -07:00
Adam Brown
7620fb81bc
New Crowdin updates (#848)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_about_app.xml (Spanish)

[ci skip]

* New translations strings_about_app.xml (German)

[ci skip]

* New translations strings_about_app.xml (Italian)

[ci skip]

* New translations strings_about_app.xml (Ukrainian)

[ci skip]

* New translations strings_about_app.xml (Portuguese, Brazilian)

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Restore project-language string keys dropped by Crowdin sync

The prior Crowdin sync (#847) overwrote strings_sync.xml from a stale
copy that predated the project-language feature (#838), silently
deleting 7 keys still referenced by composeUi. Restoring them here so
this sync's build compiles; see #851 for the same fix on develop.

* Update source file strings_sync.xml

[ci skip]
2026-08-04 21:25:39 -07:00
Adam Brown
0753c5f122
Restore project-language string keys dropped by Crowdin sync (#851)
The previous Crowdin sync PR (#847) overwrote strings_sync.xml from a
stale copy that predated the project-language feature (#838), silently
deleting 7 keys still referenced by ProjectInfoSettingsUi.kt,
ProjectSettingsUi.kt, and ProjectDataConflict.kt. That broke
compileAndroidMain and every other target across all client builds.
2026-08-04 21:25:19 -07:00
Adam Brown
32d8b6be4a Bump gradle 2026-08-04 20:53:39 -07:00
Adam Brown
e836ee7182 Switching way from kotlinx datetime where possible 2026-08-04 20:51:46 -07:00
Adam Brown
d1ea053c28
Bake the changelog into the app instead of checking GitHub on load (#850)
The app called the GitHub releases API from ProjectSelectionComponent.init
to decide whether to show an "update available" dialog. That is an
unprompted network request before the user has done anything, which has
been raised as a privacy concern.

The release notes are already in the repo at release time, so bake them
in instead. prepareForRelease writes the new CHANGELOG.md entry to
common/src/commonMain/composeResources/files/changelog.md and commits it
alongside the version bump, so the resource cannot drift from the version
in libs.versions.toml.

On load, ChangelogRepository compares the baked entry's version against
lastSeenChangelogVersion in global settings and shows a "What's New"
dialog when they differ. Dismissing records the version. Fresh installs
are seeded as already-seen, so only upgrading users get the popup;
the old lastDismissedUpdateVersion key is dropped, which is safe because
the TOML serializer ignores unknown keys.

About drops its update check entirely and gains two buttons: Changes,
which reopens the dialog with no network, and GitHub Release, which opens
the browser.

VersionCheckRepository stays for the protocol mismatch dialog, which only
appears after the user has connected to a sync server and exists to tell
them which version to get. It keeps its automatic check and is now that
repository's only consumer.

Links in the notes are clickable via linkifyChangelog, which handles
[label](url) and bare urls, including urls containing parentheses. It is
deliberately not a markdown renderer: entries lead with [New] / [Fix]
tags that a real parser mangles.
2026-08-04 19:04:59 -07:00
Adam Brown
76fc56f869
Add create-admin-account form to the server setup page (#849)
The first-run /setup page now hosts an email/password form that creates
the initial admin account, signs the operator into a web session, and
redirects to /admin. The app-based setup steps remain as a secondary
section. POST is rate limited alongside login and re-checks hasUsers()
to guard races and double submits.
2026-08-04 19:04:41 -07:00
Adam Brown
7f998f2618 ktor bump
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-04 08:40:48 -07:00
Adam Brown
ce54ad7054 Update AGP
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
2026-08-04 00:32:23 -07:00
Adam Brown
e2906293bc Upgrade Rich Text Editor 2026-08-04 00:30:42 -07:00
Adam Brown
cfac12ed5f Lib bumps
Remove kotlinx.datetime
2026-08-04 00:27:59 -07:00
Adam Brown
71a13d9e7b
Normalize tag needles for search and suggestions (#844)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Normalize tag needles for search and suggestions

Tags are stored NFC-composed, but the read paths compared raw input against
them, so a needle typed decomposed found nothing. Search queries and both
suggestion services now normalize the needle first.

Suggestion prefixes were also split on the ASCII space alone, so a
part-typed tag after a comma or an ideographic separator offered nothing
even though those separators split tags on save.

Adds normalizeTagNeedle and tagPrefixOf, and folds the two ad-hoc
hash-prefix checks into Char.isTagPrefix so the fullwidth form is
recognized in queries as well.

* Agree tag search with tag storage on where a tag starts and ends

Review of the previous commit found that widening tag recognition on the
needle side alone left three regressions.

parseQuery normalized needles, which let two typed spellings collapse onto
one string; the tag list was still an undeduped List feeding a keyed LazyRow
in Global Search, so `##epic #epic` crashed composition. It also treated a
hash anywhere as a tag opener, swallowing the fullwidth one out of ordinary
free text, and still ended a needle at whitespace while storage ends tags at
commas too. A hash now opens a tag only at a word boundary, needles split on
the storage separators, and the tags come back deduped.

The suggestion strip fired on the last run of the draft but its callers threw
the whole draft away on select, losing tags already typed. replaceTagPrefix
swaps just the run being completed.

Also normalizes tag keys as the index is built, so a tag that reached disk
unnormalized is still reachable, and folds the triplicated suggestion
derivation into rememberTagSuggestions.
2026-08-03 23:33:26 -07:00
Adam Brown
5ab2afabee Guard the English message bundle against dropped keys
English is the fallback bundle every locale resolves through, so a key the
server looks up but Messages_en.properties does not carry throws
MissingResourceException at request time. A Crowdin sync rewrote the English
source file and dropped 129 live keys, which 500ed the home page, account
refresh, and account deletion.

The test scans msg/Msg.r/localizedMsg call sites and mustache msg.* references
and asserts each resolves in English. Parity is checked against referenced keys
rather than the translation files, which Crowdin owns and which carry strings
that have outlived their use in the templates.
2026-08-03 23:24:26 -07:00
Adam Brown
ba727a1a2d
Keep the drop target live while the list autoscrolls (#845) 2026-08-03 23:18:16 -07:00
Adam Brown
c8eb4b874d
New Crowdin updates (#847)
* Update source file strings.xml

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_desktop.xml

[ci skip]

* Update source file strings_drafts.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_globalsearch.xml

[ci skip]

* Update source file strings_ideas.xml

[ci skip]

* Update source file strings_notes.xml

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file strings_project_navigation.xml

[ci skip]

* Update source file strings_project_select_navigation.xml

[ci skip]

* Update source file strings_projects_list.xml

[ci skip]

* Update source file strings_scene_editor.xml

[ci skip]

* Update source file strings_scene_list.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file strings_timeline.xml

[ci skip]

* Update source file strings_android.xml

[ci skip]

* Update source file full_description.txt

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_sync.xml (Spanish)

[ci skip]

* New translations strings_sync.xml (German)

[ci skip]

* New translations strings_sync.xml (Italian)

[ci skip]

* New translations strings_sync.xml (Ukrainian)

[ci skip]

* New translations strings_sync.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (German)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations full_description.txt (Portuguese, Brazilian)

[ci skip]
2026-08-03 23:17:09 -07:00
Adam Brown
eadb412805
Update org.jetbrains:markdown to 0.7.8 (#846)
Patch release: nested code fences parse correctly, reference link
labels must immediately follow the link text, and username@host is
recognized as part of a URL.
2026-08-03 22:58:46 -07:00
Adam Brown
3509321b24
Resolve drop targets against drawn row geometry (#842)
* Resolve drop targets against drawn row geometry

* Report row geometry in display order and keep the insert line live
2026-08-03 22:34:19 -07:00
Adam Brown
6d129c1768
Match search on rendered prose, with a scan that does not allocate (#841)
* Match global search against projected Markdown prose

Search compared queries against raw Markdown, so backslash escapes and
emphasis markers sitting between words caused misses, and snippets
rendered storage syntax.

Flatten stored Markdown to the prose a reader sees before matching:
escapes resolve to their literal character, and paired emphasis or code
delimiters are dropped. Pairing follows CommonMark flanking rules, so
literal markers in imported or hand-edited content survive; user_name
and a bare *** divider are left alone.

Timeline dates are a plain-text field and are not projected. Derived
titles go through the same projection as the note list so the two views
agree, and blank projections fall back to the raw source so marker-only
content still gets a title and still appears in tag searches.

Fixes #811

* Scope Markdown pairing to paragraphs and narrow the title change

Delimiter pairing used a single document-wide stack, so an unpaired
asterisk or a backtick used as an apostrophe paired with an unrelated
one paragraphs away. Both characters were deleted, and runs between a
bogus code span were marked inert, which left real emphasis in place and
defeated the cross-markup matching this is for.

Pair within a paragraph only. Emphasis still spans a soft line break.

Strip leading blockquote, heading and bullet markers from every line
rather than only from derived titles, so a title and its snippet agree.
Ordered-list markers are left alone: one line cannot tell "1. Draft" from
"1984. The year everything changed".

Titles now take the first non-blank line of the whole projection instead
of projecting a line in isolation, so emphasis closing on the next line
still pairs.

Revert firstNonBlankLine to its verbatim behavior. Routing it through the
projection reached into Browse Notes, Story Ideas and sync conflict
labels and unescaped backslashes there, corrupting stored paths.

Skip the raw fallback scan when the query holds no character the
projection can remove, which makes it provably redundant.

* Project Markdown in a reusable scan workspace

Global search re-projected every document on every keystroke, allocating
about 36x the source in garbage each pass: one object per delimiter run,
two lists, a StringBuilder and an output String, per scene, per search.

MarkdownProjector holds that work in buffers it keeps. Delimiter runs
move into parallel primitive arrays, the projected prose lands in a char
buffer that is matched in place rather than turned into a String, and
every buffer grows to the widest document seen and is then reused. A full
scan of a 1.25M word project drops from 31.7MB of garbage to none, at the
same wall time.

Two smaller wins came with it: the source is copied into a flat array so
the scan and render index an array instead of paying a CharSequence call
per character on both passes, and the ASCII punctuation test became four
range checks instead of a scan over a 32 character string.

The projector cannot be a field on the use case. Cancelling a search is
cooperative, so the outgoing scan can still be running when the next one
starts, and the two would share a buffer. MarkdownProjectorPool lends one
per scan and keeps it afterwards, so the buffers survive to the next
keystroke without forcing the four scans to run one at a time.

projectMarkdownToPlainText stays as the one-shot convenience over the
same code, so the behaviour is defined in one place. MarkdownProjectionTest
passes unchanged, which is the point: the rewrite is internal.

* Read scenes straight into the scan buffer

Search re-read every scene from disk on each keystroke and took a String
back for each one, about 11.5MB of garbage per pass over a 300k word
project before any matching happened.

SceneDatasource can now decode a scene into buffers the caller owns and
return the char count, so a scan reuses one pair of buffers instead of
taking a string per file. ScanBuffers is the contract; MarkdownProjector
implements it, so the bytes land in the same workspace that projects
them. The same scan now costs 0.42MB at the same wall time.

The bytes are pulled in bulk and decoded from an array. Reading a byte at
a time off a BufferedSource was measurably slower than Okio's own
readUtf8, and the decode was never the expensive part.

Matching follows the text into the buffer: findProjectedMatch works over
whatever the projector holds, and the raw-markup fallback searches the
source buffer, so neither path needs the document as a string. Only the
snippet window is copied out, and only on a hit.

SearchProjectUseCaseTest stubbed loadSceneMarkdownRaw, which the scene
path no longer calls. The stub now fills the buffer it is handed, which
is what the collaborator actually does.

* Carry the projection across every search surface

Rebasing onto develop put #831's unification and #821's projection in the
same tree, and they disagreed.

#831 made four surfaces share one rule, markdownContains, which resolves
escapes only. Global search now projects, so leaving markdownContains
alone would have re-split the surfaces it had just joined: Notes, Timeline
and Story Ideas would still miss a phrase spanning "**emphasis**". It
projects now too, and mirrors the same fallback.

The two PRs also pulled opposite ways on the raw-source fallback. #831
pinned that searching the storage form of prose must not work, and in the
same breath that literal "**Chapter**" must still be found. Under the
projection those need different answers, and #821's gate, "the query holds
any character the projection could remove", cannot give them: a backslash
and an emphasis marker are both removable, so honouring one broke the
other.

containsInlineMarkup replaces it. Only emphasis and code markers open the
fallback, because spelling those out is someone hunting for markup.
Escapes and block markers do not, because "well\-known" is the storage
form of prose and nobody types it. Both of #831's assertions hold.

matchOrPreview keeps the fallback argument and the empty-snippet chain
from #833, which the projection commits had dropped, so a tag-only search
still returns bodiless items.

* Match the query as literal text and nothing else

Global search resolved the query against the prose on screen, then, for a
query containing an emphasis or code marker, searched the raw storage form
as well. That second pass was the query being read as markup: typing
"**Chapter**" found a document whose prose reads "Chapter", because the
asterisks were matched against the source rather than against what the
document displays.

The rule is now one line. A query is literal text, matched against the
prose the document renders as, and there is no second interpretation. The
asterisks are not on screen, so typing them finds nothing.

Nothing about escapes changes, and this is easiest to see in the case that
motivated it. Text stored as "well\-known" renders as "well-known" and is
found by typing that; the storage form is not. Text stored as "well\-known"
renders as "well\-known", backslash and all, and typing that backslash
finds it. Markers the projection leaves alone, "5*4" and "user_name", are
matched where they sit, because there they are prose.

Three assertions pinned the behaviour that has gone, one per surface, and
each now states the opposite. containsInlineMarkup and the projector's
source-side accessors existed only to serve the fallback and go with it.

* Drop a comma from a test name so iOS can compile it

Kotlin/Native rejects a comma in a backticked identifier, so the whole
iosTest compilation failed on one test name. Desktop accepts it, and
compileIosMainKotlinMetadata only covers main, so nothing local caught it.

:common:compileTestKotlinIosSimulatorArm64 does, and runs on a non-Mac
host.
2026-08-03 22:26:31 -07:00
Adam Brown
1b312bc3b2
Allow a project to set the language it's written in (#838)
* 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
2026-08-03 22:22:00 -07:00
Adam Brown
ad2553908b
New Crowdin updates (#843)
* Update source file strings.xml

[ci skip]

* Update source file strings_about_app.xml

[ci skip]

* Update source file strings_account_settings.xml

[ci skip]

* Update source file strings_desktop.xml

[ci skip]

* Update source file strings_drafts.xml

[ci skip]

* Update source file strings_encyclopedia.xml

[ci skip]

* Update source file strings_globalsearch.xml

[ci skip]

* Update source file strings_ideas.xml

[ci skip]

* Update source file strings_notes.xml

[ci skip]

* Update source file strings_project_home.xml

[ci skip]

* Update source file strings_project_navigation.xml

[ci skip]

* Update source file strings_project_select_navigation.xml

[ci skip]

* Update source file strings_projects_list.xml

[ci skip]

* Update source file strings_scene_editor.xml

[ci skip]

* Update source file strings_scene_list.xml

[ci skip]

* Update source file strings_sync.xml

[ci skip]

* Update source file strings_timeline.xml

[ci skip]

* Update source file strings_android.xml

[ci skip]

* Update source file full_description.txt

[ci skip]

* Update source file Messages_en.properties

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations strings.xml (French)

[ci skip]

* New translations strings_about_app.xml (French)

[ci skip]

* New translations strings_account_settings.xml (French)

[ci skip]

* New translations strings_account_settings.xml (Spanish)

[ci skip]

* New translations strings_account_settings.xml (German)

[ci skip]

* New translations strings_account_settings.xml (Italian)

[ci skip]

* New translations strings_account_settings.xml (Ukrainian)

[ci skip]

* New translations strings_account_settings.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_desktop.xml (French)

[ci skip]

* New translations strings_drafts.xml (French)

[ci skip]

* New translations strings_encyclopedia.xml (French)

[ci skip]

* New translations strings_notes.xml (French)

[ci skip]

* New translations strings_project_home.xml (French)

[ci skip]

* New translations strings_project_home.xml (Spanish)

[ci skip]

* New translations strings_project_home.xml (German)

[ci skip]

* New translations strings_project_home.xml (Italian)

[ci skip]

* New translations strings_project_home.xml (Ukrainian)

[ci skip]

* New translations strings_project_home.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_project_navigation.xml (French)

[ci skip]

* New translations strings_project_navigation.xml (Spanish)

[ci skip]

* New translations strings_project_navigation.xml (German)

[ci skip]

* New translations strings_project_navigation.xml (Italian)

[ci skip]

* New translations strings_project_navigation.xml (Ukrainian)

[ci skip]

* New translations strings_project_navigation.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_projects_list.xml (French)

[ci skip]

* New translations strings_scene_editor.xml (French)

[ci skip]

* New translations strings_scene_list.xml (French)

[ci skip]

* New translations strings_sync.xml (French)

[ci skip]

* New translations strings_timeline.xml (French)

[ci skip]

* New translations strings_timeline.xml (Spanish)

[ci skip]

* New translations strings_timeline.xml (German)

[ci skip]

* New translations strings_timeline.xml (Italian)

[ci skip]

* New translations strings_timeline.xml (Ukrainian)

[ci skip]

* New translations strings_timeline.xml (Portuguese, Brazilian)

[ci skip]

* New translations strings_android.xml (French)

[ci skip]

* New translations strings_android.xml (Spanish)

[ci skip]

* New translations strings_android.xml (German)

[ci skip]

* New translations strings_android.xml (Italian)

[ci skip]

* New translations strings_android.xml (Ukrainian)

[ci skip]

* New translations strings_android.xml (Portuguese, Brazilian)

[ci skip]

* New translations full_description.txt (French)

[ci skip]

* New translations full_description.txt (Spanish)

[ci skip]

* New translations full_description.txt (Italian)

[ci skip]

* New translations full_description.txt (Ukrainian)

[ci skip]

* New translations short_description.txt (French)

[ci skip]

* New translations short_description.txt (Spanish)

[ci skip]

* New translations short_description.txt (Italian)

[ci skip]

* New translations short_description.txt (Ukrainian)

[ci skip]

* New translations strings_globalsearch.xml (French)

[ci skip]

* New translations strings_ideas.xml (French)

[ci skip]
2026-08-03 22:07:22 -07:00
Adam Brown
83c8d4feab
Fix scene drag grabbing the wrong row (#839)
* Fix scene drag grabbing the wrong row

The drag gesture resolves the pressed row against
LazyListLayoutInfo.visibleItemsInfo, which reports each item's target
offset. With animateItem() sliding rows into place, a row is drawn
somewhere other than where layoutInfo says it is, so a press right after
a move grabs whichever row now owns that target slot: the one below.

Drop the placement animation so the layout geometry the whole drag stack
reads is the geometry on screen. Fades on add/remove are kept.

* Resolve the grabbed row by hit test so placement can animate

* Scope the drag candidate to the pointer that claimed it
2026-08-03 21:20:12 -07:00
Adam Brown
a523cd521b
Float the scroll-away footers over their lists (#834)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The Outline Overview strip was a sibling in the scene list's Column, so
showing and hiding it resized the LazyColumn. Combined with the rule that
pins it open once the list bottoms out, that formed a feedback loop: the
strip appears, the list shrinks, the list can scroll forward again, the
strip hides, the list grows, and it repeats every frame. Fixes #827.

Extract HdScrollAwayFooter, which hosts the strip as an overlay in the
same Box as the list and reports its measured height for the list's bottom
content padding, so the last row still clears it. Hiding now waits until
the list has consumed more downward scroll than the strip's own height,
which keeps a list whose only scroll room is that padding from hiding and
immediately re-pinning.

Apply it to the project list's create bar too, which had the same
resize-on-hide shape.

* Hide the scroll-away footer on direction, not distance

The distance threshold reset on any upward pixel, so the jitter in a real
drag kept it from ever accumulating and the strip hid only sometimes.
Responsiveness matters more here than the narrow case it was guarding:
a list whose only scroll room is the strip's own bottom padding can still
hide the strip and re-pin it at the bottom.
2026-08-02 23:19:38 -07:00
Adam Brown
22eef19f4e
Unify search matching on resolved Markdown (#831)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Match every search surface through one rule

Global search resolved escapes and then retried the raw source for a
query containing a backslash, while the Notes and Timeline filters only
did the first half and Story Ideas matched raw storage. The same query
gave different answers depending on which screen it was typed into.

Drop the retry rather than spread it. Searching the storage form of your
own text is not a thing readers do, and supporting it cost real
precision: a query of \* fell back to * and matched every emphasis
marker in the project, and a query ending in a backslash matched
nothing at all. It also forced the snippet and the title to be built
from different strings, so one result row could spell the same sentence
two ways.

What is left is one rule: resolve the stored escapes, match the query
literally. markdownContains carries it, next to matchesAllTags, and all
four surfaces call it. Names, tags, dates and idea titles are not
Markdown, so matching them as stored is now consistent rather than a
bypass.

Each screen's own composition of the rule is extracted so it can be
tested; composeUi/src/desktopTest covers all three.

* Say what the search rule actually does

The KDoc claimed markdownContains compares the prose a reader sees. It
only resolves backslash escapes, so a note stored as "the **big** dog"
is still unfindable by "big dog" on every surface, which is the #811
emphasis gap. State the limit instead of denying it.

SearchFilterTest claimed to pin each screen's composition of the rule
while composing no screen, so reverting a filter to an inline raw
contains would have kept it green. TimeLineOverviewUiTest now drives a
screen's search field end to end, and the predicate tests say that is
what they are.

Global search resolves and matches separately because it needs offsets
rather than a boolean, so add a test asserting it answers the same as
markdownContains across a table of content and query shapes.

* Drop the comma from a test name Kotlin/Native rejects

Native forbids commas in backtick-quoted names, so common/commonTest
failed to compile for iOS while desktop was fine.
2026-08-02 10:23:17 -07:00
Adam Brown
b9df69de84
Fix tag search dropping bodiless items and ignoring entry names (#833)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
A tag match stands on its own, so an item with an empty body no longer
loses its result to a null preview snippet. Covers notes, encyclopedia
entries, timeline events and scenes.

The tags-present branch of the encyclopedia match now tries the entry
name before the body, the way the scene path already does, so
"#fantasy alice" returns the entry named Alice.

Fixes #828
2026-08-02 01:19:39 -07:00
Adam Brown
e7cdad3cdc
Allow accented and non-latin characters in tags (#829)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Allow accented and non-latin characters in tags

The tag pattern used \w, which is ASCII-only on both JVM and Native, so
cleanTags() silently dropped anything like "thème" on the way to disk.
Validate with Unicode-aware character predicates instead, including
combining marks so decomposed input from iOS/macOS survives too.

Fixes #778

* Address review of the Unicode tag fix

Splitting tag input, stripping the `#` and validating the result were all
still assuming ASCII in one way or another:

- Split on any Unicode whitespace and on the fullwidth/ideographic commas,
  so an IME's fullwidth space separates tags instead of fusing them into one
  token that then fails validation.
- Normalize to NFC, so the precomposed and decomposed spellings of a tag are
  one index key rather than two identical-looking ones.
- Validate by code point, so an astral letter (rare kanji, Adlam) is not read
  as two non-letter surrogates and rejected.
- Accept ZWJ/ZWNJ, which Persian and Indic scripts need word-internally, and
  require at least one letter or digit so a mark-only tag has a base glyph.
- Strip a fullwidth `#` prefix as well as the ASCII one.

The tag field now commits chips on the same separators and parses them with
parseTagInput, so what it shows is what survives the save.
2026-08-01 22:40:47 -07:00
Adam Brown
c51fc1ff8b
Resolve Markdown escapes when searching project text (#824)
* Resolve Markdown escapes when searching project text

Global search compared queries against raw stored Markdown, so a phrase
that crosses a backslash escape never matched: "well-known" missed a
document holding "well\-known", and the snippet showed the backslash.

Resolve escapes before matching, and build the snippet from the resolved
text. This inverts exactly what the editor does on save, so it is safe
for imported and hand-edited content too, where a backslash escape means
the same thing.

Nothing else is rewritten. Emphasis, code, link and block markers are
left as stored, because telling syntax apart from a literal character
needs a parser, and guessing wrong silently alters the author's words.

Timeline dates are a plain-text field, so only the event body is
resolved. A query holding a backslash also tries the raw source, so
searching for a literal escape keeps working.

* Resolve escapes in search titles and in-screen search

The title above a result still came from raw storage while its snippet
was unescaped, so one card rendered the same sentence two ways. Resolve
escapes for the title as well.

The Notes and Timeline screens filter the same text with a raw substring
test, so a query that found a note in global search returned the empty
state on the screen that owns it. Resolve escapes there too.

Move unescapeMarkdown out of StoryExportCommon into data/search so the
exporters and search share one implementation rather than two copies,
and take its tests with it. Guard the timeline raw fallback the way
findMarkdownMatch already is, so a query without a backslash stops
rescanning an identical string.
2026-08-01 21:16:35 -07:00
Adam Brown
804d9cb75c
Harden the remaining E2E workflow clicks against dropped taps (#822)
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.
2026-08-01 17:01:18 -07:00
Adam Brown
210696f66a
Send htmx callers somewhere when access is denied (#823)
Both access-control plugins built their htmx denial with createHTML().div, whose result was discarded, so the response was an empty <html></html> and the caller saw nothing happen.

Redirect by header instead of trying to deliver a message in the body. htmx acts on HX-Redirect before it decides what to do with the body, which matters here: it discards the body of a 4xx outright, and StatusPages answers a 401 with the whole unauthorized page, which must not end up swapped into a fragment. Browser requests keep the redirect they already had, so both kinds of caller now land in the same place.
2026-08-01 16:33:35 -07:00
Adam Brown
4804534151
Add visible scrollbars to scrolling screens (#820)
Fixes #787. Settings and other long screens had no scrollbar, so on
Linux there was nothing to indicate the page scrolled at all.

Adds ScrollState, LazyGridState and LazyStaggeredGridState scrollbars
alongside the existing LazyListState one, and wires them into the
screens that scroll: account and project settings, About, project
stats, the encyclopedia/notes/timeline/story-idea detail views and
browse grids, the outline reading column and chapter rail, the scene
metadata panel, and the shared reorderable list.

Compose ships no staggered-grid scrollbar adapter, so that one is
implemented over the state's ScrollIndicatorState.

Desktop draws the bar as a hatched rail: an outlineVariant hairline on
the scrollable edge with 45 degree hatching behind an opaque thumb, so
the hatch that remains marks the part of the document still to come.
Both are drawn only while there is somewhere to scroll. Android keeps
its transient fading thumb, now themed rather than black on white, and
iOS keeps its native indicators.

Screens overlay the bar with scrollBarOverlay(); full-bleed content
reserves MpScrollBarGutter so the rail does not sit on top of it.
2026-08-01 15:45:52 -07:00
Adam Brown
199bc7dc6d
Show error toasts on htmx error responses (#818)
htmx discards the body of a 4xx, so the out-of-band toasts the dashboard's rejection paths send never reached the user.

The toast helpers mark error responses with X-Hammer-Swap-Error and toast.js swaps the ones carrying it. Marking each response rather than every 4xx keeps the swap away from responses that are not swap payloads: a bare respond(BadRequest) would blank its target, and a 404 or 401 would land a whole error page inside it. Toast-only errors also reswap to none so the toast lands without emptying the request's target.

Delete-account no longer needs its 200-on-error workaround.

Fixes #812
2026-08-01 15:43:48 -07:00
Adam Brown
8edd78f052
Cover the remaining host validation cases from #797 (#819)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The acceptance criteria call for leading-hyphen labels, empty labels and
underscores to be rejected, but only the trailing-hyphen case had a test.
Hyphens in subdomain and middle labels were untested too.

All five already behave correctly; this just pins the behavior down.
2026-08-01 14:21:50 -07:00
Adam Brown
23502a6ea1
Stop the encyclopedia E2E test failing on a dropped tap (#817)
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.
2026-08-01 13:40:13 -07:00
Adam Brown
f21fc6e26b
Lowercase the sync server URL when cleaning it up (#816)
Hostnames are case-insensitive, but the cleaned value is what gets
stored, so it should be normalized. Lowercasing before the removePrefix
calls also fixes uppercase schemes: removePrefix is case-sensitive, so
HTTPS://example.com kept its scheme and then failed validation.

Refs #797
2026-08-01 13:30:58 -07:00
Adam Brown
707fa8dece
Warn in the import preview when a scene is too large (#810)
Markdown chapter detection that finds nothing collapses a whole manuscript into
one scene. The first sign of that today is the editor locking up, after the
import has already been committed.

The preview now carries a word count per scene and flags any at or over 10,000
words: an amber notice above the list, and the offending rows tinted and stamped
with their real count. The import stays enabled; one huge scene is a legitimate
thing to want.

10,000 clears almost every real chapter (3,000 to 5,000 typical, ~20,000 for the
longest) and sits far below any whole book, so it fires on the collapse case
without nagging.

Also cuts three main-thread wastes profiling turned up on very large scenes.
SceneEditorUi and FocusModeUi passed getInitialEditorContent(...) to
rememberSpellCheckState unremembered; the value is only read on first
composition, but the argument was still evaluated every recomposition and
rebuilt the whole document's AnnotatedString on the UI thread, while the scene
buffer republishes every 500ms during typing. MarkdownEditField built a fresh
spell checker each recomposition, re-keying the library's full-rescan effect.
countWords no longer materialises a list of every word to count them.
2026-08-01 12:17:55 -07:00
Adam Brown
dd9774c34c
Self-service account deletion (#815)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Add self-service account deletion

Users can delete their account from the web dashboard danger zone. The
account is soft-deleted: locked out of login and sync, all stories
unpublished, pen name released, data retained for a configurable window
(accountDeletion.retentionDays, default 30 days) during which an admin
can restore it from the users page. A daily job permanently purges
accounts past the window. Admin accounts cannot be deleted; the guard is
enforced in the SQL, the service, and the UI.

* Harden account deletion edge cases from review

softDelete verifies the deleted flag actually landed before running its
destructive steps, and retries re-run the idempotent cleanup so a partial
failure heals; markDeleted leaves an already-deleted row untouched so
retries never extend retention. Tokens of soft-deleted accounts are
hidden inside the token query itself, restoring the whitelist-off
single-query bearer auth path. Re-registration against a soft-deleted
email returns the pending-deletion message instead of a misleading
"account exists", and the delete dialog warns that the email stays
reserved. Shared test account builder replaces per-file duplicates.
2026-08-01 02:59:30 -07:00
Adam Brown
cace720c9a
Detect Setext and bold chapter titles on Markdown import (#807)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Fix download link

* Detect Setext and bold chapter titles on Markdown import

Markdown import only recognized ATX headings, so a manuscript whose
chapter titles were Setext-underlined or merely bolded collapsed into a
single scene.

- Setext headings (`===` level 1, `---` level 2). A `---` only underlines
  a line that opens a paragraph, so scene-break rules and front-matter
  fences stay body text.
- In Auto, bold-only lines become chapters when heading markup produced
  at most a story title and several bold lines agree.
- A Pattern strategy for Markdown mirroring the RTF one, with the chapter
  regex field wired through ImportOptions and the import dialog.

ChapterHeadingLevel becomes MarkdownSplitStrategy now that it carries a
non-level member.

* Keep the word-count guard local to the Markdown importer

Sharing it via ImportStructure meant touching RtfStoryImporter, which #806 is already rewriting.
2026-07-31 21:15:47 -07:00
Adam Brown
94c3465b0e
Fix literal ** and _ around RTF-imported emphasis (#806)
RTF writers routinely include the surrounding spaces inside a formatting
run, so the importer emitted "** Chapter One **", which CommonMark does
not parse as emphasis. Emphasis delimiters now hug the text: leading and
trailing whitespace of a run moves outside the markers and a run made
only of whitespace opens nothing.

Italics emit "*" instead of "_" so intra-word emphasis renders, and the
escape set now mirrors ComposeTextEditor's MARKDOWN_SPECIAL_CHARS plus
its ordered-list marker escaping, so a paragraph starting with "- " or
"1. " no longer imports as a list.

Fixes #803
2026-07-31 21:15:45 -07:00
Adam Brown
eeba953897 Fix download link 2026-07-31 17:06:16 -07:00
Xander Smeets
ff8b278195
Set file permissions correctly immediately (#799)
Some checks are pending
Build CI / iOS UI tests (push) Waiting to run
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Perform the chown on the files copied into /opt/hammer during the COPY instruction, instead of during a separate RUN instruction. This prevents the files from being 'duplicated' due to having been changed on the layer for the RUN instruction. Effectively, this reduces the size of the layer for the RUN instruction from about 211 MB to approximately 0.
2026-07-31 08:50:51 -07:00
Adam Brown
5acba72867
Tell self-hosters when the server is not serving HTTPS (#800)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Clients are HTTPS-only, but the Docker image serves plain HTTP, so a client
pointed at it fails its TLS handshake and reported only "Network error
connecting to /api/account/create". The handshake is rejected by Jetty's HTTP
parser before any route runs, so nothing about it reaches the server log
either, leaving unrelated UnsupportedProtocolVersionException entries as the
only visible clue.

Split the IOException arm of Api.makeRequest so a TLS failure names HTTPS and
the certificate or reverse-proxy requirement. Detection is expect/actual:
SSLException on JVM, message markers on iOS where NSURLSession carries nothing
else through.

Also fix the server URL field on the way in: cleanUpUrl stripped the scheme
with removeSuffix instead of removePrefix, so a pasted http:// URL failed
validation, and validateUrl required a dotted TLD, rejected capitals, and
admitted ports above 65535 that would crash the unguarded toInt() in url().

Fixes #790
2026-07-30 22:16:41 -07:00
Adam Brown
3a85c461dd
Stop large story imports from freezing the app
Some checks are pending
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Importing a 100k+ word Markdown story froze the editor and consumed all its
RAM. Parsing was never slow (~60ms for 120k words); creating the scenes was,
and quadratically in their number. Resolving a path by id regex-parsed every
filename in the project, sibling counts re-listed the parent directory several
times per scene, and the order re-padding loop re-scanned once per renamed
sibling.

The cached directory scan now carries a scene-id index and a child-count
index, in ScenePathIndex. A created file is absorbed in place; anything else
drops the cache. The re-padding loop reads what it needs before it rather than
inside it, so a run of renames costs one re-scan instead of one each.

Measured on an in-memory filesystem, importing 400 scenes drops from 2.1s to
~0.3s and 1600 from 37s to ~3s. A real disk gains more, since the discarded
scans are syscalls.

Also builds the import preview off the UI thread, debounced and single
flighted, shows the dialog while it reads instead of leaving the projects list
looking idle, makes the preview pane lazy, and ignores the local scratch
directory.
2026-07-30 21:10:36 -07:00
Adam Brown
22520baca3
Fix flaky iOS scene-editor save UI test (#786)
tapUntilGone gated the save tap solely on isHittable. Under CI load the
top-bar save button's accessibility frame can never settle to hittable
within the timeout, so the loop waited out the whole budget without ever
tapping and failed with 'Element still present after 20.0s'.

Keep the proven isHittable hit-tested fast path and add a geometry
fallback: once the button's frame has settled (fully below the status
bar, ruling out the transient dead-pixel position, and stable across two
samples) tap it via tapCenter. This guarantees a tap eventually fires
even when isHittable never flips true, while still avoiding the
dead-pixel/scroll-away hazard the isHittable gate originally fixed.
2026-07-28 22:38:18 -07:00
Adam Brown
54538da8a6
Stop the E2E teardown racing the project-scope flush (#780)
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.
2026-07-27 14:35:52 -07:00
1188 changed files with 61072 additions and 10210 deletions

View file

@ -1,64 +0,0 @@
name: 'Set up JBR'
description: 'Set up the JetBrains Runtime, retrying through transient GitHub rate limits on the JBR download.'
inputs:
token:
description: 'GitHub token used for authenticated JBR downloads.'
required: true
cache:
description: "Build tool to cache dependencies for (passed through to setup-java's cache input). Empty disables caching."
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Set up JBR (attempt 1)
id: attempt1
uses: actions/setup-java@v5
continue-on-error: true
# The jetbrains distribution authenticates its release-listing API call via
# the GITHUB_TOKEN env var, not the `token` input — without it the request is
# unauthenticated and hits GitHub's per-IP rate limit.
env:
GITHUB_TOKEN: ${{ inputs.token }}
with:
distribution: 'jetbrains'
java-version: 21
token: ${{ inputs.token }}
cache: ${{ inputs.cache }}
- name: Back off before retry
if: steps.attempt1.outcome == 'failure'
shell: bash
run: sleep 30
- name: Set up JBR (attempt 2)
id: attempt2
if: steps.attempt1.outcome == 'failure'
uses: actions/setup-java@v5
continue-on-error: true
env:
GITHUB_TOKEN: ${{ inputs.token }}
with:
distribution: 'jetbrains'
java-version: 21
token: ${{ inputs.token }}
cache: ${{ inputs.cache }}
- name: Back off before final retry
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
shell: bash
run: sleep 60
# Final attempt: no continue-on-error, so a persistent failure still fails the job.
- name: Set up JBR (attempt 3)
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
uses: actions/setup-java@v5
env:
GITHUB_TOKEN: ${{ inputs.token }}
with:
distribution: 'jetbrains'
java-version: 21
token: ${{ inputs.token }}
cache: ${{ inputs.cache }}

View file

@ -21,9 +21,9 @@ jobs:
cache: gradle
- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
node-version: '24'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
@ -33,6 +33,12 @@ jobs:
# buildDebug does not touch androidTest, so stale instrumented tests rot silently otherwise.
- name: Compile Android instrumented tests
run: ./gradlew :android:assembleDebugAndroidTest
# buildDebug abbreviates to buildDebugPreBundle, which never runs unit tests, and Kover
# aggregates only :common and :server, so the phone's unit tests need their own step.
- name: Unit test the Android app
run: ./gradlew :android:testDebugUnitTest
- name: Build and unit test the Wear OS app
run: ./gradlew :wear:assembleDebug :wear:testDebugUnitTest
- name: Run Kover
run: ./gradlew koverXmlReport
- name: Upload coverage reports to Codecov

View file

@ -15,8 +15,8 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'adopt'
java-version: 21
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Run Unit Tests
@ -50,10 +50,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JBR
uses: ./.github/actions/setup-jbr
- name: Set up JDK
uses: actions/setup-java@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
@ -71,7 +72,7 @@ jobs:
timeout_minutes: 30
max_attempts: 3
shell: pwsh
command: ./gradlew :desktop:packageReleaseMsi
command: ./gradlew :desktop:packageReleaseMsi -Pchannel=github -Pformat=msi
- name: Rename MSI Artifact
shell: cmd
@ -103,7 +104,7 @@ jobs:
timeout_minutes: 30
max_attempts: 3
shell: pwsh
command: ./gradlew :desktop:packageReleaseExe
command: ./gradlew :desktop:packageReleaseExe -Pchannel=github -Pformat=exe
- name: Rename EXE Artifact
shell: cmd
@ -129,6 +130,17 @@ jobs:
echo Successfully renamed file to "hammer.exe"
- name: Write update digests
shell: pwsh
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/msi/hammer.msi,desktop/build/installers/main-release/exe/hammer.exe"
- name: Upload update digests
uses: actions/upload-artifact@v7
with:
name: update-digests-windows
path: desktop/build/installers/main-release/*/hammer.*.sha512
if-no-files-found: error
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
env:
@ -149,10 +161,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JBR
uses: ./.github/actions/setup-jbr
- name: Set up JDK
uses: actions/setup-java@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
@ -190,7 +203,7 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew :desktop:packageReleaseDeb
command: ./gradlew :desktop:packageReleaseDeb -Pchannel=github -Pformat=deb
- name: Rename DEB Artifact
run: |
@ -214,7 +227,7 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew :desktop:packageReleaseRpm
command: ./gradlew :desktop:packageReleaseRpm -Pchannel=github -Pformat=rpm
- name: Rename RPM Artifact
run: |
@ -238,7 +251,17 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew buildDistAppImage
command: ./gradlew buildDistAppImage -Pchannel=appimage -Pformat=appimage
- name: Write update digests
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/deb/hammer.deb,desktop/build/installers/main-release/rpm/hammer.rpm,desktop/build/installers/main-release/appimage/hammer.AppImage"
- name: Upload update digests
uses: actions/upload-artifact@v7
with:
name: update-digests-linux
path: desktop/build/installers/main-release/*/hammer.*.sha512
if-no-files-found: error
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
@ -260,10 +283,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JBR
uses: ./.github/actions/setup-jbr
- name: Set up JDK
uses: actions/setup-java@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
@ -280,7 +304,7 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew :desktop:packageReleaseDmg
command: ./gradlew :desktop:packageReleaseDmg -Pchannel=github -Pformat=dmg
- name: Rename DMG Artifact
run: |
@ -304,7 +328,7 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew :desktop:packageReleasePkg
command: ./gradlew :desktop:packageReleasePkg -Pchannel=github -Pformat=pkg
- name: Rename PKG Artifact
run: |
@ -325,6 +349,16 @@ jobs:
echo "Contents of pkg directory:"
ls -la
- name: Write update digests
run: ./gradlew writeAssetDigests "-PassetFiles=desktop/build/installers/main-release/dmg/hammer.dmg,desktop/build/installers/main-release/pkg/hammer.pkg"
- name: Upload update digests
uses: actions/upload-artifact@v7
with:
name: update-digests-macos
path: desktop/build/installers/main-release/*/hammer.*.sha512
if-no-files-found: error
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
env:
@ -348,8 +382,8 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'adopt'
java-version: 21
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
@ -410,8 +444,8 @@ jobs:
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'adopt'
java-version: 21
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
@ -462,6 +496,9 @@ jobs:
echo "File renamed successfully."
- name: Rename R8 Mapping
run: cp "$GITHUB_WORKSPACE/android/build/outputs/mapping/release/mapping.txt" "$GITHUB_WORKSPACE/android/build/outputs/apk/release/hammer-apk-mapping.txt"
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
env:
@ -471,7 +508,7 @@ jobs:
prerelease: true
artifactErrorsFailBuild: true
allowUpdates: true
artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk"
artifacts: "${{ github.workspace }}/android/build/outputs/apk/release/hammer.apk,${{ github.workspace }}/android/build/outputs/apk/release/hammer-apk-mapping.txt"
snap:
needs: tests
@ -482,10 +519,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JBR
uses: ./.github/actions/setup-jbr
- name: Set up JDK
uses: actions/setup-java@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Grant Permission to Execute
@ -520,10 +558,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up JBR
uses: ./.github/actions/setup-jbr
- name: Set up JDK
uses: actions/setup-java@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
distribution: 'temurin'
java-version: '21'
cache: gradle
- name: Install Flatpak and flatpak-builder
@ -559,7 +598,7 @@ jobs:
with:
timeout_minutes: 30
max_attempts: 3
command: ./gradlew buildDistFlatpak
command: ./gradlew buildDistFlatpak -Pchannel=flathub
- name: Create Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
@ -586,10 +625,61 @@ jobs:
- name: Fetch tags
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Gradle caching
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# The desktop updater reads latest*.yml from the release. Each platform job hashed
# the installer it attached and handed the digest over as a workflow artifact, so
# nothing is downloaded back and no release lookup is needed. Attached before the
# draft flips, so a published release never exists without them.
- name: Collect update digests
uses: actions/download-artifact@v7
with:
pattern: update-digests-*
merge-multiple: true
path: ${{ runner.temp }}/update-digests
- name: Write update metadata
run: ./gradlew writeUpdateMetadata "-PreleaseDigestsDir=${{ runner.temp }}/update-digests" --no-daemon
- name: Attach update metadata
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: true
prerelease: true
artifactErrorsFailBuild: true
allowUpdates: true
artifacts: "${{ github.workspace }}/build/update-metadata/latest.yml,${{ github.workspace }}/build/update-metadata/latest-mac.yml,${{ github.workspace }}/build/update-metadata/latest-linux.yml"
- name: Get last tag message
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
TAG_MESSAGE=$(git tag -n90 --format='%(contents)' "$TAG_NAME" )
# prepareForRelease appends this trailer when the release dialog's
# auto-publish box is checked. Strip it so it stays out of the body.
AUTO_PUBLISH=false
if printf '%s\n' "$TAG_MESSAGE" | grep -qxF 'Auto-Publish: true'; then
AUTO_PUBLISH=true
fi
TAG_MESSAGE=$(printf '%s\n' "$TAG_MESSAGE" | grep -vxF 'Auto-Publish: true' || true)
echo "auto_publish=$AUTO_PUBLISH" >> $GITHUB_ENV
echo "Auto-publish: $AUTO_PUBLISH"
echo "Tag: $TAG_NAME"
echo -e "Tag message:\n$TAG_MESSAGE"
@ -607,3 +697,19 @@ jobs:
prerelease: true
allowUpdates: true
body: ${{ env.tag_message }}
# Flipping the draft fires `release: published`, which starts
# publish-release.yml. The token must not be GITHUB_TOKEN: GitHub suppresses
# workflow runs triggered by it, so no store would ever publish.
- name: Publish release
if: env.auto_publish == 'true'
env:
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
REPO: ${{ github.repository }}
TAG_NAME: ${{ github.ref_name }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Auto-publish was requested but the RELEASE_PAT secret is not set."
exit 1
fi
gh release edit "$TAG_NAME" --repo "$REPO" --draft=false --prerelease=false --latest

View file

@ -36,9 +36,9 @@ jobs:
distribution: 'temurin'
cache: gradle
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.4.8'
ruby-version: '3.4.10'
bundler-cache: true
- name: Cache Ruby - Bundler
@ -89,3 +89,15 @@ jobs:
esac
echo "Running fastlane $LANE for track=$TRACK"
bundle exec fastlane "$LANE"
# Keeps the mappings for the exact Play builds next to the release, so prod
# crash traces can be retraced without the Play Console.
- name: Attach R8 mappings to the GitHub release
if: github.event_name == 'release'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name }}
run: |
cp android/build/outputs/mapping/release/mapping.txt hammer-google-play-mapping.txt
cp wear/build/outputs/mapping/release/mapping.txt hammer-wear-mapping.txt
gh release upload "$TAG" hammer-google-play-mapping.txt hammer-wear-mapping.txt --clobber

View file

@ -21,9 +21,9 @@ jobs:
distribution: 'temurin'
cache: gradle
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.4.8'
ruby-version: '3.4.10'
bundler-cache: true
- name: Cache Ruby - Bundler

View file

@ -15,6 +15,18 @@ on:
required: false
workflow_call:
# Serialized against the Mac App Store publish: since iOS moved onto the macOS
# app record for Universal Purchase, both publishes target the SAME App Store
# Connect record, and deliver edits record-level App Information (categories,
# copyright) as well as the per-platform version. A full release tag — one with
# no `+platform` suffix — starts both in parallel, so they must queue instead.
# The group name is deliberately constant, not per-tag: two overlapping releases
# would collide the same way. cancel-in-progress stays false; aborting a publish
# part-way through a review submission is far worse than waiting for a slot.
concurrency:
group: app-store-connect
cancel-in-progress: false
jobs:
publish-ios-app-store:
runs-on: macos-latest
@ -38,9 +50,9 @@ jobs:
sudo xcode-select -s "$XCODE_APP/Contents/Developer"
xcodebuild -version
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.4.8'
ruby-version: '3.4.10'
bundler-cache: true
- name: Install bundle

View file

@ -15,6 +15,18 @@ on:
required: false
workflow_call:
# Serialized against the Mac App Store publish: since iOS moved onto the macOS
# app record for Universal Purchase, both publishes target the SAME App Store
# Connect record, and deliver edits record-level App Information (categories,
# copyright) as well as the per-platform version. A full release tag — one with
# no `+platform` suffix — starts both in parallel, so they must queue instead.
# The group name is deliberately constant, not per-tag: two overlapping releases
# would collide the same way. cancel-in-progress stays false; aborting a publish
# part-way through a review submission is far worse than waiting for a slot.
concurrency:
group: app-store-connect
cancel-in-progress: false
jobs:
publish-mac-app-store:
runs-on: macos-latest
@ -34,9 +46,9 @@ jobs:
distribution: 'temurin'
cache: gradle
- uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.4.8'
ruby-version: '3.4.10'
bundler-cache: true
- name: Grant Permission to Execute

View file

@ -27,7 +27,7 @@ jobs:
cache: gradle
- name: Build MSIX package
run: ./gradlew :desktop:packageMsix
run: ./gradlew :desktop:packageMsix -Pchannel=ms-store -Pformat=msix
- name: Upload Gradle problems report on failure
if: failure()

View file

@ -11,6 +11,8 @@ jobs:
publish-google-play:
if: ${{ !contains(github.event.release.tag_name, '+') || contains(github.event.release.tag_name, '+google-play') }}
uses: ./.github/workflows/publish-google-play.yml
permissions:
contents: write
secrets: inherit
with:
track: production

3
.gitignore vendored
View file

@ -93,3 +93,6 @@ fastlane/README.md
# server/src/main/resources/assets/images/ are committed, their sources are not
/scripts/build-home-screenshots.py
/scripts/home-screenshot-sources/
# Local working area: marketing renders, one-off scripts, anything not shipped
/scratch/

View file

@ -1,5 +1,244 @@
# Changelog
## [3.12.0] - 2026-10-5
New features
- Reader Mode: read your story as prose, one chapter at a time, instead of through the scene editor. Open it from the Read button under the scene list, the book icon in the scene editor, or the editor menu. The scene list becomes a table of contents, and the reader has its own font size
- Export: three new options in the Export Story dialog. Number chapter titles, pick a font (Word, RTF and EPUB), and choose whether to keep blank lines between paragraphs
- Android and iOS: an OLED theme, the dark theme on a pure black background
- Desktop (Windows and Linux installs from GitHub): Hammer tells you when a newer release is out and can update itself from the project list or the About screen. macOS DMG installs are told and sent to the release page. Automatic checks can be turned off in Settings
- Text editor 3.0: Huge improvements in input and performance
- Text editor: change the text size with Ctrl/Cmd and Plus, Minus or 0, or from the format bar's overflow menu
- Text editor: Delete in the right-click menu
- Turkish translation
- Web: a fifth reader reaction on public stories, "Surprised me"
Improvements
- Syncing with unsaved work now asks Save, Discard or Cancel first, where before it silently saved every unsaved scene
- Open notes, timeline events, encyclopedia entries and the scene title, outline and notes refresh when a sync or conflict resolution changes them. An edit in progress keeps its draft
- The Backup Manager lists backups of projects that are no longer on disk, so a deleted project can be restored
- Exports no longer number chapter titles unless you turn the new option on. Word, RTF, PDF and Markdown used to number them always
- Text editor: the heading button writes real Markdown headings, and Hammer's shortcuts land on the same keys on non-QWERTY layouts
- Android: a launch splash screen on every supported version
- Translations and dependencies updated
Fixes
- Android: unsaved scene edits could be lost when the app was closed in the background. They now come back as unsaved scenes the next time the project opens [#983]
- Export: unsaved scene edits were left out of the exported file [#983]
- Restoring a backup from the Backup Manager made the project disappear from the project list
- Choosing Discard on close with automatic sync on still saved and uploaded the discarded scenes
- PDF export: long paragraphs ran off the bottom of the page or left large blank gaps
- Android: a crash on launch when the saved login could not be decrypted, for example after restoring the device from a backup. You are asked to sign in again
- iOS: a crash when tapping into a text editor
- Server URL, number, tag and word-list fields no longer autocorrect or capitalize, which put a space after each "." in a server URL
- Encyclopedia references no longer match a name inside a longer word with accented or non-Latin letters ("Ana" in "Anaïs")
## [3.11.0] - 2026-9-24
New features
- Web: signed-in readers can leave kudos at the end of a publicly published story, picking up to four craft chips and one reaction. Kudos are anonymous: authors see per-chip totals on their story page, and a chip appears on the public page once three readers have picked it. Authors can turn kudos off for any story
Improvements
- Word counts no longer count standalone Markdown markers such as horizontal rules, heading marks, bullets and blockquote markers as words. The server now counts the same way, so both give the same number
- Android and Wear OS builds are now shrunk and obfuscated with R8
- Translations and dependencies updated
Fixes
- Text editor: numpad navigation keys and numpad Enter now work
- Text editor: keyboard (IME) edits are applied right away, and the keyboard is told about each one only once
- Text editor: spelling underlines stay on the right words while you keep typing
- Text editor: the formatting you picked for new text is kept after unrelated updates
- Text editor: fixed crashes and misplaced carets when drawing the cursor or moving it during a layout change
- Wear OS: pairing with the phone app works in release builds
## [3.10.1] - 2026-9-23
- Fix Android Wear release
## [3.10.0] - 2026-9-20
New features
- A Wear OS watch app: dictate notes into your projects and capture story ideas from your wrist, then sync them on their own. Pair it from your phone, or sign in on the watch directly
- Add words to a project's own spelling dictionary, either from "Add to dictionary" on a flagged word in the editor or from the word list in Project Settings under Spell checking. The list syncs with the project
- Encyclopedia entry names and draft names accept the same characters project and scene names already did: punctuation like . , ! ? : ( ) & - " and non-Latin letters
- Copy Diagnostics on the About screen puts your version, system details, recent log, and newest crash report on the clipboard, so a bug report can carry its own diagnostics
- Desktop: Export Logs saves a zip wherever you choose, which works even where opening the log folder does not
Improvements
- A failed login says what actually went wrong instead of answering with a bare 401, and a self-hosted server logs which failure occurred
- Account creation reports policy problems, real conflicts, and Allowed Users rejections distinctly
- Builds record which store or channel they came from, which shows up in diagnostics
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Web: the home page offers the iOS app, which now shares one App Store listing with macOS
Fixes
- Desktop (Microsoft Store): the app crashed on its first network call and could not sync at all
- Client: email and password fields no longer capitalize the first character as you type, which could create an account with a password you could not reproduce elsewhere
- Client: a network failure occurring before any request was sent showed a raw Java error instead of a real message
- Text editor: the spelling suggestions menu could stick on "Loading" when the editor re-anchored it
- Sync: clients on different versions no longer fail an entire sync over a field one side does not recognize
- Sync: a failed sync no longer ends by logging "Sync complete!"
- Web: previous and next page links on the community feed and authors pages
## [3.9.8] - 2026-9-12
iOS Release fix
## [3.9.7] - 2026-9-4
iOS Release fix
## [3.9.6] - 2026-9-3
Release fix
## [3.9.5] - 2026-9-1
Release fix
## [3.9.4] - 2026-8-22
Fix widgets not doing anything
## [3.9.3] - 2026-8-22
Bug fixes
## [3.9.2] - 2026-8-21
- Fix MacOS Crash
- Fix Android widgets not working
## [3.9.1] - 2026-8-18
- Fix sharing scene order
- Fix PDF export crash
- Fix MacOS crash on start
- Added configurable timezone for server admins
## [3.9.0] - 2026-8-16
New features
- Export only the scenes you choose, in every export format
- Private web shares can be limited to a selection of scenes
- Encyclopedia entry names and aliases feed the spell check dictionary, so your invented names stop being flagged as misspelled. Controllable globally, per project, and per entry
- Scenes now connect to every kind of Encyclopedia entry, not just People and Places
- Drag the editor's edge to set how wide it gets on large screens
- Server: search the Allowed Users list by email
Improvements
- Logging in to a new server asks whether to merge or replace, and only asks when local work is actually at stake
- Published stories and every export now lay prose out the way the editor shows it, line for line
- A scene opens for typing immediately instead of waiting for spell check to finish
- Scene tree nesting is easier to read
- Save all moved from Ctrl+Alt+S to Ctrl+Shift+S, so AltGr layouts keep their characters
- Desktop (Linux): runs natively on Wayland instead of being forced through XWayland
- Web: the editorial review dialog's scene list uses the full height of the window
- Italian translation added, and translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Server: Argon2 password hashing no longer loads a native library, which fixes signup and login on hosts that mount the data volume noexec
Fixes
- Client: scenes in a folder holding exactly ten items could go blank and export empty
- Client: sync could fail when a folder crossed a ten-item boundary
- Client: unsaved edits survive a device rotation on Android
- Client: AltGr chords no longer close the app on Turkish, German, and Polish layouts
- Client: crash when closing a project with the scene metadata panel open
- Sync: renaming a project onto the name of one you had deleted
- Sync: project settings could be wiped when a project moved to a different server
- Sync: a local project sharing a name with a server project could leave a duplicate behind
- Server: sync could hang indefinitely on hosts with a shallow entropy pool
- Server: concurrent sync sessions could be handed colliding sync ids
- Server: API errors returned a 500 instead of falling back to English when a translation was missing
- Web: Allowed Users paging could repeat and skip entries
## [3.8.2] - 2026-8-11
New features
- Web: public sign-up page, so an allowed user can create an account without installing the app
- Server: the Allowed Users list is now always enforced; the old whitelist toggle is gone
Improvements
- Server: web sign-ins are now recorded in the security audit trail, alongside app sign-ins
- Server: admin instance messages render as Markdown, so links, emphasis, and lists work in them
- About: Dark Rock Studios attribution moved into its own "Studio" section with a full-screen Cairn overlay
- Desktop: dropped the JetBrains Runtime requirement; builds run on stock Temurin OpenJDK now
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
Fixes
- Desktop (macOS): fixed a crash on launch
- Desktop (Linux/Snap): fixed a crash on launch
## [3.8.1] - 2026-8-8
Improvements
- Desktop window decoration migrated to the Nucleus TAO backend
- Server: trustProxyForwarding option reads real client IPs from X-Forwarded-* headers when running behind a reverse proxy, so the login rate limiter, login audit trail, and story reader counts reflect actual clients instead of the proxy
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Rendered stories now use real book typography: indented lists, a centered scene-break ornament, tinted blockquotes, and deliberate blank-line spacing between paragraphs
Fixes
- Client: text editor no longer drops block structure (horizontal rules, images, code fences) when a scene loads
- Client: font size changes no longer flatten list and blockquote indents
- Client: typed text after a size change could lose the paragraph's body style
- Client: F3 and Ctrl+Alt+S sync shortcuts work from anywhere in the project window
- Client: tag suggestion chips could add the typed prefix instead of the selected tag
- Sync: projects with a stale cached server id could get stuck failing "410 Gone" instead of recreating
- Web: ~~strikethrough~~ rendered as literal tildes instead of struck-through text
## [3.8.0] - 2026-8-4
https://hammer.ink/blog/news/hammer-3-8-0
### New features
- Set the language a project is written in, and have spell check follow it
- Self-service account deletion from the web dashboard, with an admin restore window
- Import and export documents as HTML, alongside Markdown
- "What's New" release notes shown in-app with no network request, and reopenable from About
- Import preview warns when a scene comes in unusually large, so a failed chapter split doesn't quietly collapse a manuscript into one scene
### Improvements
- Text editor: faster typing and relayout, word-wise undo with a much deeper history, and rich copy and paste that preserves formatting between apps
- Text editor: platform-correct key bindings on macOS, and AltGr chords type their character instead of firing shortcuts
- Large Markdown imports are dramatically faster: 1600 scenes went from 37 seconds to about 3
- Search matches the rendered prose, resolving Markdown escapes and formatting
- Tag search matches accented and non-latin characters, and searches entry names as well as bodies
- Scene drag and drop stays accurate while the list autoscrolls
- Chapter detection on Markdown import understands Setext-style and bold titles
- Visible scrollbars on scrolling screens, and scroll-away footers float over their lists
- Translations updated for German, Spanish, French, Italian, Portuguese (Brazil), and Ukrainian
- Server: the setup page can create the initial admin account
- Server: a self-hosted server not serving HTTPS now reports a clear error instead of "Network error"
- Server: smaller Docker image
- Web: error responses show a toast instead of failing silently
### Fixes
- Client: importing a very large story no longer freezes the app
- Client: scene drag could grab the wrong row
- Client: literal `**` and `_` left around emphasis imported from RTF
- Client: sync server URLs are normalized consistently when saved
- Client: a number of text editor crashes around undo, paste, and joined paragraphs
- Web: htmx requests denied access landed on a blank page instead of being redirected
- Web: broken download link on the home page
## [3.7.2] - 2026-7-27
[Improve]

View file

@ -146,8 +146,58 @@ flowchart TD
## Build Variants
### The distribution channel
Every build is stamped with the distribution vehicle it was produced for, via
`-Pchannel=<token>`:
```
./gradlew :desktop:packageMsix -Pchannel=ms-store
```
Omitting it produces a `dev` build. An unknown token fails the build rather than
silently falling back, so a typo in a release workflow cannot ship a store binary as
`dev`. Each release pipeline passes its own channel; `prepare-release.yml` uses `github`
for the installers it attaches to the GitHub release.
The token list lives in two places that must stay in step, pinned by a test on each side:
| Location | Role |
| --- | --- |
| `buildSrc/src/main/java/distributionChannel.kt` | Build-side enum and the `-Pchannel` resolver. |
| `base/src/commonMain/.../DistributionChannel.kt` | Runtime enum. `DistributionChannel.current` reads the baked-in `BuildMetadata.CHANNEL`. |
Branch on `DistributionChannel.current` for behaviour that varies per vehicle: self-update
(forbidden in the app stores, expected for the GitHub and AppImage builds), where "get the
app" links point, external-payment policy, and sandbox-aware path resolution. It is a flat
enum on purpose: every channel-conditional branch is a greppable `when` on the type.
Capability flags (`canSelfUpdate`, `isSandboxed`, …) can be introduced later, from real
duplication rather than a guess at the axes.
The build side reads the same token through `Project.distributionChannel()` in buildSrc:
`desktop/build.gradle.kts` derives its Mac App Store packaging mode (`isAppStoreRelease`:
sandbox entitlements, provisioning profiles, signing) from `-Pchannel=mac-app-store`. Together
with `-PbuildNumber=N`, that is everything `build-appstore.sh` passes.
Packaging runs carry a second stamp, `-Pformat=<token>` (`msi`, `exe`, `deb`, `rpm`, `dmg`,
`pkg`, `appimage`, `msix`, `snap`, `flatpak`), read the same way into
`BuildMetadata.PACKAGE_FORMAT` and `PackageFormat.current`. The channel says which vehicle a
build is for; the format says which installer it came out of, which is what the desktop
updater needs to pick its download. Omitting it gives `none`, an unknown token fails the
build, and each release step passes its own. Two packaging tasks in one Gradle invocation
would both carry the first task's format, so the release workflow runs one per step.
Note that every branch compiles into every build, so a store-forbidden code path is
physically present in a store binary even when unreachable. If a store ever objects to
presence rather than behaviour, promote that one case to a real source set rather than
restructuring everything.
### The F-Droid build flag
Predates the channel constant and is still the switch for the two things that must happen
before any Kotlin runs (see the table below). A build with the F-Droid flag set and no
`-Pchannel` reports the `fdroid` channel.
F-Droid builds are produced by the same modules as the Google Play build, but with a
single build flag toggled. There are no Gradle product flavors; instead the flag is read
directly from a Gradle property (or an environment variable) wherever it's needed:
@ -184,9 +234,10 @@ Access), which Google Play does not allow, so the feature is gated to F-Droid bu
- `HammerApplication` forces internal storage on non-F-Droid builds, so a leftover
preference can never point a Google Play build at a directory it has no permission for.
When adding new runtime behaviour that should differ between channels, branch on
`com.darkrockstudios.apps.hammer.common.BuildConfig.FDROID` rather than re-reading the
Gradle property.
For behaviour that is specifically about F-Droid's manifest and storage permissions,
branch on `com.darkrockstudios.apps.hammer.common.BuildConfig.FDROID` rather than
re-reading the Gradle property. For anything else that varies per vehicle, use
`DistributionChannel.current`.
## Client Development

View file

@ -1,6 +1,6 @@
# Privacy Policy
_Last updated: 21 July 2026_
_Last updated: 15 September 2026_
This is a plain-language explanation of how Hammer handles your data, from Dark Rock Studios, the
makers of Hammer. The short version: Hammer is offline-first, your writing lives on your own
@ -35,8 +35,12 @@ leaves your device unless you choose to send it.
## 5. Security
For anything that does leave your device through a feature you turned on, we do our best to protect
it. That said, no method of sending data over the internet is ever completely secure, and we won't
pretend otherwise.
it. Hammer connects to a sync server over an encrypted HTTPS connection unless you deliberately
enter an address beginning with `http://`, which some people need for a server on their own home
network. A plain `http://` connection is not encrypted, so your account details and the writing you
sync can be read by anyone who can see that network. The app warns you when you enter such an
address. That said, no method of sending data over the internet is ever completely secure, and we
won't pretend otherwise.
## 6. Links to other places

View file

@ -7,7 +7,7 @@ A simple tool for building stories.
![badge-platform-android] ![badge-platform-ios] ![badge-platform-windows] ![badge-platform-linux] ![badge-platform-macos]
[![Discord badge](https://img.shields.io/discord/1100282852295327744?logo=discord)](https://discord.gg/GTmgjZcupk)
[![Discord badge](https://img.shields.io/discord/1100282852295327744?logo=discord)](https://discord.gg/49Kj5mMj6d)
[![Crowdin](https://badges.crowdin.net/hammer-editor/localized.svg)](https://crowdin.com/project/hammer-editor)
### Available on:
@ -15,13 +15,15 @@ A simple tool for building stories.
| Platform | App Store | Direct download |
|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Android** | [![Google Play](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.darkrockstudios.apps.hammer.android%26l%3DGoogle%2520Play%26m%3D%24version)](https://play.google.com/store/apps/details?id=com.darkrockstudios.apps.hammer.android) [![F-Droid](https://img.shields.io/f-droid/v/com.darkrockstudios.apps.hammer.android?logo=FDROID)](https://f-droid.org/en/packages/com.darkrockstudios.apps.hammer.android/) | [![.apk](https://img.shields.io/badge/.apk-3DDC84?logo=android&logoColor=white)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.apk) |
| **iOS** | [![App Store](https://img.shields.io/badge/App_Store-0D96F6?logo=apple&logoColor=white)](https://apps.apple.com/us/app/hammer-editor/id6771650681) | — |
| **iOS** | [![App Store](https://img.shields.io/badge/App_Store-0D96F6?logo=apple&logoColor=white)](https://apps.apple.com/us/app/hammer-editor/id6770841038) | — |
| **Windows** | [![Microsoft Store](https://img.shields.io/badge/Microsoft_Store-0078D4?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZmZmZiI+PHBhdGggZD0iTTAgMy40NDlMOS43NSAyLjF2OS40NTFIMG0xMC45NDktOS42MDJMMjQgMHYxMS40SDEwLjk0OU0wIDEyLjZoOS43NXY5LjQ1MUwwIDIwLjY5OU0xMC45NDkgMTIuNkgyNFYyNGwtMTIuOS0xLjgwMSIvPjwvc3ZnPg==)](https://apps.microsoft.com/detail/9p84lm8qv5x6) | [![.msi](https://img.shields.io/badge/.msi-0078D4?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZmZmZiI+PHBhdGggZD0iTTAgMy40NDlMOS43NSAyLjF2OS40NTFIMG0xMC45NDktOS42MDJMMjQgMHYxMS40SDEwLjk0OU0wIDEyLjZoOS43NXY5LjQ1MUwwIDIwLjY5OU0xMC45NDkgMTIuNkgyNFYyNGwtMTIuOS0xLjgwMSIvPjwvc3ZnPg==)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.msi) [![.exe](https://img.shields.io/badge/.exe-0078D4?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iI2ZmZmZmZiI+PHBhdGggZD0iTTAgMy40NDlMOS43NSAyLjF2OS40NTFIMG0xMC45NDktOS42MDJMMjQgMHYxMS40SDEwLjk0OU0wIDEyLjZoOS43NXY5LjQ1MUwwIDIwLjY5OU0xMC45NDkgMTIuNkgyNFYyNGwtMTIuOS0xLjgwMSIvPjwvc3ZnPg==)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.exe) |
| **macOS** | [![Mac App Store](https://img.shields.io/badge/Mac_App_Store-000000?logo=apple&logoColor=white)](https://apps.apple.com/us/app/hammer-editor/id6770841038) | [![.dmg](https://img.shields.io/badge/.dmg-000000?logo=apple&logoColor=white)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.dmg) |
| **Linux** | [![Snap Store](https://img.shields.io/badge/Snap_Store-82BEA0?logo=snapcraft&logoColor=white)](https://snapcraft.io/hammer-editor) | [![.deb](https://img.shields.io/badge/.deb-A81D33?logo=debian&logoColor=white)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.deb) [![.rpm](https://img.shields.io/badge/.rpm-EE0000?logo=redhat&logoColor=white)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.rpm) [![AppImage](https://img.shields.io/badge/AppImage-FCC624?logo=linux&logoColor=black)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.AppImage) [![Flatpak](https://img.shields.io/badge/Flatpak-4A90D9?logo=flatpak&logoColor=white)](https://github.com/Darkrock-Studios/hammer-editor/releases/latest/download/hammer.flatpak) |
### Community
Join our [Discord](https://discord.gg/GTmgjZcupk) and help us by reporting bugs, making feature requests, and discussing
Join our [Discord](https://discord.gg/49Kj5mMj6d) and help us by reporting bugs, making feature
requests, and discussing
the future of Hammer.
Or visit us on Reddit at [r/hammereditor](https://www.reddit.com/r/hammereditor/)
@ -95,7 +97,7 @@ latest commit.~~
💡 Have suggestions?
📚 Want to help translate?
🎮 Interested in our other apps?
👉 Join our community of Open Source enthusiasts on [**Discord**](https://discord.gg/ju2RQa5x8W)!
👉 Join our community of Open Source enthusiasts on [**Discord**](https://discord.gg/49Kj5mMj6d)!
# Development

View file

@ -5,7 +5,7 @@ Thanks for using Hammer! If you need help, have feedback, or want to request a f
## Get in touch
- **Email:** [darkrockstudios@gmail.com](mailto:darkrockstudios@gmail.com)
- **Chat with us on Discord:** [discord.gg/GTmgjZcupk](https://discord.gg/GTmgjZcupk)
- **Chat with us on Discord:** [join discord](https://discord.gg/49Kj5mMj6d)
- **Report a bug or request a feature:** [open an issue on GitHub](https://github.com/Darkrock-Studios/hammer-editor/issues)
We aim to reply to support emails within a few business days.
@ -50,7 +50,12 @@ config directory).
**macOS:**
`~/Library/Application Support/hammer/0/logs/`
`~/Library/Preferences/hammer/0/logs/`
If you installed Hammer from the Mac App Store, it runs sandboxed and that
path is relocated into the app's container:
`~/Library/Containers/com.darkrockstudios.apps.hammer/Data/Library/Preferences/hammer/0/logs/`
**Linux:**

View file

@ -40,6 +40,7 @@ dependencies {
implementation(libs.work.runtime.ktx)
implementation(libs.material)
implementation(libs.appcompat)
implementation(libs.core.splashscreen)
implementation(libs.multiplatform.settings)
androidTestImplementation(libs.androidx.junit)
@ -51,7 +52,15 @@ dependencies {
androidTestUtil(libs.orchestrator)
implementation(libs.aboutlibraries.core)
if (!isFDroidBuild) {
implementation(libs.play.services.wearable)
implementation(libs.coroutines.play.services)
implementation(libs.lifecycle.process)
}
testImplementation(platform(libs.junit.bom))
testImplementation(libs.bundles.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.coroutines.test)
androidTestImplementation(libs.bundles.junit.jupiter)
}
@ -70,6 +79,14 @@ android {
useSupportLibrary = true
}
}
// Wear OS pairing needs Google Play services, which F-Droid builds may not ship.
if (isFDroidBuild) {
sourceSets.getByName("main").kotlin.srcDir("src/nogms/kotlin")
} else {
sourceSets.getByName("main").kotlin.srcDir("src/gms/kotlin")
sourceSets.getByName("main").res.srcDir("src/gms/res")
sourceSets.getByName("test").kotlin.srcDir("src/testGms/kotlin")
}
if (isFDroidBuild) {
// Swap in the manifest that also declares the public-storage permissions.
sourceSets.getByName("main").manifest.srcFile("src/fdroid/AndroidManifest.xml")
@ -111,7 +128,8 @@ android {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
File("proguard-rules.pro")
rootProject.file("proguard-common.pro"),
file("proguard-rules.pro")
)
}
}

View file

@ -1,57 +1,8 @@
-dontobfuscate
# Phone-only rules. Rules shared with the wear app live in /proguard-common.pro.
# Keep exception class names and their constructors for proper stack traces
-keep class * extends java.lang.Exception { *; }
-keep class * extends java.lang.RuntimeException { *; }
-keep class * extends java.lang.Throwable { *; }
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
# Glance instantiates ActionCallback implementations reflectively via their no-arg
# constructor. Glance's own consumer rule keeps the class but not its members, so
# R8 strips the constructor and every widget tap throws NoSuchMethodException.
-keepclassmembers class * implements androidx.glance.appwidget.action.ActionCallback {
<init>();
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
# If you have any, uncomment and replace classes with those containing named companion objects.
#-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
#-if @kotlinx.serialization.Serializable class
#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions.
#com.example.myapplication.HasNamedCompanion2
#{
# static **$* *;
#}
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
# static <1>$$serializer INSTANCE;
#}
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java.
-dontwarn org.codehaus.mojo.animal_sniffer.*
# Logging
-keep class io.github.aakira.napier.** { *; }
-dontwarn org.slf4j.**
-assumenosideeffects class org.slf4j.Logger {
public void trace(...);
public void debug(...);
}

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
@ -15,6 +16,7 @@ import com.darkrockstudios.apps.hammer.common.data.ClientResult
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.projectsrepository.ProjectsRepository
import org.koin.java.KoinJavaComponent.getKoin
import java.io.IOException
/**
* Shared helpers for editor instrumented tests: seed a project through the real Koin graph,
@ -30,7 +32,10 @@ object EditorTestHarness {
/** Create a uniquely-named project so re-runs don't collide; returns its def. */
fun seedProject(baseName: String): ProjectDef {
val result = repository().createProject("$baseName ${System.currentTimeMillis()}")
val result = repository().createProject(
"$baseName ${System.currentTimeMillis()}",
seedDefaultLanguage = true,
)
check(result is ClientResult.Success) { "Failed to seed project: $result" }
return result.data
}
@ -43,8 +48,37 @@ object EditorTestHarness {
scenario.onActivity { it.finish() }
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
scenario.close()
repository().deleteProject(projectDef)
deleteProjectWhenSettled(projectDef)
}
/**
* Delete the project directory, tolerating writes that are still landing.
*
* finish() and waitForIdleSync() only drain the main thread, but the project-scope close
* flushes scene buffers on IO coroutines after that. A straggler write can land between
* deleteRecursively emptying the directory and removing it, so the final rmdir fails with
* "failed to delete <project dir>". Each retry deletes whatever reappeared, so this
* converges as soon as the flushes stop.
*/
fun deleteProjectWhenSettled(projectDef: ProjectDef, timeoutMillis: Long = 15_000L) {
val deadline = SystemClock.uptimeMillis() + timeoutMillis
var lastFailure: IOException? = null
while (SystemClock.uptimeMillis() < deadline) {
try {
repository().deleteProject(projectDef)
return
} catch (e: IOException) {
lastFailure = e
SystemClock.sleep(DELETE_RETRY_INTERVAL_MS)
}
}
throw AssertionError(
"Project directory was still being written to ${timeoutMillis}ms after teardown began",
lastFailure,
)
}
private const val DELETE_RETRY_INTERVAL_MS = 100L
}
/** Matches nodes whose testTag starts with [prefix] - for list items keyed by an unknown id. */
@ -67,6 +101,57 @@ fun ComposeTestRule.navigateTo(navTag: String) {
onNodeWithTag(navTag).performClick()
}
/**
* Click the first node matching [matcher] and wait for [landed] to confirm the click took effect.
*
* A single injected tap is occasionally dropped before `clickable` resolves it into an onClick,
* which leaves the screen unchanged and reports no error. Re-inject until the expected outcome
* appears rather than trusting one injection.
*
* Fails if [matcher] never resolves, and never reports success without having clicked: a [landed]
* that is already true on entry would otherwise pass the step without touching the target.
*/
fun ComposeTestRule.clickUntil(
matcher: SemanticsMatcher,
timeoutMillis: Long = 10_000L,
landed: () -> Boolean,
) {
val deadline = SystemClock.uptimeMillis() + timeoutMillis
var clicked = false
while (true) {
// Only re-click while the target is still on screen; once it's gone the click did land and
// the screen is mid-change, so clicking again would hit whatever replaced it.
if (onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()) {
onAllNodes(matcher).onFirst().performClick()
clicked = true
}
if (!clicked) {
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError(
"'${matcher.description}' never appeared within ${timeoutMillis}ms, so it was never clicked",
)
}
SystemClock.sleep(CLICK_RETRY_INTERVAL_MS)
continue
}
try {
waitUntil(timeoutMillis = 1_000L) { landed() }
return
} catch (e: ComposeTimeoutException) {
if (SystemClock.uptimeMillis() >= deadline) {
throw AssertionError(
"Click on '${matcher.description}' never took effect within ${timeoutMillis}ms",
e,
)
}
}
}
}
private const val CLICK_RETRY_INTERVAL_MS = 100L
/**
* Type into the tagged markdown/scene editor and wait for the edit to land.
*

View file

@ -66,8 +66,7 @@ class EncyclopediaWorkflowTest {
composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().assertIsDisplayed()
// Opening it leaves the browse list - the create FAB only shows there.
composeRule.onAllNodes(hasTestTagPrefix("encyclopedia-entry-")).onFirst().performClick()
composeRule.waitUntil(10_000) {
composeRule.clickUntil(hasTestTagPrefix("encyclopedia-entry-")) {
composeRule.onAllNodesWithTag(ENCYCLOPEDIA_CREATE_FAB_TAG).fetchSemanticsNodes().isEmpty()
}
}

View file

@ -3,10 +3,10 @@ import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
@ -44,12 +44,11 @@ class ProjectLifecycleTest {
@Test
fun createProjectThenOpenIt() {
// 1. Open the create dialog (masthead button when wide, bottom bar when narrow).
composeRule.onNodeWithTag(CreateProjectButtonTestTag).performClick()
// 2. Type the project name into the dialog's only editable field, then confirm.
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.clickUntil(hasTestTag(CreateProjectButtonTestTag)) {
composeRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isNotEmpty()
}
// 2. Type the project name into the dialog's only editable field, then confirm.
composeRule.onNode(hasSetTextAction()).performTextInput(projectName)
// The confirm button and the dialog title both read "Create Project";
@ -89,6 +88,6 @@ class ProjectLifecycleTest {
@After
fun deleteCreatedProject() {
val repository = getKoin().get<ProjectsRepository>()
repository.deleteProject(repository.getProjectDefinition(projectName))
EditorTestHarness.deleteProjectWhenSettled(repository.getProjectDefinition(projectName))
}
}

View file

@ -1,7 +1,7 @@
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ActivityScenario
@ -44,12 +44,10 @@ class SceneEditorWorkflowTest {
composeRule.navigateTo(NAV_EDITOR_TAG)
// Create a scene and open it.
composeRule.waitUntil(10_000) {
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty()
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) {
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_SCENE_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick()
composeRule.onNodeWithTag(SCENE_LIST_ADD_SCENE_TAG).performClick()
composeRule.waitUntil(10_000) {
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_SCENE_TAG)) {
composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput("Opening")
@ -65,8 +63,7 @@ class SceneEditorWorkflowTest {
}
// Saving clears the dirty buffer, so the save action disappears.
composeRule.onNodeWithTag(SCENE_EDITOR_SAVE_TAG).performClick()
composeRule.waitUntil(10_000) {
composeRule.clickUntil(hasTestTag(SCENE_EDITOR_SAVE_TAG)) {
composeRule.onAllNodesWithTag(SCENE_EDITOR_SAVE_TAG).fetchSemanticsNodes().isEmpty()
}
}

View file

@ -1,9 +1,9 @@
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ActivityScenario
@ -62,12 +62,10 @@ class SceneListWorkflowTest {
// Open the add menu, pick scene/group, type a name, submit via the IME action.
private fun addItem(menuItemTag: String, name: String) {
composeRule.waitUntil(10_000) {
composeRule.onAllNodesWithTag(SCENE_LIST_ADD_BUTTON_TAG).fetchSemanticsNodes().isNotEmpty()
composeRule.clickUntil(hasTestTag(SCENE_LIST_ADD_BUTTON_TAG)) {
composeRule.onAllNodesWithTag(menuItemTag).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(SCENE_LIST_ADD_BUTTON_TAG).performClick()
composeRule.onNodeWithTag(menuItemTag).performClick()
composeRule.waitUntil(10_000) {
composeRule.clickUntil(hasTestTag(menuItemTag)) {
composeRule.onAllNodesWithTag(CREATE_ITEM_NAME_FIELD_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(CREATE_ITEM_NAME_FIELD_TAG).performTextInput(name)

View file

@ -1,4 +1,5 @@
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
@ -42,14 +43,8 @@ class TimelineWorkflowTest {
fun createEventThenOpenIt() {
composeRule.navigateTo(NAV_TIMELINE_TAG)
// Open the create screen via the FAB.
composeRule.waitUntil(10_000) {
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(TIME_LINE_CREATE_TAG).performClick()
// Content is optional, so a date alone persists an event.
composeRule.waitUntil(10_000) {
// Open the create screen via the FAB. Content is optional, so a date alone persists an event.
composeRule.clickUntil(hasTestTag(TIME_LINE_CREATE_TAG)) {
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_DATE_TAG).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag(TIME_LINE_CREATE_DATE_TAG).performTextInput("Year One")
@ -62,8 +57,7 @@ class TimelineWorkflowTest {
composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].assertIsDisplayed()
// Opening the event leaves the overview - the create FAB only shows there.
composeRule.onAllNodesWithTag(EVENT_CARD_TAG)[0].performClick()
composeRule.waitUntil(10_000) {
composeRule.clickUntil(hasTestTag(EVENT_CARD_TAG)) {
composeRule.onAllNodesWithTag(TIME_LINE_CREATE_TAG).fetchSemanticsNodes().isEmpty()
}
}

View file

@ -30,6 +30,7 @@
<application
android:name=".HammerApplication"
android:allowBackup="true"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View file

@ -0,0 +1,6 @@
package com.darkrockstudios.apps.hammer.android
import com.darkrockstudios.apps.hammer.android.wear.wearPairingModule
import org.koin.core.module.Module
val playServicesModules: List<Module> = listOf(wearPairingModule)

View file

@ -0,0 +1,148 @@
package com.darkrockstudios.apps.hammer.android.wear
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.app.NotificationManagerCompat
import com.darkrockstudios.apps.hammer.android.R
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore
import com.darkrockstudios.apps.hammer.common.data.globalsettings.UiTheme
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
class WearPairingActivity : ComponentActivity(), KoinComponent {
private val handler: WearPairingHandler by inject()
private val globalSettingsStore: GlobalSettingsStore by inject()
private val appScope: CoroutineScope by inject(named(APP_SCOPE))
private var nodeId: String? = null
private var request: PairRequest? = null
private var answered = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Drop touches delivered while another app's window overlays this dialog, so a tapjacking
// overlay can't approve a pairing on the user's behalf.
window.decorView.filterTouchesWhenObscured = true
setFinishOnTouchOutside(false)
window.setBackgroundDrawableResource(android.R.color.transparent)
val sourceNode = intent.getStringExtra(EXTRA_NODE_ID)
val pairRequest = intent.getByteArrayExtra(EXTRA_REQUEST)?.let(PairingProtocol::decodeRequest)
if (sourceNode == null || pairRequest == null) {
finish()
return
}
nodeId = sourceNode
request = pairRequest
NotificationManagerCompat.from(this).cancel(notificationId(pairRequest.requestId))
val accountEmail = if (handler.isSignedIn()) globalSettingsStore.serverSettings?.email else null
val settings = globalSettingsStore.globalSettings
setContent {
val isDark = when (settings.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}
BackHandler { decline() }
AppTheme(settings = settings, useDarkTheme = isDark) {
Box(
modifier = Modifier
.fillMaxWidth()
.systemBarsPadding()
.padding(Ui.Padding.M),
contentAlignment = Alignment.Center,
) {
WearPairingDialog(
deviceLabel = pairRequest.deviceLabel,
accountEmail = accountEmail,
onApprove = ::approve,
onDecline = ::decline,
)
}
}
}
}
// The reply runs in the app scope so it still reaches the watch after this dialog closes.
private fun approve() {
val sourceNode = nodeId ?: return
val pairRequest = request ?: return
answered = true
val appContext = applicationContext
appScope.launch {
val response = handler.approve(sourceNode, pairRequest)
val message = when (response) {
is PairResponse.Success -> R.string.wear_pairing_toast_success
is PairResponse.Error -> R.string.wear_pairing_toast_failure
}
if (handler.isSignedIn()) {
withContext(Dispatchers.Main) {
Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show()
}
}
}
finish()
}
private fun decline() {
val sourceNode = nodeId ?: return
val pairRequest = request ?: return
answered = true
appScope.launch { handler.decline(sourceNode, pairRequest) }
finish()
}
override fun onDestroy() {
super.onDestroy()
// Dismissed without an answer: tell the watch rather than leave it waiting. Only when finishing:
// a backgrounded instance the system reclaims comes back with the prompt still to answer.
if (!answered && isFinishing) {
decline()
}
}
companion object {
private const val NOTIFICATION_ID_BASE = 7301
private const val EXTRA_NODE_ID = "node_id"
private const val EXTRA_REQUEST = "request"
/** Per request, so two watches asking at once do not overwrite each other's prompt. */
fun notificationId(requestId: String): Int = NOTIFICATION_ID_BASE + (requestId.hashCode() and 0xFFFF)
fun createIntent(context: Context, nodeId: String, requestPayload: ByteArray): Intent =
Intent(context, WearPairingActivity::class.java)
.putExtra(EXTRA_NODE_ID, nodeId)
.putExtra(EXTRA_REQUEST, requestPayload)
}
}

View file

@ -0,0 +1,102 @@
package com.darkrockstudios.apps.hammer.android.wear
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.darkrockstudios.apps.hammer.android.R
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdFolioDivider
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdHairlineButton
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdMasthead
private val DialogMaxWidth = 480.dp
/** [accountEmail] is null when this phone is not signed in to a sync server. */
@Composable
fun WearPairingDialog(
deviceLabel: String,
accountEmail: String?,
onApprove: () -> Unit,
onDecline: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.widthIn(max = DialogMaxWidth)
.fillMaxWidth(),
shape = RectangleShape,
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.outlineVariant),
) {
Column {
HdMasthead(
section = stringResource(R.string.wear_pairing_masthead),
leadingMeta = listOf(deviceLabel),
)
HdFolioDivider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Ui.Padding.XL),
verticalArrangement = Arrangement.spacedBy(Ui.Padding.L),
) {
if (accountEmail != null) {
Text(
text = stringResource(R.string.wear_pairing_title, deviceLabel),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.wear_pairing_body, accountEmail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
HdHairlineButton(
label = stringResource(R.string.wear_pairing_decline),
onClick = onDecline,
)
HdHairlineButton(
label = stringResource(R.string.wear_pairing_approve),
emphasised = true,
onClick = onApprove,
)
}
} else {
Text(
text = stringResource(R.string.wear_pairing_not_signed_in),
style = MaterialTheme.typography.bodyLarge,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
// Approving here answers the watch with NotSignedIn rather than a bare decline.
HdHairlineButton(
label = stringResource(R.string.wear_pairing_close),
onClick = onApprove,
)
}
}
}
}
}
}

View file

@ -0,0 +1,75 @@
package com.darkrockstudios.apps.hammer.android.wear
import com.darkrockstudios.apps.hammer.common.data.CResult
import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings
import com.darkrockstudios.apps.hammer.common.data.isSuccess
import com.darkrockstudios.apps.hammer.common.data.pairing.PairErrorCode
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
import io.github.aakira.napier.Napier
import kotlinx.coroutines.CancellationException
/** Answers a watch's pairing request once the user has approved or declined it on the phone. */
class WearPairingHandler(
private val serverSettings: () -> ServerSettings?,
private val pairInstall: suspend (installId: String) -> CResult<ServerSettings>,
private val responder: PairingResponder,
) {
fun isSignedIn(): Boolean {
val settings = serverSettings() ?: return false
return settings.userId >= 0 && !settings.bearerToken.isNullOrBlank()
}
suspend fun approve(nodeId: String, request: PairRequest): PairResponse {
val response = when {
request.version != PairingProtocol.VERSION ->
PairResponse.Error(request.requestId, PairErrorCode.Unsupported)
!isSignedIn() ->
PairResponse.Error(request.requestId, PairErrorCode.NotSignedIn)
else -> {
val result = pairInstall(request.installId)
if (isSuccess(result)) {
PairResponse.Success(request.requestId, result.data)
} else {
PairResponse.Error(request.requestId, PairErrorCode.ServerRejected)
}
}
}
send(nodeId, response)
return response
}
suspend fun decline(nodeId: String, request: PairRequest): PairResponse {
val response = PairResponse.Error(request.requestId, PairErrorCode.Declined)
send(nodeId, response)
return response
}
/**
* Replies to a request this phone could not read, so the watch stops waiting. The request id is
* unknown precisely because the payload could not be decoded, so this answers with a blank one,
* which the watch matches against whatever it has pending.
*/
suspend fun rejectUnreadable(nodeId: String) {
send(nodeId, PairResponse.Error(requestId = "", code = PairErrorCode.Unsupported))
}
/** Replies when the confirmation cannot be shown, so the watch can tell the user what to do. */
suspend fun reportUnavailable(nodeId: String, request: PairRequest) {
send(nodeId, PairResponse.Error(request.requestId, PairErrorCode.PhoneUnavailable))
}
private suspend fun send(nodeId: String, response: PairResponse) {
try {
responder.respond(nodeId, response)
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
// The watch may have gone out of range; it can retry from its own screen.
Napier.w("Failed to reply to the watch", e)
}
}
}

View file

@ -0,0 +1,100 @@
package com.darkrockstudios.apps.hammer.android.wear
import android.Manifest
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import com.darkrockstudios.apps.hammer.android.R
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
import com.darkrockstudios.apps.hammer.common.dependencyinjection.APP_SCOPE
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
import io.github.aakira.napier.Napier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
/**
* Receives a watch's pairing request. The user must approve it on the phone, so this only brings
* up [WearPairingActivity]: directly when Hammer is on screen, through a notification otherwise,
* since Android blocks activity starts from the background.
*/
class WearPairingListenerService : WearableListenerService(), KoinComponent {
private val handler: WearPairingHandler by inject()
private val appScope: CoroutineScope by inject(named(APP_SCOPE))
override fun onMessageReceived(event: MessageEvent) {
if (event.path != PairingProtocol.REQUEST_PATH) return
val request = PairingProtocol.decodeRequest(event.data)
if (request == null) {
appScope.launch { handler.rejectUnreadable(event.sourceNodeId) }
return
}
val intent = WearPairingActivity.createIntent(this, event.sourceNodeId, event.data)
val appVisible = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
if (appVisible) {
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} else if (!showNotification(intent, request.requestId)) {
// Nothing can put the prompt in front of the user, so answer instead of leaving the
// watch waiting on a confirmation that will never appear.
appScope.launch { handler.reportUnavailable(event.sourceNodeId, request) }
}
}
/** False when the prompt could not be raised, so the caller can answer the watch instead. */
@SuppressLint("MissingPermission")
private fun showNotification(intent: Intent, requestId: String): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
Napier.w("Cannot show the watch pairing prompt: notification permission not granted")
return false
}
val notificationId = WearPairingActivity.notificationId(requestId)
val manager = NotificationManagerCompat.from(this)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.wear_pairing_channel_name))
.build()
)
val pendingIntent = PendingIntent.getActivity(
this,
notificationId,
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(getString(R.string.wear_pairing_notification_title))
.setContentText(getString(R.string.wear_pairing_notification_text))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
return try {
manager.notify(notificationId, notification)
true
} catch (@Suppress("SwallowedException") e: SecurityException) {
Napier.w("Cannot show the watch pairing prompt", e)
false
}
}
private companion object {
const val CHANNEL_ID = "wear_pairing"
}
}

View file

@ -0,0 +1,21 @@
package com.darkrockstudios.apps.hammer.android.wear
import android.content.Context
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
import com.darkrockstudios.apps.hammer.common.data.pairing.PairingProtocol
import com.google.android.gms.wearable.Wearable
import kotlinx.coroutines.tasks.await
interface PairingResponder {
suspend fun respond(nodeId: String, response: PairResponse)
}
class WearPairingResponder(context: Context) : PairingResponder {
private val messageClient = Wearable.getMessageClient(context)
override suspend fun respond(nodeId: String, response: PairResponse) {
messageClient
.sendMessage(nodeId, PairingProtocol.RESPONSE_PATH, PairingProtocol.encodeResponse(response))
.await()
}
}

View file

@ -0,0 +1,40 @@
package com.darkrockstudios.apps.hammer.android.wear.preview
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.darkrockstudios.apps.hammer.android.wear.WearPairingDialog
import com.darkrockstudios.apps.hammer.common.compose.Ui
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
private val previewSettings = GlobalSettings(projectsDirectory = "/projects")
@Preview
@Composable
private fun WearPairingDialogSignedInPreview() {
AppTheme(settings = previewSettings, useDarkTheme = false) {
WearPairingDialog(
deviceLabel = "Pixel Watch 3",
accountEmail = "writer@example.com",
onApprove = {},
onDecline = {},
modifier = Modifier.padding(Ui.Padding.M),
)
}
}
@Preview
@Composable
private fun WearPairingDialogNotSignedInDarkPreview() {
AppTheme(settings = previewSettings, useDarkTheme = true) {
WearPairingDialog(
deviceLabel = "Pixel Watch 3",
accountEmail = null,
onApprove = {},
onDecline = {},
modifier = Modifier.padding(Ui.Padding.M),
)
}
}

View file

@ -0,0 +1,19 @@
package com.darkrockstudios.apps.hammer.android.wear
import com.darkrockstudios.apps.hammer.common.data.account.AccountUseCase
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettingsStore
import org.koin.core.module.Module
import org.koin.dsl.module
val wearPairingModule: Module = module {
single<PairingResponder> { WearPairingResponder(context = get()) }
single {
val globalSettingsStore: GlobalSettingsStore = get()
val accountUseCase: AccountUseCase = get()
WearPairingHandler(
serverSettings = { globalSettingsStore.serverSettings },
pairInstall = accountUseCase::pairInstall,
responder = get(),
)
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Thar be dragons: only the Wearable Data Layer reads this array, by name, at runtime, so
resource shrinking drops it unless it is kept here. A keep attribute is honoured only in a
res/raw keep file; one on the values file itself is silently ignored, and the watch then
never finds the phone.
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@array/android_wear_capabilities" />

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Advertised to the Wearable Data Layer so the watch app can find this phone. Kept from
resource shrinking by res/raw/keep.xml. -->
<string-array name="android_wear_capabilities" translatable="false">
<item>hammer_phone_pairing</item>
</string-array>
</resources>

View file

@ -5,10 +5,13 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY"/>
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Watch pairing prompt. Play services only: not mirrored in src/fdroid/AndroidManifest.xml. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".HammerApplication"
android:allowBackup="true"
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@ -20,6 +23,7 @@
<activity
android:name=".ProjectSelectActivity"
android:theme="@style/Theme.Hammer.Starting"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@ -100,6 +104,26 @@
android:resource="@xml/story_info_widget_info"/>
</receiver>
<!-- Wear OS pairing. Play services only: not mirrored in src/fdroid/AndroidManifest.xml. -->
<activity
android:name=".wear.WearPairingActivity"
android:excludeFromRecents="true"
android:exported="false"
android:theme="@style/Theme.AppCompat.DayNight.Dialog"
android:windowSoftInputMode="adjustResize"/>
<service
android:name=".wear.WearPairingListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED"/>
<data
android:host="*"
android:pathPrefix="/hammer/pair/request"
android:scheme="wear"/>
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View file

@ -16,16 +16,15 @@ import com.darkrockstudios.apps.hammer.android.shortcuts.shortcutsModule
import com.darkrockstudios.apps.hammer.android.widgets.AddNoteWidgetReceiver
import com.darkrockstudios.apps.hammer.android.widgets.StoriesListWidgetReceiver
import com.darkrockstudios.apps.hammer.android.widgets.StoryInfoWidgetReceiver
import com.darkrockstudios.apps.hammer.base.BuildMetadata
import com.darkrockstudios.apps.hammer.common.BuildConfig
import com.darkrockstudios.apps.hammer.common.FileLogger
import com.darkrockstudios.apps.hammer.common.data.migrator.DataMigrator
import com.darkrockstudios.apps.hammer.common.dependencyinjection.NapierLogger
import com.darkrockstudios.apps.hammer.common.dependencyinjection.appModule
import com.darkrockstudios.apps.hammer.common.dependencyinjection.imageLoadingModule
import com.darkrockstudios.apps.hammer.common.dependencyinjection.mainModule
import com.darkrockstudios.apps.hammer.common.getConfigDirectory
import com.darkrockstudios.apps.hammer.common.installGlobalExceptionHandler
import com.darkrockstudios.apps.hammer.common.logStartupBanner
import com.darkrockstudios.apps.hammer.common.platformStartupInfo
import com.darkrockstudios.apps.hammer.common.setExternalDirectories
import com.darkrockstudios.apps.hammer.common.setInternalDirectories
import com.darkrockstudios.apps.hammer.common.util.AndroidSettingsKeys
@ -41,7 +40,6 @@ import org.koin.android.ext.koin.androidContext
import org.koin.core.context.GlobalContext.startKoin
import org.koin.java.KoinJavaComponent
import org.koin.java.KoinJavaComponent.getKoin
import java.io.File
class HammerApplication : Application(), SingletonImageLoader.Factory {
@ -59,11 +57,13 @@ class HammerApplication : Application(), SingletonImageLoader.Factory {
logger(NapierLogger())
androidContext(this@HammerApplication)
modules(
mainModule,
imageLoadingModule,
aboutLibrariesModule,
shortcutsModule,
appModule(applicationScope)
listOf(
mainModule,
imageLoadingModule,
aboutLibrariesModule,
shortcutsModule,
appModule(applicationScope),
) + playServicesModules
)
}
@ -126,38 +126,6 @@ class HammerApplication : Application(), SingletonImageLoader.Factory {
}
}
/**
* Catch, log, and hand off any otherwise-unhandled exception. The async [FileLogger] can't be
* relied on to flush before the process dies, so we also write a synchronous crash dump straight
* to disk, then chain to the platform default handler so Android still terminates and reports.
*/
private fun installGlobalExceptionHandler() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
Napier.e(
"Uncaught exception on thread '${thread.name}', terminating",
throwable
)
}
runCatching { writeCrashDump(thread, throwable) }
previous?.uncaughtException(thread, throwable)
}
}
/** Synchronous, self-contained crash record in the logs dir — the guaranteed artifact when the app dies. */
private fun writeCrashDump(thread: Thread, throwable: Throwable) {
val dir = File(getConfigDirectory(), "logs")
dir.mkdirs()
File(dir, "crash-${System.currentTimeMillis()}.txt").writeText(
buildString {
append("Hammer v${BuildMetadata.APP_VERSION} | ${platformStartupInfo()}\n")
append("Uncaught exception on thread '${thread.name}'\n\n")
append(throwable.stackTraceToString())
}
)
}
override fun onTerminate() {
super.onTerminate()
applicationScope.cancel("Application onTerminate")

View file

@ -30,6 +30,7 @@ import com.darkrockstudios.apps.hammer.android.shortcuts.ProjectShortcutsManager
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectDeepLink
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRoot
import com.darkrockstudios.apps.hammer.common.components.projectroot.ProjectRootComponent
import com.darkrockstudios.apps.hammer.common.compose.ProjectShortcutHost
import com.darkrockstudios.apps.hammer.common.compose.theme.AppTheme
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.closeProjectScope
@ -70,6 +71,7 @@ class ProjectRootActivity : AppCompatActivity() {
private val viewModel: ProjectRootViewModel by viewModels()
private var projectRoot: ProjectRoot? = null
private val shortcutHost = ProjectShortcutHost()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -107,7 +109,7 @@ class ProjectRootActivity : AppCompatActivity() {
val settingsState by globalSettings.subscribeAsState()
val isDark = when (settingsState.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark -> true
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}
@ -185,14 +187,22 @@ class ProjectRootActivity : AppCompatActivity() {
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN &&
event.keyCode == KeyEvent.KEYCODE_F &&
event.isShiftPressed &&
event.isCtrlPressed
) {
projectRoot?.let {
it.showGlobalSearch()
return true
if (event.action == KeyEvent.ACTION_DOWN) {
// hasModifiers matches exactly, so AltGr chords fall through and type their character.
val ctrlShift = KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON
if (event.keyCode == KeyEvent.KEYCODE_F && event.hasModifiers(ctrlShift)) {
projectRoot?.let {
it.showGlobalSearch()
return true
}
}
if (event.keyCode == KeyEvent.KEYCODE_F3 && event.hasNoModifiers()) {
if (shortcutHost.startProjectSync()) return true
}
if (event.keyCode == KeyEvent.KEYCODE_S && event.hasModifiers(ctrlShift)) {
if (shortcutHost.saveAllBuffers()) return true
}
}
return super.dispatchKeyEvent(event)
@ -210,11 +220,12 @@ class ProjectRootActivity : AppCompatActivity() {
// Only intercept back when at Home AND there's potential unsaved work to confirm
// Otherwise, let Android's default back behavior close the activity
BackHandler(enabled = backEnabled && (component.hasUnsavedBuffers() || shouldConfirmClose.isNotEmpty())) {
val unsavedBuffers by component.unsavedBuffers.subscribeAsState()
BackHandler(enabled = backEnabled && (unsavedBuffers || shouldConfirmClose.isNotEmpty())) {
component.requestClose()
}
ProjectRootScaffold(component, onCloseRequest = ::finish)
ProjectRootScaffold(component, onCloseRequest = ::finish, shortcutHost = shortcutHost)
}
companion object {

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.retainedComponent
@ -43,6 +44,7 @@ class ProjectSelectActivity : AppCompatActivity() {
private var settingsUpdateJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@ -61,7 +63,7 @@ class ProjectSelectActivity : AppCompatActivity() {
val settingsState by globalSettings.subscribeAsState()
val isDark = when (settingsState.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark -> true
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}

View file

@ -18,6 +18,7 @@ import com.darkrockstudios.apps.hammer.common.components.projectselection.projec
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata
import com.darkrockstudios.apps.hammer.base.http.storyideas.StoryIdea
import com.darkrockstudios.apps.hammer.common.data.ImportOptions
import com.darkrockstudios.apps.hammer.common.data.appupdate.AppUpdateState
import com.darkrockstudios.apps.hammer.common.data.Msg
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.fileio.HPath
@ -92,16 +93,20 @@ val component = object : ProjectSelection {
override val navRailState: Value<ProjectSelection.NavRailState> =
MutableValue(ProjectSelection.NavRailState(expanded = false))
override val updateNotification: Value<ProjectSelection.UpdateNotificationState> =
MutableValue(ProjectSelection.UpdateNotificationState())
override val changelog: Value<ProjectSelection.ChangelogState> =
MutableValue(ProjectSelection.ChangelogState())
override val appUpdate: Value<AppUpdateState> = MutableValue(AppUpdateState.Unsupported)
override fun isAtRoot() = false
override fun onBack() {}
override fun showLocation(location: ProjectSelection.Locations) {}
override fun toggleNavRailExpanded() {}
override fun openReleaseUrl() {}
override fun dismissUpdateNotification(remember: Boolean) {}
override fun showCurrentReleaseDetails() {}
override fun openLatestRelease() {}
override fun dismissChangelog() {}
override fun showChangelog() {}
override fun updateApp() {}
override fun dismissUpdate() {}
override val backHandler = dummyBackHandler
}

View file

@ -97,7 +97,7 @@ class AddNoteActivity : ComponentActivity(), KoinComponent {
val settingsState by globalSettings.subscribeAsState()
val isDark = when (settingsState.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark -> true
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}

View file

@ -90,7 +90,7 @@ class AddNoteWidgetConfigActivity : ComponentActivity() {
val settingsState by globalSettings.subscribeAsState()
val isDark = when (settingsState.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark -> true
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}

View file

@ -88,7 +88,7 @@ class StoryInfoWidgetConfigActivity : ComponentActivity() {
val settingsState by globalSettings.subscribeAsState()
val isDark = when (settingsState.uiTheme) {
UiTheme.Light -> false
UiTheme.Dark -> true
UiTheme.Dark, UiTheme.OledBlack -> true
UiTheme.FollowSystem -> isSystemInDarkTheme()
}

View file

@ -30,7 +30,7 @@
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
<string name="story_info_widget_unconfigured">Tap to configure</string>
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
<string name="story_info_widget_byline">by %1$s</string>
<string name="story_info_widget_byline">von %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Ablehnen</string>
<string name="wear_pairing_close">Schließen</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -30,7 +30,7 @@
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
<string name="story_info_widget_unconfigured">Tap to configure</string>
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
<string name="story_info_widget_byline">by %1$s</string>
<string name="story_info_widget_byline">por %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Rechazar</string>
<string name="wear_pairing_close">Cerrar</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -25,24 +25,24 @@
<string name="note_widget_config_project_checkbox">Ajouter la Note au projet</string>
<string name="note_widget_config_project_checkbox_explained">Si aucun projet n\'est sélectionné, on vous demandera de choisir un projet à chaque fois.</string>
<string name="stories_list_widget_description">Hammer · Bibliothèque -- Ouvrir une histoire</string>
<string name="story_info_widget_description">Hammer · Histoire — aperçu des statistiques pour un projet</string>
<string name="story_info_widget_description">Hammer · Histoire — Aperçu des statistiques pour un projet</string>
<string name="story_info_widget_config_title">Widget d\'info pour une histoire :</string>
<string name="title_activity_story_info_widget_config">Configurer le Widget d\'info pour une histoire</string>
<string name="story_info_widget_unconfigured">Appuyez pour configurer</string>
<string name="story_info_widget_folio_header">§ %1$s · HISTOIRE</string>
<string name="story_info_widget_byline">de %1$s</string>
<string name="story_info_widget_words_label">MOTS</string>
<string name="story_info_widget_goal_summary">%1$d%% / %2$s %3$s</string>
<string name="story_info_widget_goal_summary">%1$d%% SUR %2$s %3$s</string>
<string name="story_info_widget_cadence_today">AUJOURD\'HUI</string>
<string name="story_info_widget_cadence_this_week">CETTE SEMAINE</string>
<string name="story_info_widget_today_words">+%1$d AUJOURD\'HUI</string>
<string name="story_info_widget_no_session_today">AUCUNE SESSION AUJOURD\'HUI</string>
<string name="story_info_widget_streak">SERIE DE %1$dJ</string>
<string name="story_info_widget_sessions_header">SESSIONS · 7J</string>
<string name="story_info_widget_week_total">%1$s SEM.</string>
<string name="story_info_widget_week_total">%1$s S</string>
<string name="story_info_widget_action_open">OUVRIR</string>
<string name="story_info_widget_action_note">NOTE</string>
<string name="story_info_widget_strip_folio">§ %1$s</string>
<string name="story_info_widget_strip_words_unit">M</string>
<string name="story_info_widget_continue_label">CONTINUER</string>
<string name="story_info_widget_continue_label">CONTINUER ·</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">APPAIRER LA MONTRE</string>
<string name="wear_pairing_title">Se connecter à %1$s sur Hammer ?</string>
<string name="wear_pairing_body">La montre sera synchronisée en tant que %1$s avec sa propre session. Vous pouvez la déconnecter depuis la montre à tout moment.</string>
<string name="wear_pairing_not_signed_in">Connectez-vous au serveur sur ce smartphone en premier, puis appairez la montre de nouveau.</string>
<string name="wear_pairing_approve">Appairer</string>
<string name="wear_pairing_decline">Refuser</string>
<string name="wear_pairing_close">Fermer</string>
<string name="wear_pairing_toast_success">Montre appairée</string>
<string name="wear_pairing_toast_failure">La montre n\'a pas pu être appairée</string>
<string name="wear_pairing_channel_name">Appairage de la montre</string>
<string name="wear_pairing_notification_title">Appairez votre montre</string>
<string name="wear_pairing_notification_text">Tapoter pour que votre montre se synchronise avec votre compte Hammer</string>
</resources>

View file

@ -1,48 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hammer</string>
<string name="title_activity_add_note">Add Note</string>
<string name="loading">Loading…</string>
<string name="title_activity_configure_note_widget">Configure Widget</string>
<string name="overflow_menu_button">More Menu</string>
<string name="navdrawer_button">Navigation Drawer</string>
<string name="note_widget_dialog_title">Add Note to:</string>
<string name="note_widget_dialog_save_button">Save</string>
<string name="note_widget_dialog_cancel_button">Cancel</string>
<string name="note_widget_dialog_projects_dropdown">Projects</string>
<string name="note_widget_dialog_failure_bad_project">Project not found: %1$s</string>
<string name="note_widget_confirm_cancel_title">Discard Note?</string>
<string name="note_widget_confirm_cancel_positive">Discard</string>
<string name="note_widget_confirm_cancel_negative">Cancel</string>
<string name="note_widget_button_description">Add Note</string>
<string name="note_widget_toast_no_projects">No projects</string>
<string name="note_widget_toast_success">Note Added</string>
<string name="action_add_note_short_label">Add Note</string>
<string name="action_add_note_long_label">Add Story Note</string>
<string name="action_add_note_disabled">Adding notes is disabled right now</string>
<string name="title_activity_add_note_widget_config">Configure Widget</string>
<string name="note_widget_config_title">Configure Note Widget:</string>
<string name="note_widget_config_project_checkbox">Add notes to specified project</string>
<string name="note_widget_config_project_checkbox_explained">If no project is selected, you will be asked to choose a project each time.</string>
<string name="stories_list_widget_description">Hammer · Library — open a story</string>
<string name="story_info_widget_description">Hammer · Story — at-a-glance stats for one project</string>
<string name="story_info_widget_config_title">Story Info Widget:</string>
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
<string name="story_info_widget_unconfigured">Tap to configure</string>
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
<string name="story_info_widget_byline">by %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>
<string name="story_info_widget_cadence_this_week">THIS WEEK</string>
<string name="story_info_widget_today_words">+%1$d TODAY</string>
<string name="story_info_widget_no_session_today">NO SESSION TODAY</string>
<string name="story_info_widget_streak">%1$dD STREAK</string>
<string name="story_info_widget_sessions_header">SESSIONS · 7D</string>
<string name="story_info_widget_week_total">%1$s W</string>
<string name="story_info_widget_action_open">OPEN</string>
<string name="story_info_widget_action_note">NOTE</string>
<string name="title_activity_add_note">Aggiungi nota</string>
<string name="loading">Caricamento…</string>
<string name="title_activity_configure_note_widget">Configura widget</string>
<string name="overflow_menu_button">Menu Altro</string>
<string name="navdrawer_button">Cassetto di navigazione</string>
<string name="note_widget_dialog_title">Aggiungi nota a:</string>
<string name="note_widget_dialog_save_button">Salva</string>
<string name="note_widget_dialog_cancel_button">Annulla</string>
<string name="note_widget_dialog_projects_dropdown">Progetti</string>
<string name="note_widget_dialog_failure_bad_project">Progetto non trovato: %1$s</string>
<string name="note_widget_confirm_cancel_title">Scartare la nota?</string>
<string name="note_widget_confirm_cancel_positive">Scarta</string>
<string name="note_widget_confirm_cancel_negative">Annulla</string>
<string name="note_widget_button_description">Aggiungi nota</string>
<string name="note_widget_toast_no_projects">Nessun progetto</string>
<string name="note_widget_toast_success">Nota aggiunta</string>
<string name="action_add_note_short_label">Aggiungi nota</string>
<string name="action_add_note_long_label">Aggiungi nota alla storia</string>
<string name="action_add_note_disabled">L\'aggiunta di note è disabilitata al momento</string>
<string name="title_activity_add_note_widget_config">Configura widget</string>
<string name="note_widget_config_title">Configura widget note:</string>
<string name="note_widget_config_project_checkbox">Aggiungi note al progetto specificato</string>
<string name="note_widget_config_project_checkbox_explained">Se non viene selezionato alcun progetto, ti verrà chiesto di sceglierne uno ogni volta.</string>
<string name="stories_list_widget_description">Hammer · Libreria — apri una storia</string>
<string name="story_info_widget_description">Hammer · Storia — statistiche rapide per un progetto</string>
<string name="story_info_widget_config_title">Widget info storia:</string>
<string name="title_activity_story_info_widget_config">Configura widget info storia</string>
<string name="story_info_widget_unconfigured">Tocca per configurare</string>
<string name="story_info_widget_folio_header">§ %1$s · STORIA</string>
<string name="story_info_widget_byline">di %1$s</string>
<string name="story_info_widget_words_label">PAROLE</string>
<string name="story_info_widget_goal_summary">%1$d%% DI %2$s %3$s</string>
<string name="story_info_widget_cadence_today">OGGI</string>
<string name="story_info_widget_cadence_this_week">QUESTA SETTIMANA</string>
<string name="story_info_widget_today_words">+%1$d OGGI</string>
<string name="story_info_widget_no_session_today">NESSUNA SESSIONE OGGI</string>
<string name="story_info_widget_streak">SERIE DI %1$dG</string>
<string name="story_info_widget_sessions_header">SESSIONI · 7G</string>
<string name="story_info_widget_week_total">%1$s P</string>
<string name="story_info_widget_action_open">APRI</string>
<string name="story_info_widget_action_note">NOTA</string>
<string name="story_info_widget_strip_folio">§ %1$s</string>
<string name="story_info_widget_strip_words_unit">W</string>
<string name="story_info_widget_continue_label">CONTINUE ·</string>
<string name="story_info_widget_strip_words_unit">P</string>
<string name="story_info_widget_continue_label">CONTINUA ·</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">ASSOCIA OROLOGIO</string>
<string name="wear_pairing_title">Accedere come %1$s su Hammer?</string>
<string name="wear_pairing_body">L’orologio si sincronizzerà come %1$s con una propria sessione. Puoi disconnetterlo dall’orologio in qualsiasi momento.</string>
<string name="wear_pairing_not_signed_in">Accedi prima a un server di sincronizzazione su questo telefono, poi associa nuovamente l’orologio.</string>
<string name="wear_pairing_approve">Associa</string>
<string name="wear_pairing_decline">Rifiuta</string>
<string name="wear_pairing_close">Chiudi</string>
<string name="wear_pairing_toast_success">Orologio associato</string>
<string name="wear_pairing_toast_failure">Impossibile associare l’orologio</string>
<string name="wear_pairing_channel_name">Associazione orologio</string>
<string name="wear_pairing_notification_title">Associa il tuo orologio</string>
<string name="wear_pairing_notification_text">Tocca per consentire all’orologio di sincronizzarsi con il tuo account Hammer</string>
</resources>

View file

@ -30,7 +30,7 @@
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
<string name="story_info_widget_unconfigured">Tap to configure</string>
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
<string name="story_info_widget_byline">by %1$s</string>
<string name="story_info_widget_byline">por %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Recusar</string>
<string name="wear_pairing_close">Fechar</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hammer</string>
<string name="title_activity_add_note">Not Ekle</string>
<string name="loading">Yükleniyor…</string>
<string name="title_activity_configure_note_widget">Widget\'ı Yapılandır</string>
<string name="overflow_menu_button">Diğer Menüler</string>
<string name="navdrawer_button">Gezinti Çekmecesi</string>
<string name="note_widget_dialog_title">Notu Şuraya Ekle:</string>
<string name="note_widget_dialog_save_button">Kaydet</string>
<string name="note_widget_dialog_cancel_button">İptal</string>
<string name="note_widget_dialog_projects_dropdown">Projeler</string>
<string name="note_widget_dialog_failure_bad_project">Proje bulunamadı: %1$s</string>
<string name="note_widget_confirm_cancel_title">Nottan Vazgeçilsin mi?</string>
<string name="note_widget_confirm_cancel_positive">Vazgeç</string>
<string name="note_widget_confirm_cancel_negative">İptal</string>
<string name="note_widget_button_description">Not Ekle</string>
<string name="note_widget_toast_no_projects">Proje yok</string>
<string name="note_widget_toast_success">Not Eklendi</string>
<string name="action_add_note_short_label">Not Ekle</string>
<string name="action_add_note_long_label">Hikâye Notu Ekle</string>
<string name="action_add_note_disabled">Not ekleme şu anda devre dışı</string>
<string name="title_activity_add_note_widget_config">Widget\'ı Yapılandır</string>
<string name="note_widget_config_title">Not Widget\'ını yapılandır:</string>
<string name="note_widget_config_project_checkbox">Notları belirtilen projeye ekle</string>
<string name="note_widget_config_project_checkbox_explained">Eğer seçili bir proje yoksa her seferinde bir proje seçmen istenir.</string>
<string name="stories_list_widget_description">Hammer · Kütüphane — bir hikâyeyi aç</string>
<string name="story_info_widget_description">Hammer · Hikâye — bir proje için özet istatistikler</string>
<string name="story_info_widget_config_title">Hikâye Bilgisi Widget\'ı:</string>
<string name="title_activity_story_info_widget_config">Hikâye Bilgisi Widget\'ını Yapılandır</string>
<string name="story_info_widget_unconfigured">Yapılandırmak için dokun</string>
<string name="story_info_widget_folio_header">§ %1$s · HİKÂYE</string>
<string name="story_info_widget_byline">- %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>
<string name="story_info_widget_cadence_this_week">THIS WEEK</string>
<string name="story_info_widget_today_words">+%1$d TODAY</string>
<string name="story_info_widget_no_session_today">NO SESSION TODAY</string>
<string name="story_info_widget_streak">%1$dD STREAK</string>
<string name="story_info_widget_sessions_header">SESSIONS · 7D</string>
<string name="story_info_widget_week_total">%1$s W</string>
<string name="story_info_widget_action_open">OPEN</string>
<string name="story_info_widget_action_note">NOTE</string>
<string name="story_info_widget_strip_folio">§ %1$s</string>
<string name="story_info_widget_strip_words_unit">W</string>
<string name="story_info_widget_continue_label">CONTINUE ·</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Decline</string>
<string name="wear_pairing_close">Close</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -7,13 +7,13 @@
<string name="overflow_menu_button">More Menu</string>
<string name="navdrawer_button">Navigation Drawer</string>
<string name="note_widget_dialog_title">Add Note to:</string>
<string name="note_widget_dialog_save_button">Save</string>
<string name="note_widget_dialog_cancel_button">Cancel</string>
<string name="note_widget_dialog_projects_dropdown">Projects</string>
<string name="note_widget_dialog_save_button">Зберегти</string>
<string name="note_widget_dialog_cancel_button">Скасувати</string>
<string name="note_widget_dialog_projects_dropdown">Проєкти</string>
<string name="note_widget_dialog_failure_bad_project">Project not found: %1$s</string>
<string name="note_widget_confirm_cancel_title">Discard Note?</string>
<string name="note_widget_confirm_cancel_positive">Discard</string>
<string name="note_widget_confirm_cancel_negative">Cancel</string>
<string name="note_widget_confirm_cancel_positive">Відхилити</string>
<string name="note_widget_confirm_cancel_negative">Скасувати</string>
<string name="note_widget_button_description">Add Note</string>
<string name="note_widget_toast_no_projects">No projects</string>
<string name="note_widget_toast_success">Note Added</string>
@ -30,7 +30,7 @@
<string name="title_activity_story_info_widget_config">Configure Story Info Widget</string>
<string name="story_info_widget_unconfigured">Tap to configure</string>
<string name="story_info_widget_folio_header">§ %1$s · STORY</string>
<string name="story_info_widget_byline">by %1$s</string>
<string name="story_info_widget_byline">автор: %1$s</string>
<string name="story_info_widget_words_label">WORDS</string>
<string name="story_info_widget_goal_summary">%1$d%% OF %2$s %3$s</string>
<string name="story_info_widget_cadence_today">TODAY</string>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Відхилити</string>
<string name="wear_pairing_close">Закрити</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wear_pairing_masthead">PAIR WATCH</string>
<string name="wear_pairing_title">Sign in %1$s to Hammer?</string>
<string name="wear_pairing_body">The watch will sync as %1$s with a session of its own. You can sign it out from the watch at any time.</string>
<string name="wear_pairing_not_signed_in">Sign in to a sync server on this phone first, then pair the watch again.</string>
<string name="wear_pairing_approve">Pair</string>
<string name="wear_pairing_decline">Decline</string>
<string name="wear_pairing_close">Close</string>
<string name="wear_pairing_toast_success">Watch paired</string>
<string name="wear_pairing_toast_failure">The watch could not be paired</string>
<string name="wear_pairing_channel_name">Watch pairing</string>
<string name="wear_pairing_notification_title">Pair your watch</string>
<string name="wear_pairing_notification_text">Tap to let your watch sync with your Hammer account</string>
</resources>

View file

@ -2,4 +2,10 @@
<resources>
<style name="Theme.Hammer" parent="Theme.AppCompat.DayNight.NoActionBar">
</style>
</resources>
<style name="Theme.Hammer.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.Hammer</item>
</style>
</resources>

View file

@ -3,4 +3,5 @@
<exclude domain="file" path="HammerProjects/server.json"/>
<exclude domain="file" path="HammerProjects/sync.json"/>
<exclude domain="file" path="HammerProjects/.backups"/>
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
</full-backup-content>

View file

@ -4,10 +4,12 @@
<exclude domain="file" path="HammerProjects/server.json"/>
<exclude domain="file" path="HammerProjects/sync.json"/>
<exclude domain="file" path="HammerProjects/.backups"/>
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
</cloud-backup>
<device-transfer>
<exclude domain="file" path="HammerProjects/server.json"/>
<exclude domain="file" path="HammerProjects/sync.json"/>
<exclude domain="file" path="HammerProjects/.backups"/>
<exclude domain="sharedpref" path="hammer_auth_tokens.xml"/>
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,5 @@
package com.darkrockstudios.apps.hammer.android
import org.koin.core.module.Module
val playServicesModules: List<Module> = emptyList()

View file

@ -0,0 +1,100 @@
package com.darkrockstudios.apps.hammer.android.wear
import com.darkrockstudios.apps.hammer.common.data.CResult
import com.darkrockstudios.apps.hammer.common.data.globalsettings.ServerSettings
import com.darkrockstudios.apps.hammer.common.data.pairing.PairErrorCode
import com.darkrockstudios.apps.hammer.common.data.pairing.PairRequest
import com.darkrockstudios.apps.hammer.common.data.pairing.PairResponse
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class WearPairingHandlerTest {
private class RecordingResponder : PairingResponder {
val sent = mutableListOf<Pair<String, PairResponse>>()
override suspend fun respond(nodeId: String, response: PairResponse) {
sent += nodeId to response
}
}
private val phoneSettings = ServerSettings(
url = "hammer.ink",
email = "writer@example.com",
userId = 7,
bearerToken = "phone-auth",
refreshToken = "phone-refresh",
)
private val watchSettings = phoneSettings.copy(bearerToken = "watch-auth", refreshToken = "watch-refresh")
private val request = PairRequest(requestId = "req-1", installId = "watch-install", deviceLabel = "Pixel Watch")
private val responder = RecordingResponder()
private val pairedInstalls = mutableListOf<String>()
private fun handler(
settings: ServerSettings? = phoneSettings,
pairResult: CResult<ServerSettings> = CResult.success(watchSettings),
) = WearPairingHandler(
serverSettings = { settings },
pairInstall = { installId ->
pairedInstalls += installId
pairResult
},
responder = responder,
)
@Test
fun `approving mints a session for the watch's install and sends it back`() = runTest {
val response = handler().approve("watch-node", request)
assertEquals(PairResponse.Success("req-1", watchSettings), response)
assertEquals(listOf("watch-install"), pairedInstalls)
assertEquals(listOf("watch-node" to response), responder.sent)
}
@Test
fun `a phone that is not signed in tells the watch so without calling the server`() = runTest {
val response = handler(settings = null).approve("watch-node", request)
assertEquals(PairResponse.Error("req-1", PairErrorCode.NotSignedIn), response)
assertEquals(emptyList<String>(), pairedInstalls)
assertEquals(listOf("watch-node" to response), responder.sent)
}
@Test
fun `a server rejection is reported to the watch`() = runTest {
val response = handler(pairResult = CResult.failure(error = "nope")).approve("watch-node", request)
assertEquals(PairResponse.Error("req-1", PairErrorCode.ServerRejected), response)
assertEquals(listOf("watch-node" to response), responder.sent)
}
@Test
fun `declining tells the watch without minting anything`() = runTest {
val response = handler().decline("watch-node", request)
assertEquals(PairResponse.Error("req-1", PairErrorCode.Declined), response)
assertEquals(emptyList<String>(), pairedInstalls)
assertEquals(listOf("watch-node" to response), responder.sent)
}
@Test
fun `a phone that cannot raise the prompt tells the watch rather than going quiet`() = runTest {
handler().reportUnavailable("watch-node", request)
assertEquals(
listOf("watch-node" to PairResponse.Error("req-1", PairErrorCode.PhoneUnavailable)),
responder.sent,
)
assertEquals(emptyList<String>(), pairedInstalls)
}
@Test
fun `a request from a newer protocol version is refused`() = runTest {
val response = handler().approve("watch-node", request.copy(version = 99))
assertEquals(PairResponse.Error("req-1", PairErrorCode.Unsupported), response)
assertEquals(emptyList<String>(), pairedInstalls)
}
}

View file

@ -1,3 +1,5 @@
import com.darkrockstudios.build.distributionChannel
import com.darkrockstudios.build.packageFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@ -47,7 +49,6 @@ kotlin {
api(libs.koin.core)
implementation(libs.serialization.core)
implementation(libs.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.serialization.json)
//implementation("org.kotlincrypto.endians:endians:0.1.0")
//api("io.getstream:stream-result:1.1.0")
@ -77,6 +78,9 @@ buildConfig {
buildConfigField("String", "APP_VERSION", "\"${libs.versions.app.get()}\"")
buildConfigField("String", "DATA_VERSION", "\"${libs.versions.data.version.get()}\"")
buildConfigField("String", "CHANNEL", "\"${project.distributionChannel().token}\"")
buildConfigField("String", "PACKAGE_FORMAT", "\"${project.packageFormat().token}\"")
}
val GIT_TASK_NAME = "install-git-hooks"

View file

@ -0,0 +1,45 @@
package com.darkrockstudios.apps.hammer.base
/**
* The distribution vehicle this build was produced for, baked in at build time from
* `-Pchannel=<token>`. Branch on [current] for the rules that vary per vehicle: self-update
* (forbidden in the app stores, expected for the GitHub and AppImage builds), where "get the app"
* links point, external-payment policy, and sandbox-aware path resolution.
*
* Deliberately a flat enum rather than a set of capability flags. Every branch is a greppable
* `when` on this type, so each channel-conditional behaviour is findable in one search; flags can
* be added later if two channels start sharing a trait and branches begin to duplicate.
*
* Because every branch compiles into every build, a store-forbidden code path is physically
* present in a store binary even when unreachable. If a store ever objects to presence rather
* than behaviour, that is the trigger to promote that one case to a real source set.
*
* Kept in step with the build-side enum in `buildSrc/src/main/java/distributionChannel.kt`.
*/
enum class DistributionChannel(val token: String, val displayName: String) {
DEV("dev", "Development"),
GITHUB("github", "GitHub"),
PLAY("google-play", "Google Play"),
FDROID("fdroid", "F-Droid"),
SNAP("snap", "Snap"),
APPIMAGE("appimage", "AppImage"),
FLATHUB("flathub", "Flathub"),
MICROSOFT_STORE("ms-store", "Microsoft Store"),
MAC_APP_STORE("mac-app-store", "Mac App Store"),
IOS_APP_STORE("ios-app-store", "iOS App Store"),
;
companion object {
/**
* An unrecognised token falls back to [DEV] rather than throwing: the build validates the
* token, so reaching here with an unknown one means something is already wrong and taking
* down the app over a log-header string would be worse.
*/
val current: DistributionChannel =
entries.firstOrNull { it.token == BuildMetadata.CHANNEL } ?: DEV
/** The sandboxed, App Review facing desktop build. */
val isMacAppStore: Boolean
get() = current == MAC_APP_STORE
}
}

View file

@ -0,0 +1,30 @@
package com.darkrockstudios.apps.hammer.base
/**
* The installer format this build was packaged as, baked in at build time from `-Pformat=<token>`.
* The second axis next to [DistributionChannel]: the channel says which vehicle the build is for
* and whether it may update itself, the format says which installer artifact it came from. Dev
* runs and anything built without a packaging task report [NONE].
*
* Kept in step with the build-side enum in `buildSrc/src/main/java/packageFormat.kt`.
*/
enum class PackageFormat(val token: String) {
NONE("none"),
MSI("msi"),
EXE("exe"),
DEB("deb"),
RPM("rpm"),
DMG("dmg"),
PKG("pkg"),
APPIMAGE("appimage"),
MSIX("msix"),
SNAP("snap"),
FLATPAK("flatpak"),
;
companion object {
/** An unrecognised token falls back to [NONE] for the same reason [DistributionChannel.current] falls back to DEV. */
val current: PackageFormat =
entries.firstOrNull { it.token == BuildMetadata.PACKAGE_FORMAT } ?: NONE
}
}

View file

@ -1,7 +1,8 @@
package com.darkrockstudios.apps.hammer.base
const val GITHUB_URL = "https://github.com/Darkrock-Studios/hammer-editor/"
const val DISCORD_URL = "https://discord.gg/GTmgjZcupk"
const val RELEASES_LATEST_URL = "${GITHUB_URL}releases/latest"
const val DISCORD_URL = "https://discord.gg/49Kj5mMj6d"
const val REDDIT_URL = "https://www.reddit.com/r/hammereditor/"
const val VERSION_CHECK_URL = "https://api.github.com/repos/Darkrock-Studios/hammer-editor/releases/latest"
const val HAMMER_INK_URL = "https://hammer.ink/"

View file

@ -99,6 +99,7 @@ sealed interface ApiProjectEntity {
val tags: Set<String>,
val image: Image?,
val aliases: List<String> = emptyList(),
val excludeFromDictionary: Boolean = false,
) : ApiProjectEntity {
@Serializable
data class Image(
@ -114,6 +115,7 @@ sealed interface ApiProjectEntity {
tags = tags,
image = image,
aliases = aliases,
excludeFromDictionary = excludeFromDictionary,
)
}

View file

@ -8,6 +8,30 @@ data class HttpResponseError(
val error: String,
/** A translated message to display to the user */
val displayMessage: String,
/**
* Stable, machine-readable reason from [ApiErrorCode]. Null when the server predates
* error codes, or when the failure has no code worth branching on.
*/
val errorCode: String? = null,
) {
override fun toString() = displayMessage
}
/**
* Reasons a request failed, stable across releases and independent of the translated
* [HttpResponseError.displayMessage]. Plain strings rather than an enum so an unknown
* value from a newer server deserializes instead of throwing.
*/
object ApiErrorCode {
/** Wrong password, or no such account. Deliberately one code: see AccountsRepository.login. */
const val INVALID_CREDENTIALS = "invalid_credentials"
const val ACCOUNT_EXISTS = "account_exists"
const val ACCOUNT_PENDING_DELETION = "account_pending_deletion"
const val INVALID_EMAIL = "invalid_email"
// Wire error codes, not secrets.
const val PASSWORD_TOO_SHORT = "password_too_short" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
const val PASSWORD_TOO_LONG = "password_too_long" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
const val PASSWORD_INVALID = "password_invalid" // nosemgrep: generic.secrets.gitleaks.hashicorp-tf-password.hashicorp-tf-password
const val NOT_WHITELISTED = "not_whitelisted"
const val TOKEN_INVALID = "token_invalid"
}

View file

@ -2,7 +2,9 @@ package com.darkrockstudios.apps.hammer.base.http
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import org.koin.core.qualifier.named
/** For human-facing files on disk: indented, and forgiving of hand edits. */
@OptIn(ExperimentalSerializationApi::class)
fun createJsonSerializer(): Json {
return Json {
@ -13,4 +15,22 @@ fun createJsonSerializer(): Json {
allowTrailingComma = true
ignoreUnknownKeys = true
}
}
}
/**
* For machine-facing content on the wire. Matches Ktor's `DefaultJson` plus `ignoreUnknownKeys`,
* which the sync protocol depends on: synced models gain fields without a protocol bump, so a peer
* must drop fields a newer build wrote rather than failing the decode. See `SYNCING-PROTOCOL.md`.
*/
fun createNetworkJsonSerializer(): Json {
return Json {
encodeDefaults = true
isLenient = true
allowSpecialFloatingPointValues = true
allowStructuredMapKeys = true
ignoreUnknownKeys = true
}
}
/** Resolves [createNetworkJsonSerializer]; an unqualified `Json` is the file serializer. */
val NetworkJsonQualifier = named("networkJson")

View file

@ -9,6 +9,12 @@ data class ProjectData(
val wordCountGoal: WordCountGoal? = null,
/** Picked as a unit during conflict resolution — the whole set comes from one side, never a merge. */
val tags: Set<String> = emptySet(),
/** BCP-47 tag of the language the project is written in (e.g. "en", "pt-BR"); null when unset. */
val language: String? = null,
/** Whether Encyclopedia entry names feed the spell-check session dictionary for this project. */
val encyclopediaDictionary: Boolean = true,
/** User-added spell-check words. Unlike [tags], a conflict merges both sides by union. */
val dictionaryWords: Set<String> = emptySet(),
)
/** Picked as a unit during conflict resolution — one device's primary paired with another's secondary is undesigned. */

View file

@ -99,6 +99,7 @@ object EntityHasher {
tags: Set<String>,
image: ApiProjectEntity.EncyclopediaEntryEntity.Image?,
aliases: List<String>,
excludeFromDictionary: Boolean = false,
): String {
val buf = buff()
val d = Algorithm.MurmurHash3_X64_128().createDigest()
@ -121,6 +122,11 @@ object EntityHasher {
d.update(image.fileExtension, buf)
}
// Zero bytes at the default (false): pre-existing hashes must stay identical.
if (excludeFromDictionary) {
d.update(1, buf)
}
return d.digest().base64Url
}

View file

@ -46,6 +46,34 @@ object ProjectDataHasher {
data.tags.sorted().forEach { tag -> d.update(tag, buf) }
}
// Zero bytes when null, same rule as tags: pre-language hashes must stay identical.
// The -1 marker can never be a tags size and the length prefix bounds the string,
// so a language block cannot be read as a continuation of the tags block
// (tags=["x"] and language="x" must not collide).
if (data.language != null) {
d.update(-1, buf)
d.update(data.language.length, buf)
d.update(data.language, buf)
}
// Zero bytes at the default (true): pre-existing hashes must stay identical.
// -2 marker: -1 is taken by the language block.
if (!data.encyclopediaDictionary) {
d.update(-2, buf)
}
// Zero bytes when empty, same rule as tags. -3 marker: -1 and -2 are taken. Each word
// carries its length: update(String) writes bare char codes, so without it
// {"a","bc"} and {"ab","c"} would collide.
if (data.dictionaryWords.isNotEmpty()) {
d.update(-3, buf)
d.update(data.dictionaryWords.size, buf)
data.dictionaryWords.sorted().forEach { word ->
d.update(word.length, buf)
d.update(word, buf)
}
}
return d.digest().base64Url
}
}

View file

@ -0,0 +1,148 @@
package com.darkrockstudios.apps.hammer.base.markdown
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.LeafASTNode
import org.intellij.markdown.ast.accept
import org.intellij.markdown.flavours.MarkdownFlavourDescriptor
import org.intellij.markdown.html.GeneratingProvider
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.html.SimpleTagProvider
import org.intellij.markdown.html.TrimmingInlineHolderProvider
import org.intellij.markdown.parser.LinkMap
/**
* How a story's markdown becomes HTML, shared by every surface that renders prose for a reader: the
* web pages the server serves and the EPUB the client exports.
*
* CommonMark reflows prose. A run of single-newline lines collapses into one block and any run of
* blank lines collapses to one paragraph break, so a page of dialogue arrives as a wall of text that
* looks nothing like what the writer sees in the editor. These providers lay the text out as it was
* typed instead: every newline starts a new line, and every blank line is a blank line.
*
* Only prose is touched. Lists, tables, code and headings keep markdown's own layout, and blank
* lines next to them add nothing — those blocks carry their own spacing already.
*/
object ProseHtml {
/** Upper bound on the breaks one run of blank lines can produce. */
const val MAX_CONSECUTIVE_BREAKS = 6
/**
* The generating providers [flavour] would use, with prose layout applied. Pass the result to
* [HtmlGenerator]. With [keepBlankLines] off, a lone blank line between paragraphs produces
* nothing and a run of two or more produces a single break.
*/
fun providers(
flavour: MarkdownFlavourDescriptor,
linkMap: LinkMap,
keepBlankLines: Boolean = true,
): Map<IElementType, GeneratingProvider> =
flavour.createHtmlGeneratingProviders(linkMap, null) + mapOf(
MarkdownElementTypes.PARAGRAPH to ProseParagraphProvider,
MarkdownElementTypes.MARKDOWN_FILE to ProseContainerProvider("body", keepBlankLines),
MarkdownElementTypes.BLOCK_QUOTE to ProseContainerProvider("blockquote", keepBlankLines),
)
/**
* The parser only ends a line on `\n`. Content written on Windows, or imported from a file that
* was, arrives with `\r\n` and would otherwise parse as one run-on paragraph.
*/
fun normalizeLineEndings(markdown: String): String = markdown.replace(CARRIAGE_RETURN, "\n")
private val CARRIAGE_RETURN = Regex("\r\n?")
}
/**
* A paragraph of prose keeps the lines the author typed: each becomes its own paragraph.
*
* This relies on the reader's stylesheet giving paragraph siblings no vertical margin, so
* consecutive lines sit directly under one another and each picks up the first-line indent, exactly
* as the editor draws them.
*/
private object ProseParagraphProvider : TrimmingInlineHolderProvider() {
override fun openTag(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {
visitor.consumeTagOpen(node, "p")
}
override fun closeTag(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {
visitor.consumeTagClose("p")
}
override fun processNode(
visitor: HtmlGenerator.HtmlGeneratingVisitor,
text: String,
node: ASTNode,
) {
val children = childrenToRender(node)
openTag(visitor, text, node)
children.forEachIndexed { index, child ->
when {
// An edge newline would only open or close an empty paragraph.
child.type == MarkdownTokenTypes.EOL -> {
if (index != 0 && index != children.lastIndex) {
closeTag(visitor, text, node)
openTag(visitor, text, node)
}
}
// The trailing spaces of a hard break are redundant: the newline itself breaks here.
child.type == MarkdownTokenTypes.HARD_LINE_BREAK -> Unit
child is LeafASTNode -> visitor.visitLeaf(child)
else -> child.accept(visitor)
}
}
closeTag(visitor, text, node)
}
}
/** Emits a break for each blank line the author left between two paragraphs. */
private class ProseContainerProvider(
tag: String,
private val keepBlankLines: Boolean,
) : SimpleTagProvider(tag) {
override fun processNode(
visitor: HtmlGenerator.HtmlGeneratingVisitor,
text: String,
node: ASTNode,
) {
openTag(visitor, text, node)
var newlines = 0
var afterParagraph = false
for (child in node.children) {
when (child.type) {
MarkdownTokenTypes.EOL -> newlines++
// A quote's own '>' markers sit between the lines they mark, and neither they nor
// stray indentation say anything about how far apart two passages are.
MarkdownTokenTypes.WHITE_SPACE, MarkdownTokenTypes.BLOCK_QUOTE -> Unit
else -> {
val isParagraph = child.type == MarkdownElementTypes.PARAGRAPH
if (afterParagraph && isParagraph) {
val blanks = (newlines - 1).coerceIn(0, ProseHtml.MAX_CONSECUTIVE_BREAKS)
val breaks = when {
keepBlankLines -> blanks
blanks >= 2 -> 1
else -> 0
}
repeat(breaks) { visitor.consumeHtml(BREAK) }
}
child.accept(visitor)
afterParagraph = isParagraph
newlines = 0
}
}
}
closeTag(visitor, text, node)
}
private companion object {
const val BREAK = "<br />"
}
}

View file

@ -0,0 +1,20 @@
package com.darkrockstudios.apps.hammer.base.markdown
/**
* Counts runs of non-whitespace that contain at least one letter or digit, so standalone
* Markdown markers (`---`, `#`, `-`, `>`) are not words. Single pass: a whole manuscript is
* one string, not a word list.
*/
fun countWords(text: String): Int {
var count = 0
var counted = false
for (char in text) {
if (char.isWhitespace()) {
counted = false
} else if (!counted && char.isLetterOrDigit()) {
counted = true
count++
}
}
return count
}

View file

@ -0,0 +1,35 @@
package com.darkrockstudios.apps.hammer.base
import kotlin.test.Test
import kotlin.test.assertEquals
class DistributionChannelTest {
/**
* Pins the tokens against the build-side enum in
* `buildSrc/src/main/java/distributionChannel.kt`. A channel added or renamed on one side and
* not the other would otherwise ship a build whose reported channel silently falls back to
* [DistributionChannel.DEV].
*/
@Test
fun `tokens match the build side enum`() {
val expected = listOf(
"dev",
"github",
"google-play",
"fdroid",
"snap",
"appimage",
"flathub",
"ms-store",
"mac-app-store",
"ios-app-store",
)
assertEquals(expected, DistributionChannel.entries.map { it.token })
}
@Test
fun `current resolves the baked in token`() {
assertEquals(BuildMetadata.CHANNEL, DistributionChannel.current.token)
}
}

View file

@ -0,0 +1,34 @@
package com.darkrockstudios.apps.hammer.base
import kotlin.test.Test
import kotlin.test.assertEquals
class PackageFormatTest {
/**
* Pins the tokens against the build-side enum in `buildSrc/src/main/java/packageFormat.kt`,
* the same way [DistributionChannelTest] pins the channel tokens.
*/
@Test
fun `tokens match the build side enum`() {
val expected = listOf(
"none",
"msi",
"exe",
"deb",
"rpm",
"dmg",
"pkg",
"appimage",
"msix",
"snap",
"flatpak",
)
assertEquals(expected, PackageFormat.entries.map { it.token })
}
@Test
fun `current resolves the baked in token`() {
assertEquals(BuildMetadata.PACKAGE_FORMAT, PackageFormat.current.token)
}
}

View file

@ -136,6 +136,23 @@ class EntityHasherTest {
assertNotEquals(base, different, "hashEncyclopediaEntry must include aliases in the digest")
}
@Test
fun `hashEncyclopediaEntry differs when excludeFromDictionary differs`() {
val included = EntityHasher.hashEncyclopediaEntry(
id = 2, name = "Robert", entryType = "person",
text = "text", tags = emptySet(), image = null,
aliases = emptyList(),
excludeFromDictionary = false,
)
val excluded = EntityHasher.hashEncyclopediaEntry(
id = 2, name = "Robert", entryType = "person",
text = "text", tags = emptySet(), image = null,
aliases = emptyList(),
excludeFromDictionary = true,
)
assertNotEquals(included, excluded, "hashEncyclopediaEntry must include excludeFromDictionary in the digest")
}
@Test
fun `hashEncyclopediaEntry alias order is significant`() {
// aliases is a List, not a Set - reordering is a meaningful change that should

View file

@ -56,6 +56,82 @@ class ProjectDataHasherTest {
)
}
@Test
fun `language affects hash`() {
val unset = ProjectData(authorName = "Pat")
val set = unset.copy(language = "en-US")
assertNotEquals(ProjectDataHasher.hash(unset), ProjectDataHasher.hash(set))
}
@Test
fun `language region affects hash`() {
val en = ProjectData(language = "en")
val enUs = ProjectData(language = "en-US")
assertNotEquals(ProjectDataHasher.hash(en), ProjectDataHasher.hash(enUs))
}
@Test
fun `language block does not collide with a one-element tags block`() {
val tagged = ProjectData(tags = setOf("en"))
val languaged = ProjectData(language = "en")
assertNotEquals(
ProjectDataHasher.hash(tagged),
ProjectDataHasher.hash(languaged),
"tags=[x] and language=x must hash differently",
)
}
@Test
fun `encyclopedia dictionary affects hash`() {
val on = ProjectData(authorName = "Pat")
val off = on.copy(encyclopediaDictionary = false)
assertNotEquals(ProjectDataHasher.hash(on), ProjectDataHasher.hash(off))
}
@Test
fun `dictionary words affect hash`() {
val without = ProjectData(authorName = "Pat")
val with = without.copy(dictionaryWords = setOf("kvothe"))
assertNotEquals(ProjectDataHasher.hash(without), ProjectDataHasher.hash(with))
}
@Test
fun `dictionary word insertion order does not affect hash`() {
val ab = ProjectData(dictionaryWords = setOf("alpha", "beta"))
val ba = ProjectData(dictionaryWords = setOf("beta", "alpha"))
assertEquals(ProjectDataHasher.hash(ab), ProjectDataHasher.hash(ba))
}
@Test
fun `dictionary word boundaries affect hash`() {
val joined = ProjectData(dictionaryWords = setOf("ab"))
val split = ProjectData(dictionaryWords = setOf("a", "b"))
assertNotEquals(ProjectDataHasher.hash(joined), ProjectDataHasher.hash(split))
// Same size and same concatenation: only a per-word length keeps these apart.
val left = ProjectData(dictionaryWords = setOf("a", "bc"))
val right = ProjectData(dictionaryWords = setOf("ab", "c"))
assertNotEquals(ProjectDataHasher.hash(left), ProjectDataHasher.hash(right))
}
@Test
fun `dictionary block does not collide with tags or language`() {
val words = ProjectData(dictionaryWords = setOf("en"))
assertNotEquals(ProjectDataHasher.hash(ProjectData(tags = setOf("en"))), ProjectDataHasher.hash(words))
assertNotEquals(ProjectDataHasher.hash(ProjectData(language = "en")), ProjectDataHasher.hash(words))
}
@Test
fun `null vs empty language distinguishable`() {
val nullLanguage = ProjectData(language = null)
val emptyLanguage = ProjectData(language = "")
assertNotEquals(
ProjectDataHasher.hash(nullLanguage),
ProjectDataHasher.hash(emptyLanguage),
"null and empty-string language must hash differently",
)
}
@Test
fun `same input produces same hash`() {
val data = ProjectData(

View file

@ -1,7 +1,6 @@
package repositories.projectstatistics
package com.darkrockstudios.apps.hammer.base.markdown
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.countWords
import org.junit.jupiter.api.Test
import kotlin.test.Test
import kotlin.test.assertEquals
class WordCountTest {
@ -46,4 +45,38 @@ class WordCountTest {
fun `mixed whitespace runs collapse to single separator`() {
assertEquals(3, countWords("one \t two\n\n three"))
}
@Test
fun `a non-breaking space separates words`() {
assertEquals(2, countWords("hello world"))
}
@Test
fun `horizontal rule is not a word`() {
assertEquals(2, countWords("one\n\n---\n\ntwo"))
assertEquals(0, countWords("***"))
assertEquals(0, countWords("___"))
}
@Test
fun `heading marker is not a word`() {
assertEquals(1, countWords("# Heading"))
}
@Test
fun `bullet markers are not words`() {
assertEquals(2, countWords("- a\n- b"))
}
@Test
fun `blockquote marker is not a word`() {
assertEquals(2, countWords("> quoted text"))
}
@Test
fun `attached punctuation stays part of the word`() {
assertEquals(1, countWords("**bold**"))
assertEquals(1, countWords("don't"))
assertEquals(1, countWords("end."))
}
}

View file

@ -1,16 +1,20 @@
import com.darkrockstudios.build.APPLE_STORE_LIMIT
import com.darkrockstudios.build.PLAY_STORE_LIMIT
import com.darkrockstudios.build.Platform
import com.darkrockstudios.build.configureRelease
import com.darkrockstudios.build.extractLatestChangelog
import com.darkrockstudios.build.formatStoreNotes
import com.darkrockstudios.build.isPlatformReleaseTag
import com.darkrockstudios.build.registerLinuxDistributionTasks
import com.darkrockstudios.build.registerPublishTasks
import com.darkrockstudios.build.registerUpdateMetadataTasks
import com.darkrockstudios.build.releaseNotesUrl
import com.darkrockstudios.build.storeNotesLength
import com.darkrockstudios.build.updateFlatpakFiles
import com.darkrockstudios.build.updateIosShortVersion
import com.darkrockstudios.build.updateSnapcraftYaml
import com.darkrockstudios.build.wearVersionCode
import com.darkrockstudios.build.writeBakedChangelog
import com.darkrockstudios.build.writeChangelogMarkdown
import com.darkrockstudios.build.writeSemvar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
@ -42,6 +46,9 @@ allprojects {
tasks.withType<Test> {
useJUnitPlatform()
// The Koin compiler plugin's compile-safety hints are not tests, and as of plugin
// 1.1.0 they carry duplicate method signatures that JUnit's scan chokes on.
exclude("org/koin/plugin/hints/**")
}
// Compiler flags applied globally
@ -122,11 +129,19 @@ kover {
registerPublishTasks()
registerLinuxDistributionTasks(libs.versions.app.get())
registerUpdateMetadataTasks(libs.versions.app.get())
// The pre-commit hook runs Gradle, so git's hook env leaks into the daemon and Gradle
// blanks rather than unsets it later; a blank GIT_INDEX_FILE reads as an empty index.
fun ProcessBuilder.scrubGitEnv(): ProcessBuilder = apply {
environment().keys.removeAll { it.startsWith("GIT_") }
}
val releasePreFlightChecks = tasks.register("releasePreFlightChecks") {
doLast {
fun runGit(vararg args: String): String {
val process = ProcessBuilder(*args)
.scrubGitEnv()
.directory(project.rootDir)
.start()
val stdout = process.inputStream.bufferedReader().readText().trim()
@ -135,7 +150,7 @@ val releasePreFlightChecks = tasks.register("releasePreFlightChecks") {
}
println("Fetching origin...")
ProcessBuilder("git", "fetch", "origin").directory(project.rootDir).inheritIO().start().waitFor()
ProcessBuilder("git", "fetch", "origin").scrubGitEnv().directory(project.rootDir).inheritIO().start().waitFor()
// Check for unstaged/uncommitted changes
val statusText = runGit("git", "status", "--porcelain")
@ -171,34 +186,43 @@ tasks.register("prepareForRelease") {
val versionsFile = project.rootDir.resolve(versionsPath)
writeSemvar(libs.versions.app.get(), releaseInfo.semVar, versionsFile)
// Store listings carry the app-only notes plus a link to the GitHub release,
// which holds the full text including the web and server changes. A release
// that reaches no store has no store notes; leave the existing metadata alone
// rather than blanking the notes the last client release published.
// Store listings carry the app-only notes, written per sink in the release
// dialog. Google Play also gets a link to the GitHub release, which holds the
// full text including the web and server changes; the Apple stores must not
// (see releaseNotesUrl) and get the notes alone. A sink with no notes is one
// this release does not reach: leave its metadata alone rather than blanking
// the notes the last client release published.
val fullNotesUrl = releaseNotesUrl(releaseInfo.tag)
val storeNotes = releaseInfo.storeChangeLog
val writeStoreNotes = storeNotes.isNotBlank()
val notesLength = storeNotesLength(storeNotes, fullNotesUrl)
val playChangelog = formatStoreNotes(storeNotes, PLAY_STORE_LIMIT, fullNotesUrl)
val storeNotes = releaseInfo.storeNotes
val playNotes = storeNotes.notesFor(Platform.GOOGLE_PLAY)
val appleNotes = storeNotes.notesFor(Platform.IOS_APP_STORE)
val writePlayNotes = playNotes.isNotBlank()
val writeAppleNotes = appleNotes.isNotBlank()
val writeSharedNotes = storeNotes.shared.isNotBlank()
val playNotesLength = storeNotesLength(playNotes, fullNotesUrl)
val playChangelog = formatStoreNotes(playNotes, PLAY_STORE_LIMIT, fullNotesUrl)
// Write the Fastlane changelog file
val rootDir: File = project.rootDir
val changelogsPath =
"fastlane/metadata/android/en-US/changelogs".replace("/", File.separator)
val changeLogsDir = rootDir.resolve(changelogsPath)
val changeLogFile = File(changeLogsDir, "$versionCode.txt")
if (writeStoreNotes) {
changeLogFile.writeText(playChangelog)
println("Changelog for version ${releaseInfo.semVar} written to $changelogsPath/$versionCode.txt")
if (notesLength > PLAY_STORE_LIMIT) {
// The watch app ships in the same listing under its own version code.
val changeLogFiles = listOf(versionCode, wearVersionCode(versionCode)).map { File(changeLogsDir, "$it.txt") }
if (writePlayNotes) {
changeLogFiles.forEach { file ->
file.writeText(playChangelog)
println("Changelog for version ${releaseInfo.semVar} written to $changelogsPath/${file.name}")
}
if (playNotesLength > PLAY_STORE_LIMIT) {
println(" Google Play notes truncated to $PLAY_STORE_LIMIT characters; full text at $fullNotesUrl")
}
} else {
println("No store notes for this release; store metadata left untouched")
println("No Google Play notes for this release; store metadata left untouched")
}
val appleChangelog = formatStoreNotes(storeNotes, APPLE_STORE_LIMIT, fullNotesUrl)
if (writeStoreNotes && notesLength > APPLE_STORE_LIMIT) {
val appleChangelog = formatStoreNotes(appleNotes, APPLE_STORE_LIMIT, null)
if (writeAppleNotes && storeNotesLength(appleNotes, null) > APPLE_STORE_LIMIT) {
println("App Store notes truncated to $APPLE_STORE_LIMIT characters; full text at $fullNotesUrl")
}
@ -207,7 +231,11 @@ tasks.register("prepareForRelease") {
val macReleaseNotesDir = rootDir.resolve(macReleaseNotesPath)
macReleaseNotesDir.mkdirs()
val macReleaseNotesFile = File(macReleaseNotesDir, "release_notes.txt")
if (writeStoreNotes) {
// Both Apple listings share one set of notes but each has its own file, so a
// release to one leaves the other's file holding what it last published.
val writeMacNotes = writeAppleNotes && Platform.MAC_APP_STORE in releaseInfo.platforms
val writeIosNotes = writeAppleNotes && Platform.IOS_APP_STORE in releaseInfo.platforms
if (writeMacNotes) {
macReleaseNotesFile.writeText(appleChangelog)
println("macOS release notes written to $macReleaseNotesPath/release_notes.txt")
}
@ -217,14 +245,21 @@ tasks.register("prepareForRelease") {
val iosReleaseNotesDir = rootDir.resolve(iosReleaseNotesPath)
iosReleaseNotesDir.mkdirs()
val iosReleaseNotesFile = File(iosReleaseNotesDir, "release_notes.txt")
if (writeStoreNotes) {
if (writeIosNotes) {
iosReleaseNotesFile.writeText(appleChangelog)
println("iOS release notes written to $iosReleaseNotesPath/release_notes.txt")
}
// Write the Global changelog file
val globalChangelogFile = File("${project.rootDir}/CHANGELOG.md")
writeChangelogMarkdown(releaseInfo, globalChangelogFile)
val changelogEntry = writeChangelogMarkdown(releaseInfo, globalChangelogFile)
// Bake the same entry into the app. Written for every release, including server-only
// ones, so the resource never drifts from the version in libs.versions.toml.
val bakedChangelogPath =
"common/src/commonMain/composeResources/files/changelog.md".replace("/", File.separator)
val bakedChangelogFile = project.rootDir.resolve(bakedChangelogPath)
writeBakedChangelog(changelogEntry, bakedChangelogFile)
// Update snapcraft.yaml with new version and JVM version
val snapcraftPath = "snap/snapcraft.yaml".replace("/", File.separator)
@ -237,10 +272,17 @@ tasks.register("prepareForRelease") {
val flatpakManifestFile = project.rootDir.resolve(flatpakManifestPath)
val flatpakMetainfoPath = "flatpak/studio.darkrock.hammer.metainfo.xml".replace("/", File.separator)
val flatpakMetainfoFile = project.rootDir.resolve(flatpakMetainfoPath)
// Flathub is a store listing, so it gets the app-only notes. A release with
// none of them is not going to Flathub, so it gets no release entry either.
if (writeStoreNotes) {
updateFlatpakFiles(releaseInfo.semVar, jvmVersion, flatpakManifestFile, flatpakMetainfoFile, storeNotes)
// Flathub is a store listing with no tab of its own, so it gets the shared
// app-only notes. They are blank unless the release reaches Flathub (see
// reachesFlathub), and then it gets no release entry either.
if (writeSharedNotes) {
updateFlatpakFiles(
releaseInfo.semVar,
jvmVersion,
flatpakManifestFile,
flatpakMetainfoFile,
storeNotes.shared,
)
}
// Keep the iOS marketing version in sync with the semver. macOS pulls this
@ -253,6 +295,7 @@ tasks.register("prepareForRelease") {
val cmd = listOf("git") + args.toList()
println("> ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(project.rootDir)
.redirectErrorStream(true)
.start()
@ -264,15 +307,22 @@ tasks.register("prepareForRelease") {
}
// Commit the changes to the repo
if (writeStoreNotes) {
git("add", changeLogFile.absolutePath)
if (writePlayNotes) {
changeLogFiles.forEach { git("add", it.absolutePath) }
}
if (writeMacNotes) {
git("add", macReleaseNotesFile.absolutePath)
}
if (writeIosNotes) {
git("add", iosReleaseNotesFile.absolutePath)
}
if (writeSharedNotes) {
git("add", flatpakManifestFile.absolutePath)
git("add", flatpakMetainfoFile.absolutePath)
}
git("add", versionsFile.absolutePath)
git("add", globalChangelogFile.absolutePath)
git("add", bakedChangelogFile.absolutePath)
git("add", snapcraftFile.absolutePath)
git("add", iosInfoPlistFile.absolutePath)
git("commit", "-m", "Prepared for release: v${releaseInfo.semVar}")
@ -284,6 +334,7 @@ tasks.register("prepareForRelease") {
val cmd = listOf("git") + args.toList()
println("> (${dir.name}) ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(dir)
.redirectErrorStream(true)
.start()
@ -312,7 +363,7 @@ tasks.register("prepareForRelease") {
// Tag the merge commit explicitly; the main tree stays on develop.
val tagMessageFile = File(project.rootDir, "build/release-tag-message.txt")
tagMessageFile.parentFile.mkdirs()
tagMessageFile.writeText(releaseInfo.changeLog)
tagMessageFile.writeText(releaseInfo.tagMessage)
git("tag", "-a", releaseInfo.tag, "-F", tagMessageFile.absolutePath, "release")
// Push the branches and only this release's tag. Pushing --tags would try
@ -333,6 +384,7 @@ tasks.register("backoutLastRelease") {
val cmd = listOf("git") + args.toList()
println("> ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(project.rootDir)
.redirectErrorStream(true)
.start()
@ -350,6 +402,7 @@ tasks.register("backoutLastRelease") {
// the prefix (`vX.Y.Z+rc1`, `vX.Y.Z+sbom`) aren't included.
fun findReleaseTags(): List<String> {
val proc = ProcessBuilder("git", "tag", "-l", tagName, "$tagName+*")
.scrubGitEnv()
.directory(project.rootDir)
.start()
val tags = proc.inputStream.bufferedReader().readLines().filter { it.isNotBlank() }
@ -362,6 +415,7 @@ tasks.register("backoutLastRelease") {
// Check if HEAD commit is the release commit
val headProcess = ProcessBuilder("git", "log", "-1", "--format=%s")
.scrubGitEnv()
.directory(project.rootDir).start()
val headMessage = headProcess.inputStream.bufferedReader().readText().trim()
headProcess.waitFor()
@ -409,6 +463,7 @@ tasks.register("revertLastRelease") {
val cmd = listOf("git") + args.toList()
println("> ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(project.rootDir)
.redirectErrorStream(true)
.start()
@ -421,6 +476,7 @@ tasks.register("revertLastRelease") {
val cmd = listOf("git") + args.toList()
println("> ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(project.rootDir)
.redirectErrorStream(true)
.start()
@ -433,6 +489,7 @@ tasks.register("revertLastRelease") {
fun gitOutput(vararg args: String): String {
val cmd = listOf("git") + args.toList()
val process = ProcessBuilder(cmd)
.scrubGitEnv()
.directory(project.rootDir)
.redirectErrorStream(true)
.start()

View file

@ -8,8 +8,6 @@ repositories {
dependencies {
implementation(kotlin("stdlib"))
//implementation(libs.markdown)
implementation(libs.kotlinx.datetime)
// for the prepareForRelease dialog.
implementation(libs.flatlaf)
testImplementation(kotlin("test"))

View file

@ -57,28 +57,43 @@ fun extractLatestChangelog(changelogFile: File): String? {
return body.trim().ifEmpty { null }
}
fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File) {
/** Prepends this release's entry to CHANGELOG.md and returns the entry text. */
fun writeChangelogMarkdown(releaseInfo: ReleaseInfo, changelogFile: File): String {
val currentChangelog = changelogFile.readText()
val withoutHeader = currentChangelog.substring(currentChangelog.indexOf('\n') + 1)
val headerDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-M-d"))
val newEntry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n\n"
val entry = "## [${releaseInfo.semVar}] - $headerDate\n\n" + releaseInfo.changeLog + "\n"
val newChangeLog = "# Changelog\n\n" + newEntry + withoutHeader
val newChangeLog = "# Changelog\n\n" + entry + "\n" + withoutHeader
changelogFile.writeText(newChangeLog)
println("CHANGELOG.md written")
return entry
}
/** The same entry, baked into the app so the client can show it without calling GitHub. */
fun writeBakedChangelog(entry: String, bakedFile: File) {
bakedFile.parentFile.mkdirs()
bakedFile.writeText(entry)
println("Baked changelog written to ${bakedFile.path}")
}
/** Tag-message trailer that tells CI to publish the release instead of leaving it a draft. */
const val AUTO_PUBLISH_TRAILER = "Auto-Publish: true"
/**
* @param changeLog The full release notes: CHANGELOG.md and the GitHub release.
* @param storeChangeLog The app-only subset every store listing carries.
* @param storeNotes The app-only text each store listing carries.
* @param autoPublish Publish the GitHub release once every CI build job passes.
*/
data class ReleaseInfo(
val semVar: SemVar,
val changeLog: String,
val storeChangeLog: String,
val storeNotes: StoreChangelogs,
val platforms: Set<Platform>,
val autoPublish: Boolean = false,
) {
init {
// Fail fast at construction so we never get halfway through prepareForRelease
@ -89,6 +104,11 @@ data class ReleaseInfo(
/** The git tag for this release: `vX.Y.Z` for full, `vX.Y.Z+token+token` for partial. */
val tag: String get() = "v$semVar${tagSuffix(platforms)}"
// The trailer is the only channel that reaches CI per-release: the tag name already
// encodes platform scope, and a `+` suffix there would read as a partial release.
/** The annotated-tag message: the notes, plus the auto-publish trailer when asked for. */
val tagMessage: String get() = if (autoPublish) "$changeLog\n\n$AUTO_PUBLISH_TRAILER" else changeLog
}
/**
@ -112,6 +132,10 @@ private fun droppedSummary(dropped: List<String>): String {
return if (rest > 0) "$shown · +$rest more" else shown
}
/** A store editor's character count against its limit, and by how much it is over. */
private fun counterText(length: Int, limit: Int): String =
if (length > limit) "$length/$limit · over by ${length - limit}" else "$length/$limit"
/** Small muted caption above a changelog editor. */
private fun tabHint(text: String): JLabel = JLabel(wrapped(text)).apply {
font = font.deriveFont(Font.ITALIC, font.size - 1f)
@ -119,6 +143,64 @@ private fun tabHint(text: String): JLabel = JLabel(wrapped(text)).apply {
border = BorderFactory.createEmptyBorder(0, 2, 2, 2)
}
/** Tab positions in the changelog tab strip. */
private const val TAB_FULL = 0
private const val TAB_STORE = 1
private const val TAB_PLAY = 2
private const val TAB_APPLE = 3
/**
* A changelog editor that mirrors [derive] until it is hand-edited, after which it
* keeps its own text until [resync] is called. Chaining these gives the dialog its
* default: the full notes are the only thing anyone has to write, and a store tab
* is touched only when that store needs something different.
*/
private class MirroredEditor(private val derive: () -> String) {
val editor = JTextArea().apply {
lineWrap = true
wrapStyleWord = true
rows = 12
}
var edited = false
private set
private var syncing = false
val text: String get() = editor.text
fun onChange(listener: () -> Unit) {
editor.document.addDocumentListener(OnChangeListener {
if (!syncing) edited = true
listener()
})
}
/** Pulls the source text in, unless this editor has been hand-edited. */
fun sync() {
if (!edited) write(derive())
}
/** Discards hand edits and follows the source again. */
fun resync() {
edited = false
sync()
}
/** Replaces the text with a hand edit the dialog made, such as trim-to-fit. */
fun overwrite(value: String) {
write(value)
edited = true
}
private fun write(value: String) {
syncing = true
editor.text = value
editor.caretPosition = 0
syncing = false
}
}
class OnChangeListener(
val onChange: (e: DocumentEvent?) -> Unit
) : DocumentListener {
@ -141,7 +223,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
SwingUtilities.invokeAndWait {
val frame = JFrame()
frame.defaultCloseOperation = JFrame.DISPOSE_ON_CLOSE
frame.setSize(620, 900)
// The fixed sections take the height they need; this leaves the changelog editor ~250px.
frame.setSize(620, 1080)
fun refreshTitle() {
frame.title = "Prepare Release — $curSemVar → $newSemVar"
@ -163,7 +246,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
// --- Tag preview label + commit button declared early so refresh can close over them ---
val warningColor: Color = UIManager.getColor("Label.disabledForeground") ?: Color.GRAY
val normalColor: Color = UIManager.getColor("Label.foreground") ?: Color.LIGHT_GRAY
val truncateColor = Color(0xE0, 0x9B, 0x2B)
val editedColor = Color(0xE0, 0x9B, 0x2B)
val errorColor = Color(0xD0, 0x4A, 0x4A)
val tagFontBig = Font(Font.MONOSPACED, Font.BOLD, 18)
val tagFontWarn = Font(Font.MONOSPACED, Font.ITALIC, 14)
@ -176,18 +260,21 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
font = font.deriveFont(Font.BOLD, 14f)
}
// Editors and store preview are declared up here so the refresh below can close
// over them; they are laid out in the Changelog section further down.
// Editors, previews and tabs are declared up here so the refresh below can
// close over them; the Changelog section further down only places them.
val changeLog = JTextArea().apply {
lineWrap = true
wrapStyleWord = true
rows = 12
}
val storeNotes = JTextArea().apply {
lineWrap = true
wrapStyleWord = true
rows = 12
}
// Each store editor mirrors its source until hand-edited: the shared notes
// follow a filtered copy of the full changelog, and each store follows the
// shared notes. Stores diverge only when someone makes them.
val storeNotes = MirroredEditor { deriveStoreNotes(changeLog.text).notes }
val playNotes = MirroredEditor { storeNotes.text }
val appleNotes = MirroredEditor { storeNotes.text }
val playPreview = JTextArea().apply {
isEditable = false
lineWrap = true
@ -195,15 +282,100 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
rows = 6
font = Font(Font.MONOSPACED, Font.PLAIN, 12)
}
val characterCount = JLabel().apply {
fun counterLabel() = JLabel().apply {
font = font.deriveFont(font.size - 1f)
foreground = warningColor
}
// What the filter took out, so nothing leaves the store listing unnoticed.
val playCount = counterLabel()
val appleCount = counterLabel()
// What the filter took out, and whether a store still follows its source, so
// nothing leaves a listing unnoticed.
val storeStatus = tabHint("")
val resyncButton = JButton("Re-sync from full notes").apply {
val playStatus = tabHint("")
val appleStatus = tabHint("")
fun smallButton(label: String) = JButton(label).apply {
font = font.deriveFont(font.size - 1f)
}
val storeResync = smallButton("Re-sync from full notes")
val playResync = smallButton("Re-sync from store notes")
val appleResync = smallButton("Re-sync from store notes")
val trimButton = smallButton("Trim to fit")
fun tabHeader(hint: String, status: JLabel, buttons: List<JButton>): JPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(tabHint(hint).apply { alignmentX = Component.LEFT_ALIGNMENT })
add(status.apply { alignmentX = Component.LEFT_ALIGNMENT })
add(
JPanel(FlowLayout(FlowLayout.LEFT, 6, 2)).apply {
alignmentX = Component.LEFT_ALIGNMENT
buttons.forEach { add(it) }
},
)
}
fun editorTab(header: JComponent, center: JComponent, counter: JLabel?): JPanel =
JPanel(BorderLayout(0, 4)).apply {
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
add(header, BorderLayout.NORTH)
add(center, BorderLayout.CENTER)
if (counter != null) {
add(
JPanel(FlowLayout(FlowLayout.RIGHT, 0, 4)).apply { add(counter) },
BorderLayout.SOUTH,
)
}
}
val fullTab = editorTab(
tabHint("Goes to CHANGELOG.md and the GitHub release. Write everything here."),
JScrollPane(changeLog),
null,
)
val storeTab = editorTab(
tabHeader(
"The app-only base every listing starts from. Entries tagged for the web or server are filtered out, because stores reject notes about anything but the app. Google Play and Apple follow this text on their own tabs; Flathub publishes it as it stands, on releases to every store.",
storeStatus,
listOf(storeResync),
),
JScrollPane(storeNotes.editor),
null,
)
val playTab = editorTab(
tabHeader(
"Goes to Google Play and F-Droid, which read the same file. 500 characters, including the link to the full notes on GitHub.",
playStatus,
listOf(playResync, trimButton),
),
JSplitPane(
JSplitPane.VERTICAL_SPLIT,
JScrollPane(playNotes.editor),
JScrollPane(playPreview).apply {
border = BorderFactory.createTitledBorder("Google Play preview")
},
).apply {
resizeWeight = 0.6
border = null
},
playCount,
)
val appleTab = editorTab(
tabHeader(
"Goes to the iOS and Mac App Stores. 4000 characters, and no link: App Store review rejects notes pointing at the GitHub releases page.",
appleStatus,
listOf(appleResync),
),
JScrollPane(appleNotes.editor),
appleCount,
)
val changelogTabs = JTabbedPane().apply {
addTab("Full", fullTab)
addTab("App stores", storeTab)
addTab("Google Play", playTab)
addTab("Apple", appleTab)
}
/** The platforms the current scope selection targets, empty if the selection is incomplete. */
fun currentPlatforms(): Set<Platform> = when (scope) {
@ -212,30 +384,20 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
ReleaseScope.SERVER_ONLY -> setOf(Platform.SERVER)
}
// The store editor mirrors a filtered copy of the full notes until it is
// edited by hand, after which it is left alone.
var storeNotesEdited = false
var syncingStoreNotes = false
fun syncStoreNotes() {
if (storeNotesEdited) return
syncingStoreNotes = true
storeNotes.text = deriveStoreNotes(changeLog.text).notes
storeNotes.caretPosition = 0
syncingStoreNotes = false
// Without a full selection there is no tag yet, so the notes preview falls back
// to the bare version tag.
fun currentTag(): String {
val platforms = currentPlatforms()
return if (platforms.isEmpty()) "v$newSemVar" else "v$newSemVar${tagSuffix(platforms)}"
}
fun resyncStoreNotes() {
storeNotesEdited = false
syncStoreNotes()
}
fun followStatus(edited: Boolean) =
if (edited) "Hand-edited, so it no longer follows the store notes."
else "Following the store notes."
fun refresh() {
val platforms = currentPlatforms()
// Without a full selection there is no tag yet, so the notes preview falls
// back to the bare version tag.
val tag =
if (platforms.isEmpty()) "v$newSemVar" else "v$newSemVar${tagSuffix(platforms)}"
val tag = currentTag()
if (platforms.isNotEmpty()) {
tagLabel.text = tag
@ -247,42 +409,80 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
tagLabel.foreground = warningColor
}
// Empty notes would publish a "What's new" that describes nothing, which
// App Store review rejects — after the tag has already been pushed. A
// server-only release reaches no store, so it needs no store notes.
val needsStoreNotes = platforms.any { it in Platform.CLIENT_STORES }
commitButton.isEnabled = platforms.isNotEmpty() &&
changeLog.text.isNotBlank() &&
(!needsStoreNotes || storeNotes.text.isNotBlank())
// A sink only matters when the release reaches it, and a store outside the
// scope has its tab disabled so nobody writes notes that go nowhere.
// The shared tab stays enabled for any store release because the store tabs
// follow it, but it is only required when it is published itself.
val needsShared = reachesFlathub(platforms)
val needsPlay = reachesPlay(platforms)
val needsApple = reachesApple(platforms)
changelogTabs.setEnabledAt(TAB_STORE, reachesAnyStore(platforms))
changelogTabs.setEnabledAt(TAB_PLAY, needsPlay)
changelogTabs.setEnabledAt(TAB_APPLE, needsApple)
if (!changelogTabs.isEnabledAt(changelogTabs.selectedIndex)) {
changelogTabs.selectedIndex = TAB_FULL
}
val dropped = deriveStoreNotes(changeLog.text).dropped
storeStatus.text = wrapped(
when {
storeNotesEdited -> "Hand-edited, so it no longer follows the full notes."
storeNotes.edited -> "Hand-edited, so it no longer follows the full notes."
dropped.isEmpty() -> "Nothing was removed."
else -> "Removed ${dropped.size}: " + droppedSummary(dropped)
}
)
storeStatus.foreground = if (storeNotesEdited) truncateColor else warningColor
resyncButton.isEnabled = storeNotesEdited
storeStatus.foreground = if (storeNotes.edited) editedColor else warningColor
storeResync.isEnabled = storeNotes.edited
playStatus.text = wrapped(followStatus(playNotes.edited))
playStatus.foreground = if (playNotes.edited) editedColor else warningColor
playResync.isEnabled = playNotes.edited
appleStatus.text = wrapped(followStatus(appleNotes.edited))
appleStatus.foreground = if (appleNotes.edited) editedColor else warningColor
appleResync.isEnabled = appleNotes.edited
// Over-limit blocks the commit instead of truncating quietly: the Play tab
// exists so the 500 characters are chosen, not cut off at a bullet boundary.
val url = releaseNotesUrl(tag)
val needed = storeNotesLength(storeNotes.text, url)
playPreview.text = formatStoreNotes(storeNotes.text, PLAY_STORE_LIMIT, url)
val playLength = storeNotesLength(playNotes.text, url)
val playOver = playLength > PLAY_STORE_LIMIT
playPreview.text = formatStoreNotes(playNotes.text, PLAY_STORE_LIMIT, url)
playPreview.caretPosition = 0
characterCount.text = if (needed > PLAY_STORE_LIMIT) {
"Store notes: ${storeNotes.document.length} · Play: $needed/$PLAY_STORE_LIMIT — will truncate"
} else {
"Store notes: ${storeNotes.document.length} · Play: $needed/$PLAY_STORE_LIMIT"
}
characterCount.foreground =
if (needed > PLAY_STORE_LIMIT) truncateColor else warningColor
playCount.text = counterText(playLength, PLAY_STORE_LIMIT)
playCount.foreground = if (playOver) errorColor else warningColor
trimButton.isEnabled = playOver
val appleLength = storeNotesLength(appleNotes.text, null)
val appleOver = appleLength > APPLE_STORE_LIMIT
appleCount.text = counterText(appleLength, APPLE_STORE_LIMIT)
appleCount.foreground = if (appleOver) errorColor else warningColor
// Empty notes would publish a "What's new" that describes nothing, which App
// Store review rejects, after the tag has already been pushed.
commitButton.isEnabled = platforms.isNotEmpty() &&
changeLog.text.isNotBlank() &&
(!needsShared || storeNotes.text.isNotBlank()) &&
(!needsPlay || (playNotes.text.isNotBlank() && !playOver)) &&
(!needsApple || (appleNotes.text.isNotBlank() && !appleOver))
}
changeLog.document.addDocumentListener(OnChangeListener { syncStoreNotes(); refresh() })
storeNotes.document.addDocumentListener(OnChangeListener {
if (!syncingStoreNotes) storeNotesEdited = true
changeLog.document.addDocumentListener(OnChangeListener { storeNotes.sync(); refresh() })
storeNotes.onChange { playNotes.sync(); appleNotes.sync(); refresh() }
playNotes.onChange { refresh() }
appleNotes.onChange { refresh() }
storeResync.addActionListener { storeNotes.resync(); refresh() }
playResync.addActionListener { playNotes.resync(); refresh() }
appleResync.addActionListener { appleNotes.resync(); refresh() }
// Trim puts the fitted text in the editor instead of letting the write path cut
// it later, so what the store gets is text someone looked at.
trimButton.addActionListener {
val budget = storeNotesBudget(PLAY_STORE_LIMIT, releaseNotesUrl(currentTag()))
playNotes.overwrite(fitStoreNotes(playNotes.text, budget))
refresh()
})
}
// --- Label-value row helper for the Version section ---
fun labelPair(label: String, value: JComponent): JPanel = JPanel().apply {
@ -364,58 +564,22 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
// ============= Section: Will push tag =============
val tagSection = section("Will push tag").apply { add(tagLabel) }
// ============= Section: After the build =============
val autoPublishBox = JCheckBox("Auto-publish the GitHub release").apply {
alignmentX = Component.LEFT_ALIGNMENT
}
val autoPublishSection = section("After the build").apply {
add(autoPublishBox)
add(
tabHint("Off leaves a draft. Publishing starts the store workflow.")
.apply { alignmentX = Component.LEFT_ALIGNMENT },
)
}
// ============= Section: Changelog =============
val changelogSection = section("Changelog")
val fullTab = JPanel(BorderLayout(0, 4)).apply {
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
add(tabHint("Goes to CHANGELOG.md and the GitHub release. Write everything here."), BorderLayout.NORTH)
add(JScrollPane(changeLog), BorderLayout.CENTER)
}
resyncButton.addActionListener { resyncStoreNotes(); refresh() }
val storeHeader = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(
tabHint("Goes to every store listing. Entries tagged for the web or server are filtered out — stores reject notes about anything but the app.").apply {
alignmentX = Component.LEFT_ALIGNMENT
},
)
add(storeStatus.apply { alignmentX = Component.LEFT_ALIGNMENT })
add(
JPanel(FlowLayout(FlowLayout.LEFT, 0, 2)).apply {
alignmentX = Component.LEFT_ALIGNMENT
add(resyncButton)
},
)
}
val storeTab = JPanel(BorderLayout(0, 4)).apply {
border = BorderFactory.createEmptyBorder(8, 0, 0, 0)
add(storeHeader, BorderLayout.NORTH)
add(JScrollPane(storeNotes), BorderLayout.CENTER)
}
val changelogTabs = JTabbedPane().apply {
addTab("Full", fullTab)
addTab("App stores", storeTab)
}
val counterRow = JPanel(FlowLayout(FlowLayout.RIGHT, 0, 4)).apply {
alignmentX = Component.LEFT_ALIGNMENT
add(characterCount)
}
val editorPane = JPanel(BorderLayout()).apply {
add(changelogTabs, BorderLayout.CENTER)
add(counterRow, BorderLayout.SOUTH)
}
val previewPane = JScrollPane(playPreview).apply {
border = BorderFactory.createTitledBorder("Google Play preview")
}
val splitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT, editorPane, previewPane).apply {
resizeWeight = 0.6
border = null
alignmentX = Component.LEFT_ALIGNMENT
}
changelogSection.add(splitPane)
changelogSection.add(changelogTabs)
// ============= Release-type change listeners (after newVersionLabel exists) =============
// A patch carries the same notes as the release it patches, so pre-fill the
@ -455,8 +619,15 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
result = ReleaseInfo(
semVar = newSemVar,
changeLog = changeLog.text,
storeChangeLog = storeNotes.text,
// Mirroring fills every editor whatever the scope, so the sinks this
// release does not reach are dropped here rather than published.
storeNotes = StoreChangelogs(
shared = storeNotes.text,
play = playNotes.text,
apple = appleNotes.text,
).restrictedTo(currentPlatforms()),
platforms = currentPlatforms(),
autoPublish = autoPublishBox.isSelected,
)
frame.dispose()
}
@ -476,6 +647,8 @@ fun configureRelease(currentSemVarStr: String, lastReleaseChangelog: String? = n
add(Box.createRigidArea(Dimension(0, 8)))
add(tagSection)
add(Box.createRigidArea(Dimension(0, 8)))
add(autoPublishSection)
add(Box.createRigidArea(Dimension(0, 8)))
}
val root = JPanel(BorderLayout()).apply {
border = BorderFactory.createEmptyBorder(16, 20, 16, 20)

View file

@ -0,0 +1,57 @@
package com.darkrockstudios.build
import org.gradle.api.Project
/**
* Build-side mirror of the runtime `DistributionChannel` enum in `:base`. The two lists have to
* stay in step; `DistributionChannelTest` in `:base` pins the token strings so a change to one
* without the other fails a test rather than shipping a build that reports the wrong channel.
*
* Tokens match [Platform.tagToken] wherever the two overlap.
*/
enum class DistributionChannel(val token: String) {
DEV("dev"),
GITHUB("github"),
PLAY("google-play"),
FDROID("fdroid"),
SNAP("snap"),
APPIMAGE("appimage"),
FLATHUB("flathub"),
MICROSOFT_STORE("ms-store"),
MAC_APP_STORE("mac-app-store"),
IOS_APP_STORE("ios-app-store"),
;
companion object {
fun fromToken(token: String): DistributionChannel? = entries.firstOrNull { it.token == token }
}
}
/**
* Resolves the channel for this build from `-Pchannel=<token>`, defaulting to [FDROID] when the
* F-Droid build flags are set and [DEV] otherwise. Throws on an unknown token, so a typo in a
* release workflow fails the build instead of quietly shipping a store binary as [DEV].
*/
fun resolveDistributionChannel(
channelProperty: String?,
isFDroid: Boolean,
): DistributionChannel {
val requested = channelProperty?.trim()?.takeIf { it.isNotEmpty() }
?: return if (isFDroid) DistributionChannel.FDROID else DistributionChannel.DEV
return DistributionChannel.fromToken(requested)
?: error(
"Unknown -Pchannel=$requested. Valid channels: " +
DistributionChannel.entries.joinToString(", ") { it.token }
)
}
/**
* The channel this build is for. The F-Droid detection mirrors settings.gradle.kts, which cannot
* see buildSrc, so a plain F-Droid build reports its channel without passing -Pchannel too.
*/
fun Project.distributionChannel(): DistributionChannel {
val isFDroid = findProperty("fdroid")?.toString()?.isNotEmpty() == true ||
System.getenv("FDROID_BUILD") != null
return resolveDistributionChannel(findProperty("channel")?.toString(), isFDroid)
}

View file

@ -0,0 +1,45 @@
package com.darkrockstudios.build
import org.gradle.api.Project
/**
* Build-side mirror of the runtime `PackageFormat` enum in `:base`. The two lists have to stay
* in step; `PackageFormatTest` in `:base` pins the token strings so a change to one without the
* other fails a test rather than shipping a build that reports the wrong format.
*/
enum class PackageFormat(val token: String) {
NONE("none"),
MSI("msi"),
EXE("exe"),
DEB("deb"),
RPM("rpm"),
DMG("dmg"),
PKG("pkg"),
APPIMAGE("appimage"),
MSIX("msix"),
SNAP("snap"),
FLATPAK("flatpak"),
;
companion object {
fun fromToken(token: String): PackageFormat? = entries.firstOrNull { it.token == token }
}
}
/**
* Resolves the installer format for this build from `-Pformat=<token>`, defaulting to [NONE]
* for anything that is not a packaging run. Throws on an unknown token, so a typo in a release
* workflow fails the build instead of quietly shipping an installer that cannot name itself.
*/
fun resolvePackageFormat(formatProperty: String?): PackageFormat {
val requested = formatProperty?.trim()?.takeIf { it.isNotEmpty() } ?: return PackageFormat.NONE
return PackageFormat.fromToken(requested)
?: error(
"Unknown -Pformat=$requested. Valid formats: " +
PackageFormat.entries.joinToString(", ") { it.token }
)
}
/** The installer format this build is packaged as, from `-Pformat`. */
fun Project.packageFormat(): PackageFormat = resolvePackageFormat(findProperty("format")?.toString())

View file

@ -25,7 +25,14 @@ private val SECTION_HEADER = Regex("""^\[[^]]+]$""")
/** The longest `Label:` prefix treated as an audience label rather than prose. */
private const val MAX_LABEL_LENGTH = 20
/** The GitHub release page for a release tag, where the untruncated notes live. */
/**
* The GitHub release page for a release tag, where the untruncated notes live.
*
* Only Google Play is given this link. App Store review rejects release notes that
* link to the project's GitHub page — it offers the app outside the App Store — so
* the Apple stores pass `null` as the footer URL. The link shipped to iOS and macOS
* from v3.7.0 onward and drew "invalid metadata" rejections until it was removed.
*/
fun releaseNotesUrl(tag: String): String = "$GITHUB_REPO/releases/tag/$tag"
/**
@ -156,8 +163,9 @@ fun deriveStoreNotes(fullChangelog: String): StoreNotesDerivation {
}
/**
* Fits [changelog] into a store's character limit, always leaving room for a footer
* pointing at the full notes. Whole entries are kept so the text does not end
* Fits [changelog] into a store's character limit, leaving room for a footer pointing
* at the full notes when [fullNotesUrl] is given — see [releaseNotesUrl] for why the
* Apple stores pass null. Whole entries are kept so the text does not end
* mid-bullet, and an entry too long for the remaining budget is skipped rather than
* ending the notes. The `…` mark is appended only when an entry was actually left
* out; text that fits once its blank lines are collapsed is not marked truncated.
@ -169,18 +177,34 @@ fun deriveStoreNotes(fullChangelog: String): StoreNotesDerivation {
* A blank changelog yields an empty string: store metadata that is only a link
* describes nothing, and App Store review rejects it.
*/
fun formatStoreNotes(changelog: String, limit: Int, fullNotesUrl: String): String {
fun formatStoreNotes(changelog: String, limit: Int, fullNotesUrl: String?): String {
val body = normalizeNotes(changelog)
if (body.isEmpty()) return ""
val footer = footerFor(fullNotesUrl)
val budget = limit - footer.length
val budget = storeNotesBudget(limit, fullNotesUrl)
require(budget > 0) { "Limit $limit is too small to hold the changelog footer" }
if (body.length <= budget) return body + footer
return fitStoreNotes(body, budget) + footer
}
/** How much of a store's limit is left for the notes once its footer is subtracted. */
fun storeNotesBudget(limit: Int, fullNotesUrl: String?): Int = limit - footerFor(fullNotesUrl).length
/**
* [changelog] cut down to [limit] characters, footer excluded. Returns the body on
* its own so the release dialog can put the fitted text back in the Google Play
* editor for the user to improve, rather than appending a footer that the next
* [formatStoreNotes] call would append again.
*/
fun fitStoreNotes(changelog: String, limit: Int): String {
val body = normalizeNotes(changelog)
if (body.isEmpty()) return ""
require(limit > 0) { "Limit $limit leaves no room for notes" }
if (body.length <= limit) return body
val entries = parseEntries(body)
val truncatedBudget = budget - TRUNCATION_MARK.length
val truncatedBudget = limit - TRUNCATION_MARK.length
val kept = mutableListOf<Entry>()
for (entry in entries) {
val candidate = pruneEmptySections(kept + entry)
@ -194,21 +218,27 @@ fun formatStoreNotes(changelog: String, limit: Int, fullNotesUrl: String): Strin
val hardCut = body.take(truncatedBudget).trimEnd()
val lastSpace = hardCut.lastIndexOf(' ')
val cut = if (lastSpace > 0) hardCut.take(lastSpace) else hardCut
return cut.trimEnd() + TRUNCATION_MARK + footer
return cut.trimEnd() + TRUNCATION_MARK
}
val fitted = render(pruneEmptySections(kept)).trimEnd()
val mark = if (kept.size == entries.size) "" else TRUNCATION_MARK
return fitted + mark + footer
return fitted + mark
}
/** How many characters [changelog] needs at a store, footer included. */
fun storeNotesLength(changelog: String, fullNotesUrl: String): Int {
/**
* How many characters [changelog] needs at a store, including the footer when
* [fullNotesUrl] is given. Pass the same URL the store's [formatStoreNotes] call
* gets — null for the Apple stores — or the "will it truncate?" comparison is off
* by the footer's length.
*/
fun storeNotesLength(changelog: String, fullNotesUrl: String?): Int {
val body = normalizeNotes(changelog)
return if (body.isEmpty()) 0 else body.length + footerFor(fullNotesUrl).length
}
private fun footerFor(fullNotesUrl: String) = "\n\n$FOOTER_LABEL\n$fullNotesUrl"
private fun footerFor(fullNotesUrl: String?) =
if (fullNotesUrl == null) "" else "\n\n$FOOTER_LABEL\n$fullNotesUrl"
/**
* CHANGELOG.md is checked out with CRLF on Windows and seeds the release dialog, so
@ -216,3 +246,55 @@ private fun footerFor(fullNotesUrl: String) = "\n\n$FOOTER_LABEL\n$fullNotesUrl"
* lines with LF) and the verbatim path would disagree with the counted length.
*/
private fun normalizeNotes(changelog: String) = changelog.replace("\r\n", "\n").trim()
/**
* The release notes each store listing carries.
*
* [shared] is the app-only text produced by filtering the full changelog; [play]
* and [apple] begin as copies of it and diverge only when hand-edited in the
* release dialog. The split exists because the two sinks have incompatible
* budgets (500 characters with a footer link at Google Play against 4000 with no
* link at Apple), so text written to fill one is machine-mangled at the other.
*
* Stores with no tab of their own take [shared]: F-Droid and Google Play read the
* same fastlane file, so F-Droid is covered by [play].
*/
data class StoreChangelogs(
val shared: String,
val play: String = shared,
val apple: String = shared,
) {
fun notesFor(platform: Platform): String = when (platform) {
Platform.GOOGLE_PLAY, Platform.FDROID -> play
Platform.IOS_APP_STORE, Platform.MAC_APP_STORE -> apple
Platform.SNAP, Platform.MS_STORE, Platform.SERVER -> shared
}
/** The same notes with every sink this release does not reach blanked out. */
fun restrictedTo(platforms: Set<Platform>) = StoreChangelogs(
shared = if (reachesFlathub(platforms)) shared else "",
play = if (reachesPlay(platforms)) play else "",
apple = if (reachesApple(platforms)) apple else "",
)
/** True when no listing would carry any text at all. */
val isBlank: Boolean get() = shared.isBlank() && play.isBlank() && apple.isBlank()
}
/** Whether a release to [platforms] reaches the Google Play file, which F-Droid also reads. */
fun reachesPlay(platforms: Set<Platform>) =
Platform.GOOGLE_PLAY in platforms || Platform.FDROID in platforms
/** Whether a release to [platforms] reaches either Apple listing. */
fun reachesApple(platforms: Set<Platform>) =
Platform.IOS_APP_STORE in platforms || Platform.MAC_APP_STORE in platforms
/** Whether a release to [platforms] reaches any store listing at all. */
fun reachesAnyStore(platforms: Set<Platform>) = platforms.any { it in Platform.CLIENT_STORES }
/**
* Whether a release to [platforms] is a Flathub release. Flathub is published by hand
* with `publishFlathub`, outside the tag's store scope, so only a release that ships
* every client store counts; a targeted hotfix names its stores and Flathub is not one.
*/
fun reachesFlathub(platforms: Set<Platform>) = platforms.containsAll(Platform.CLIENT_STORES)

View file

@ -0,0 +1,145 @@
package com.darkrockstudios.build
import org.gradle.api.Project
import java.io.File
import java.security.MessageDigest
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Base64
/** One attached installer as the update metadata describes it. */
data class UpdateAsset(val name: String, val size: Long, val sha512: String)
/**
* The metadata files the desktop updater reads, each listing its platform's installers in
* preference order. The format is electron-builder's `latest.yml`, which Nucleus parses; the
* `url` is the bare asset name and the client prefixes the release URL.
*/
private val UPDATE_METADATA_FILES: Map<String, List<String>> = mapOf(
"latest.yml" to listOf("hammer.msi", "hammer.exe"),
"latest-mac.yml" to listOf("hammer.zip", "hammer.dmg", "hammer.pkg"),
"latest-linux.yml" to listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"),
)
/** Assets a release may legitimately lack; every other listed name must have a digest. */
private val OPTIONAL_ASSETS: Set<String> = setOf("hammer.zip")
/** Suffix of the sidecar each platform job writes next to its installer: `<base64 sha512> <bytes>`. */
const val DIGEST_SUFFIX = ".sha512"
private const val SHA512_BUFFER_SIZE = 1 shl 16
fun renderUpdateMetadata(version: String, releaseDate: String, assets: List<UpdateAsset>): String =
buildString {
appendLine("version: $version")
appendLine("releaseDate: $releaseDate")
appendLine("files:")
for (asset in assets) {
appendLine(" - url: ${asset.name}")
appendLine(" sha512: ${asset.sha512}")
appendLine(" size: ${asset.size}")
}
}
/** Base64 of the SHA-512 digest, which is how Nucleus's checksum verifier compares it. */
fun sha512Base64(file: File): String {
val digest = MessageDigest.getInstance("SHA-512")
file.inputStream().use { input ->
val buffer = ByteArray(SHA512_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return Base64.getEncoder().encodeToString(digest.digest())
}
/** The one-line sidecar content for an installer. */
fun assetDigestLine(file: File): String = "${sha512Base64(file)} ${file.length()}"
fun parseAssetDigest(name: String, line: String): UpdateAsset {
val parts = line.trim().split(' ')
require(parts.size == 2) { "Digest for $name should be '<base64 sha512> <bytes>', got '$line'" }
val size = parts[1].toLongOrNull() ?: error("Digest for $name has a non-numeric size: '$line'")
return UpdateAsset(name = name, size = size, sha512 = parts[0])
}
/**
* Builds every metadata file from the digest sidecars anywhere under [digestsDir], keyed by file
* name: upload-artifact keeps each installer's format subdirectory. A required installer with no
* sidecar fails loudly: a yml that omits it would silently leave that format with no update path.
*/
fun updateMetadataFiles(digestsDir: File, version: String, releaseDate: String): Map<String, String> {
val sidecars = digestsDir.walk()
.filter { it.isFile && it.name.endsWith(DIGEST_SUFFIX) }
.groupBy { it.name }
.mapValues { (name, found) ->
found.singleOrNull() ?: error("$digestsDir has more than one $name: $found")
}
return UPDATE_METADATA_FILES.mapValues { (metadataFile, names) ->
val assets = names.mapNotNull { name ->
val sidecar = sidecars[name + DIGEST_SUFFIX]
when {
sidecar != null -> parseAssetDigest(name, sidecar.readText())
name in OPTIONAL_ASSETS -> null
else -> error("$metadataFile needs $name$DIGEST_SUFFIX, but $digestsDir has no such file")
}
}
renderUpdateMetadata(version, releaseDate, assets)
}
}
/**
* Registers the two halves of the update metadata:
*
* - `writeAssetDigests -PassetFiles=<path,path>` runs in each platform job right after it
* packages and renames its installers, writing `<installer>.sha512` beside each one. The
* sidecars travel to the final job as a workflow artifact, so no installer is downloaded
* twice and no release lookup is needed.
* - `writeUpdateMetadata -PreleaseDigestsDir=<dir>` runs once in the final job over the
* collected sidecars and writes the yml files to `build/update-metadata/`.
*/
fun Project.registerUpdateMetadataTasks(appVersion: String) {
tasks.register("writeAssetDigests") {
group = "distribution"
description = "Writes a <installer>.sha512 sidecar beside each installer in -PassetFiles (comma separated)."
doLast {
val files = findProperty("assetFiles")?.toString()
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
?: error("Pass -PassetFiles=<installer>[,<installer>...]")
for (path in files) {
val file = File(path)
check(file.isFile) { "No installer at $path" }
val sidecar = File(file.path + DIGEST_SUFFIX)
sidecar.writeText(assetDigestLine(file))
println("Wrote $sidecar")
}
}
}
tasks.register("writeUpdateMetadata") {
group = "distribution"
description = "Writes the latest*.yml files the desktop updater reads, from the digest sidecars in -PreleaseDigestsDir."
doLast {
val digestsDir = File(
findProperty("releaseDigestsDir")?.toString()
?: error("Pass -PreleaseDigestsDir=<directory holding the *$DIGEST_SUFFIX sidecars>")
)
val outputDir = layout.buildDirectory.dir("update-metadata").get().asFile
outputDir.mkdirs()
val releaseDate = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()
for ((name, content) in updateMetadataFiles(digestsDir, appVersion, releaseDate)) {
val target = outputDir.resolve(name)
target.writeText(content)
println("Wrote $target")
}
}
}
}

View file

@ -8,9 +8,29 @@ fun getVersionCode(semVarStr: String): Int {
val semVar = parseSemVar(semVarStr)
val versionCode = semVar.createVersionCode(isRelease, buildNumber)
require(versionCode in 0 until WEAR_VERSION_CODE_OFFSET) {
"Version $semVarStr produces version code $versionCode, which runs into the Wear OS band at " +
"$WEAR_VERSION_CODE_OFFSET. Rework the version code scheme before releasing 10.0.0."
}
return versionCode
}
/**
* Play needs unique version codes across every bundle in a listing. Phone codes stay below this
* while the major version is below 10, and phone plus offset stays under Play's 2,100,000,000 cap.
*/
private const val WEAR_VERSION_CODE_OFFSET = 1_000_000_000
fun getWearVersionCode(semVarStr: String): Int = wearVersionCode(getVersionCode(semVarStr))
fun wearVersionCode(phoneVersionCode: Int): Int {
require(phoneVersionCode in 0 until WEAR_VERSION_CODE_OFFSET) {
"Phone version code $phoneVersionCode leaves no room for the wear offset"
}
return phoneVersionCode + WEAR_VERSION_CODE_OFFSET
}
data class SemVar(
val major: Int,
val minor: Int,

View file

@ -0,0 +1,50 @@
import com.darkrockstudios.build.DistributionChannel
import com.darkrockstudios.build.resolveDistributionChannel
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class DistributionChannelTest {
@Test
fun `no channel property is a dev build`() {
assertEquals(DistributionChannel.DEV, resolveDistributionChannel(null, isFDroid = false))
assertEquals(DistributionChannel.DEV, resolveDistributionChannel("", isFDroid = false))
assertEquals(DistributionChannel.DEV, resolveDistributionChannel(" ", isFDroid = false))
}
@Test
fun `the fdroid build flag stands in for the channel property`() {
assertEquals(DistributionChannel.FDROID, resolveDistributionChannel(null, isFDroid = true))
}
@Test
fun `an explicit channel wins over the fdroid flag`() {
assertEquals(
DistributionChannel.GITHUB,
resolveDistributionChannel("github", isFDroid = true)
)
}
@Test
fun `every token resolves to its own channel`() {
for (channel in DistributionChannel.entries) {
assertEquals(channel, resolveDistributionChannel(channel.token, isFDroid = false))
}
}
/** A typo in a release workflow must fail the build, not ship a store binary as DEV. */
@Test
fun `an unknown token fails the build`() {
val error = assertFailsWith<IllegalStateException> {
resolveDistributionChannel("microsoftStore", isFDroid = false)
}
assertEquals(true, error.message?.contains("ms-store"))
}
@Test
fun `tokens are unique`() {
val tokens = DistributionChannel.entries.map { it.token }
assertEquals(tokens.size, tokens.toSet().size)
}
}

View file

@ -0,0 +1,34 @@
import com.darkrockstudios.build.PackageFormat
import com.darkrockstudios.build.resolvePackageFormat
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class PackageFormatTest {
@Test
fun `no format property means not packaged`() {
assertEquals(PackageFormat.NONE, resolvePackageFormat(null))
assertEquals(PackageFormat.NONE, resolvePackageFormat(""))
assertEquals(PackageFormat.NONE, resolvePackageFormat(" "))
}
@Test
fun `every token resolves to its own format`() {
for (format in PackageFormat.entries) {
assertEquals(format, resolvePackageFormat(format.token))
}
}
@Test
fun `an unknown token fails the build`() {
val error = assertFailsWith<IllegalStateException> { resolvePackageFormat("windows") }
assertEquals(true, error.message?.contains("Unknown -Pformat=windows"))
}
@Test
fun `tokens are unique`() {
val tokens = PackageFormat.entries.map { it.token }
assertEquals(tokens.toSet().size, tokens.size)
}
}

View file

@ -0,0 +1,65 @@
package com.darkrockstudios.build
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ReleaseInfoTest {
private fun releaseInfo(autoPublish: Boolean) = ReleaseInfo(
semVar = SemVar(1, 2, 3),
changeLog = "Added a thing\n- detail one",
storeNotes = StoreChangelogs("Added a thing"),
platforms = Platform.ALL,
autoPublish = autoPublish,
)
@Test
fun `Auto-publish is off by default`() {
assertFalse(
ReleaseInfo(
semVar = SemVar(1, 2, 3),
changeLog = "notes",
storeNotes = StoreChangelogs("notes"),
platforms = Platform.ALL,
).autoPublish
)
}
@Test
fun `Tag message is the changelog alone when auto-publish is off`() {
assertEquals("Added a thing\n- detail one", releaseInfo(autoPublish = false).tagMessage)
}
@Test
fun `Tag message appends the trailer as its own last line when auto-publish is on`() {
val lines = releaseInfo(autoPublish = true).tagMessage.lines()
assertEquals(AUTO_PUBLISH_TRAILER, lines.last())
// The blank line keeps the trailer out of the last changelog paragraph, so
// set-release-body strips it without leaving a dangling line in the body.
assertEquals("", lines[lines.size - 2])
}
// set-release-body greps for this exact whole line; a reword here silently stops
// CI from publishing.
@Test
fun `Trailer wire format is stable`() {
assertEquals("Auto-Publish: true", AUTO_PUBLISH_TRAILER)
}
@Test
fun `Dropping the trailer restores the plain changelog`() {
val stripped = releaseInfo(autoPublish = true).tagMessage
.lines()
.filterNot { it == AUTO_PUBLISH_TRAILER }
.joinToString("\n")
.trimEnd()
assertEquals(releaseInfo(autoPublish = false).tagMessage, stripped)
}
@Test
fun `Trailer never leaks into a release that did not ask for it`() {
assertTrue(AUTO_PUBLISH_TRAILER !in releaseInfo(autoPublish = false).tagMessage)
}
}

View file

@ -0,0 +1,151 @@
package com.darkrockstudios.build
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class StoreChangelogsTest {
private val url = releaseNotesUrl("v3.7.0")
@Test
fun `A store with no tab of its own takes the shared notes`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals("shared", notes.notesFor(Platform.SNAP))
assertEquals("shared", notes.notesFor(Platform.MS_STORE))
assertEquals("shared", notes.notesFor(Platform.SERVER))
}
// F-Droid and Google Play read the same fastlane file, so they cannot disagree.
@Test
fun `F-Droid takes the Google Play notes`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals(notes.notesFor(Platform.GOOGLE_PLAY), notes.notesFor(Platform.FDROID))
}
@Test
fun `Both Apple stores take the Apple notes`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals("apple", notes.notesFor(Platform.IOS_APP_STORE))
assertEquals("apple", notes.notesFor(Platform.MAC_APP_STORE))
}
@Test
fun `Stores follow the shared notes until they are given their own`() {
val notes = StoreChangelogs(shared = "shared")
Platform.values().forEach { platform ->
assertEquals("shared", notes.notesFor(platform), "$platform diverged")
}
}
@Test
fun `Blank is only blank when every sink is`() {
assertTrue(StoreChangelogs(shared = "").isBlank)
assertFalse(StoreChangelogs(shared = "", play = "play", apple = "").isBlank)
assertFalse(StoreChangelogs(shared = "shared").isBlank)
}
@Test
fun `Fitted notes carry no footer`() {
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
val fitted = fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url))
assertFalse(url in fitted, "The footer url leaked into the fitted body")
assertTrue(fitted.length <= storeNotesBudget(PLAY_STORE_LIMIT, url))
}
// The dialog's Trim to fit puts fitted text back in the editor, which is then
// formatted again on the way to the store. A footer in the fitted body would be
// published twice and eat the budget a second time.
@Test
fun `Trimming then publishing stays within the limit with one footer`() {
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
val trimmed = fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url))
val published = formatStoreNotes(trimmed, PLAY_STORE_LIMIT, url)
assertTrue(published.length <= PLAY_STORE_LIMIT, "Notes were ${published.length} characters")
assertEquals(1, published.split(url).size - 1, "The footer was published more than once")
}
@Test
fun `Trimming already fitted notes changes nothing`() {
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
val budget = storeNotesBudget(PLAY_STORE_LIMIT, url)
val once = fitStoreNotes(changelog, budget)
assertEquals(once, fitStoreNotes(once, budget))
}
@Test
fun `Publishing is fitting plus the footer`() {
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
assertEquals(
fitStoreNotes(changelog, storeNotesBudget(PLAY_STORE_LIMIT, url)) + "\n\nFull changelog:\n$url",
formatStoreNotes(changelog, PLAY_STORE_LIMIT, url),
)
}
@Test
fun `The Apple budget is the whole limit because it carries no footer`() {
assertEquals(APPLE_STORE_LIMIT, storeNotesBudget(APPLE_STORE_LIMIT, null))
}
@Test
fun `A server-only release publishes no store notes`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertTrue(notes.restrictedTo(setOf(Platform.SERVER)).isBlank)
}
@Test
fun `A release skips the sinks it does not reach`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
val playOnly = notes.restrictedTo(setOf(Platform.GOOGLE_PLAY))
assertEquals("play", playOnly.play)
assertEquals("", playOnly.apple)
assertEquals("", playOnly.shared)
}
@Test
fun `Only a release to every client store reaches Flathub`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals("shared", notes.restrictedTo(Platform.CLIENT_STORES).shared)
assertEquals("", notes.restrictedTo(Platform.CLIENT_STORES - Platform.SNAP).shared)
}
@Test
fun `An F-Droid release still fills the Google Play file they share`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals("play", notes.restrictedTo(setOf(Platform.FDROID)).play)
}
@Test
fun `A mac-only release keeps the Apple notes both listings share`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
val macOnly = notes.restrictedTo(setOf(Platform.MAC_APP_STORE))
assertEquals("apple", macOnly.apple)
assertEquals("", macOnly.play)
}
@Test
fun `A full release keeps every sink`() {
val notes = StoreChangelogs(shared = "shared", play = "play", apple = "apple")
assertEquals(notes, notes.restrictedTo(Platform.ALL))
}
}

View file

@ -213,6 +213,47 @@ class StoreNotesTest {
assertEquals(shortNotes.length, storeNotesLength(short, url))
}
@Test
fun `A null url publishes the notes with no footer and no link`() {
val changelog = "- [Fix] A bug\n- [New] A feature"
val notes = formatStoreNotes(changelog, APPLE_STORE_LIMIT, null)
assertEquals(changelog, notes)
assertEquals(changelog.length, storeNotesLength(changelog, null))
}
@Test
fun `Apple notes never carry a link that App Store review would reject`() {
// The regression that broke every iOS and macOS submission from v3.7.0: the
// footer was appended for Apple too, so the notes ended in a github.com URL.
val changelog = (1..400).joinToString("\n") { "- [New] Feature number $it in this release" }
listOf(
formatStoreNotes("- [Fix] A bug", APPLE_STORE_LIMIT, null),
formatStoreNotes(changelog, APPLE_STORE_LIMIT, null),
formatStoreNotes("word ".repeat(2000).trim(), APPLE_STORE_LIMIT, null),
).forEach { notes ->
assertTrue(!notes.contains("github.com"), "Apple notes carry a GitHub link:\n$notes")
assertTrue(!notes.contains("Full changelog:"), "Apple notes carry the footer:\n$notes")
assertTrue(notes.length <= APPLE_STORE_LIMIT, "Notes were ${notes.length} characters")
}
}
@Test
fun `Dropping the footer gives the body more of the budget`() {
val changelog = (1..40).joinToString("\n") { "- [New] Feature number $it in this release" }
val withFooter = formatStoreNotes(changelog, PLAY_STORE_LIMIT, url)
val without = formatStoreNotes(changelog, PLAY_STORE_LIMIT, null)
assertTrue(
publishedBody(without).length > publishedBody(withFooter).length,
"The footer's characters were not returned to the body",
)
assertTrue(without.length <= PLAY_STORE_LIMIT, "Notes were ${without.length} characters")
}
@Test
fun `Release url points at the tag`() {
assertEquals(

View file

@ -0,0 +1,140 @@
import com.darkrockstudios.build.DIGEST_SUFFIX
import com.darkrockstudios.build.UpdateAsset
import com.darkrockstudios.build.assetDigestLine
import com.darkrockstudios.build.parseAssetDigest
import com.darkrockstudios.build.renderUpdateMetadata
import com.darkrockstudios.build.sha512Base64
import com.darkrockstudios.build.updateMetadataFiles
import java.io.File
import java.nio.file.Files
import java.security.MessageDigest
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class UpdateMetadataTest {
private val installers = listOf(
"hammer.msi", "hammer.exe", "hammer.dmg", "hammer.pkg", "hammer.deb", "hammer.rpm", "hammer.AppImage",
)
@Test
fun `renders the electron-builder layout Nucleus parses`() {
val yml = renderUpdateMetadata(
version = "3.12.0",
releaseDate = "2026-10-20T18:04:11Z",
assets = listOf(
UpdateAsset("hammer.msi", 190034708, "abc="),
UpdateAsset("hammer.exe", 190100000, "def="),
),
)
val expected = """
|version: 3.12.0
|releaseDate: 2026-10-20T18:04:11Z
|files:
| - url: hammer.msi
| sha512: abc=
| size: 190034708
| - url: hammer.exe
| sha512: def=
| size: 190100000
|""".trimMargin()
assertEquals(expected, yml)
}
@Test
fun `sha512 is base64 of the digest`() {
val file = tempDir().resolve("asset.bin").apply { writeBytes(ByteArray(100_000) { it.toByte() }) }
val expected = Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-512").digest(file.readBytes())
)
assertEquals(expected, sha512Base64(file))
}
@Test
fun `a digest line round-trips through the sidecar`() {
val file = tempDir().resolve("hammer.msi").apply { writeBytes(ByteArray(12_345) { (it * 3).toByte() }) }
val asset = parseAssetDigest("hammer.msi", assetDigestLine(file) + "\n")
assertEquals(UpdateAsset("hammer.msi", 12_345, sha512Base64(file)), asset)
}
@Test
fun `a malformed sidecar is rejected`() {
assertFailsWith<IllegalArgumentException> { parseAssetDigest("hammer.msi", "abc=") }
assertFailsWith<IllegalStateException> { parseAssetDigest("hammer.msi", "abc= many") }
}
@Test
fun `every platform file lists its installers from the sidecars`() {
val dir = tempDir()
installers.forEachIndexed { i, name -> dir.sidecar(name, "digest$i= ${100 + i}") }
val files = updateMetadataFiles(dir, "3.12.0", "2026-10-20T18:04:11Z")
assertEquals(setOf("latest.yml", "latest-mac.yml", "latest-linux.yml"), files.keys)
assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml")))
assertEquals(listOf("hammer.dmg", "hammer.pkg"), urlsIn(files.getValue("latest-mac.yml")))
assertEquals(
listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"),
urlsIn(files.getValue("latest-linux.yml")),
)
assertEquals(" sha512: digest0=", files.getValue("latest.yml").lines()[4])
assertEquals(" size: 100", files.getValue("latest.yml").lines()[5])
}
@Test
fun `sidecars are found inside the per-format subdirectories the artifacts keep`() {
val dir = tempDir()
installers.forEach { dir.resolve(it.substringAfter('.').lowercase()).apply { mkdirs() }.sidecar(it, "x= 1") }
val files = updateMetadataFiles(dir, "1", "d")
assertEquals(listOf("hammer.msi", "hammer.exe"), urlsIn(files.getValue("latest.yml")))
assertEquals(listOf("hammer.deb", "hammer.rpm", "hammer.AppImage"), urlsIn(files.getValue("latest-linux.yml")))
}
@Test
fun `a duplicated sidecar fails instead of picking one`() {
val dir = tempDir()
installers.forEach { dir.sidecar(it, "x= 1") }
dir.resolve("msi").apply { mkdirs() }.sidecar("hammer.msi", "y= 2")
assertFailsWith<IllegalStateException> { updateMetadataFiles(dir, "1", "d") }
}
@Test
fun `the mac zip is listed first when present and skipped when absent`() {
val dir = tempDir()
installers.forEach { dir.sidecar(it, "x= 1") }
assertNull(urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")).firstOrNull { it == "hammer.zip" })
dir.sidecar("hammer.zip", "z= 2")
assertEquals(
listOf("hammer.zip", "hammer.dmg", "hammer.pkg"),
urlsIn(updateMetadataFiles(dir, "1", "d").getValue("latest-mac.yml")),
)
}
@Test
fun `a missing required installer fails instead of publishing a gap`() {
val dir = tempDir()
installers.filter { it != "hammer.exe" }.forEach { dir.sidecar(it, "x= 1") }
val error = assertFailsWith<IllegalStateException> { updateMetadataFiles(dir, "1", "d") }
assertEquals(true, error.message?.startsWith("latest.yml needs hammer.exe$DIGEST_SUFFIX"))
}
private fun File.sidecar(name: String, line: String) = resolve(name + DIGEST_SUFFIX).writeText(line)
private fun urlsIn(yml: String): List<String> =
yml.lines().filter { it.trim().startsWith("- url:") }.map { it.substringAfter("- url:").trim() }
private fun tempDir(): File = Files.createTempDirectory("update-metadata-test").toFile().apply { deleteOnExit() }
}

View file

@ -28,4 +28,27 @@ class VersionCodeTest {
versionCode = semVar.createVersionCode(true, 1)
assertEquals(102040000, versionCode, "Release: True Build: 1")
}
@Test
fun `Wear version code sits above every phone version code`() {
val phoneCode = parseSemVar("9.99.99").createVersionCode(false, 9_999)
assertEquals(1_999_999_999, wearVersionCode(phoneCode))
assertEquals(1_102_030_001, wearVersionCode(102_030_001))
}
@Test(expected = IllegalArgumentException::class)
fun `Wear version code rejects a phone code that reaches the offset`() {
wearVersionCode(1_000_000_000)
}
@Test(expected = IllegalArgumentException::class)
fun `A version that collides with the wear band is refused for every target`() {
getVersionCode("10.0.0")
}
@Test
fun `The last version below the wear band is still allowed`() {
assertEquals(999_990_000, getVersionCode("9.99.99"))
}
}

View file

@ -155,7 +155,7 @@ kotlin {
api(libs.serialization.jvm)
api(libs.coroutines.swing)
implementation(libs.appdirs)
implementation(libs.ktor.client.java)
implementation(libs.ktor.client.okhttp)
implementation(libs.turbine)
}
}

View file

@ -0,0 +1,150 @@
package com.darkrockstudios.apps.hammer.common.data.globalsettings.datasource
import android.content.SharedPreferences
import kotlinx.serialization.json.Json
import javax.crypto.AEADBadTagException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class EncryptedSharedPrefsAuthTokenStoreTest {
private val disk = FakeEncryptedPrefsDisk()
private fun createStore() = EncryptedSharedPrefsAuthTokenStore(
json = Json,
openPrefs = disk::open,
resetPrefs = disk::reset,
)
@Test
fun `tokens round trip`() {
val store = createStore()
store.put(URL, USER_ID, TOKENS)
assertEquals(TOKENS, createStore().get(URL, USER_ID))
}
@Test
fun `keyset the master key cannot decrypt is treated as no tokens`() {
disk.keysetDecryptable = false
val store = createStore()
val tokens = store.get(URL, USER_ID)
assertNull(tokens)
}
@Test
fun `store is usable again after an undecryptable keyset`() {
disk.keysetDecryptable = false
createStore().get(URL, USER_ID)
createStore().put(URL, USER_ID, TOKENS)
assertEquals(TOKENS, createStore().get(URL, USER_ID))
}
@Test
fun `value that cannot be decrypted is treated as no tokens`() {
createStore().put(URL, USER_ID, TOKENS)
disk.valuesDecryptable = false
val tokens = createStore().get(URL, USER_ID)
assertNull(tokens)
}
@Test
fun `put replaces a value that cannot be decrypted`() {
createStore().put(URL, USER_ID, TOKENS)
disk.valuesDecryptable = false
val replacement = AuthTokens(bearerToken = "new-bearer", refreshToken = "new-refresh")
createStore().put(URL, USER_ID, replacement)
assertEquals(replacement, createStore().get(URL, USER_ID))
}
companion object {
private const val URL = "https://hammer.example"
private const val USER_ID = 7L
private val TOKENS = AuthTokens(bearerToken = "bearer", refreshToken = "refresh")
}
}
/**
* Models the encrypted prefs file: [open] fails like EncryptedSharedPreferences.create
* when the Keystore master key cannot decrypt the stored keyset, and [reset] removes
* the file (keyset included) so the next open generates a fresh one.
*/
private class FakeEncryptedPrefsDisk {
val values = mutableMapOf<String, String>()
var keysetDecryptable = true
var valuesDecryptable = true
fun open(): SharedPreferences {
if (!keysetDecryptable) throw AEADBadTagException()
return FakePrefs()
}
fun reset() {
values.clear()
keysetDecryptable = true
valuesDecryptable = true
}
private inner class FakePrefs : SharedPreferences {
override fun getString(key: String, defValue: String?): String? {
val stored = values[key] ?: return defValue
if (!valuesDecryptable) throw SecurityException("Could not decrypt value")
return stored
}
override fun contains(key: String): Boolean = key in values
override fun getAll(): MutableMap<String, *> = values.toMutableMap()
override fun edit(): SharedPreferences.Editor = FakeEditor()
override fun getStringSet(key: String, defValues: MutableSet<String>?) = unsupported()
override fun getInt(key: String, defValue: Int): Int = unsupported()
override fun getLong(key: String, defValue: Long): Long = unsupported()
override fun getFloat(key: String, defValue: Float): Float = unsupported()
override fun getBoolean(key: String, defValue: Boolean): Boolean = unsupported()
override fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) = unsupported()
override fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) = unsupported()
}
private inner class FakeEditor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, String?>()
private var clearAll = false
override fun putString(key: String, value: String?) = apply { pending[key] = value }
override fun remove(key: String) = apply { pending[key] = null }
override fun clear() = apply { clearAll = true }
override fun commit(): Boolean {
if (clearAll) values.clear()
pending.forEach { (key, value) -> if (value == null) values.remove(key) else values[key] = value }
valuesDecryptable = true
return true
}
override fun apply() {
commit()
}
override fun putStringSet(key: String, values: MutableSet<String>?) = unsupported()
override fun putInt(key: String, value: Int) = unsupported()
override fun putLong(key: String, value: Long) = unsupported()
override fun putFloat(key: String, value: Float) = unsupported()
override fun putBoolean(key: String, value: Boolean) = unsupported()
}
}
private fun unsupported(): Nothing = throw UnsupportedOperationException()

View file

@ -0,0 +1,36 @@
package com.darkrockstudios.apps.hammer.common
import io.github.aakira.napier.Napier
import java.io.File
/**
* Catch, log, and hand off any otherwise-unhandled exception. The async [FileLogger] can't be
* relied on to flush before the process dies, so we also write a synchronous crash dump straight
* to disk, then chain to the platform default handler so Android still terminates and reports.
*/
fun installGlobalExceptionHandler() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
Napier.e(
"Uncaught exception on thread '${thread.name}', terminating",
throwable
)
}
runCatching { writeCrashDump(thread, throwable) }
previous?.uncaughtException(thread, throwable)
}
}
/** Synchronous, self-contained crash record in the logs dir: the guaranteed artifact when the app dies. */
private fun writeCrashDump(thread: Thread, throwable: Throwable) {
val dir = File(getConfigDirectory(), "logs")
dir.mkdirs()
File(dir, "crash-${System.currentTimeMillis()}.txt").writeText(
buildString {
append(startupBanner() + "\n")
append("Uncaught exception on thread '${thread.name}'\n\n")
append(throwable.stackTraceToString())
}
)
}

View file

@ -1,4 +1,4 @@
package com.darkrockstudios.apps.hammer.android
package com.darkrockstudios.apps.hammer.common
import com.darkrockstudios.apps.hammer.common.getConfigDirectory
import com.darkrockstudios.apps.hammer.common.getInDevelopmentMode

View file

@ -0,0 +1,3 @@
package com.darkrockstudios.apps.hammer.common.data.changelog
actual val supportsInAppChangelog: Boolean = true

View file

@ -9,31 +9,46 @@ import io.github.aakira.napier.Napier
import kotlinx.atomicfu.locks.reentrantLock
import kotlinx.atomicfu.locks.withLock
import kotlinx.serialization.json.Json
import java.io.IOException
import java.security.GeneralSecurityException
import java.security.KeyStore
/**
* [AuthTokenStore] backed by [EncryptedSharedPreferences] with an Android
* Keystore-backed master key. The account-keyed token map is stored as JSON in a
* single encrypted preferences entry.
*/
class EncryptedSharedPrefsAuthTokenStore(
context: Context,
class EncryptedSharedPrefsAuthTokenStore internal constructor(
private val json: Json,
private val openPrefs: () -> SharedPreferences,
private val resetPrefs: () -> Unit,
) : AuthTokenStore {
constructor(context: Context, json: Json) : this(
json = json,
openPrefs = { openEncryptedPrefs(context) },
resetPrefs = { resetEncryptedPrefs(context) },
)
private val lock = reentrantLock()
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
try {
openPrefs()
} catch (e: GeneralSecurityException) {
recreatePrefs(e)
} catch (e: IOException) {
recreatePrefs(e)
}
}
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
// The keyset lives in the prefs file but its master key lives in the Keystore, which is
// never backed up, so a restored or orphaned file is undecryptable, and some keystores
// leave the master key itself unusable. Start over with both (forces re-login).
private fun recreatePrefs(cause: Exception): SharedPreferences {
Napier.w("Auth token keyset is unreadable; recreating the store", cause)
resetPrefs()
return openPrefs()
}
override fun get(url: String, userId: Long): AuthTokens? = lock.withLock {
@ -54,11 +69,11 @@ class EncryptedSharedPrefsAuthTokenStore(
}
private fun loadMap(): Map<String, AuthTokens> {
val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
return try {
val stored = prefs.getString(TOKENS_KEY, null) ?: return emptyMap()
json.decodeFromString<Map<String, AuthTokens>>(stored)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Napier.w("Failed to parse encrypted auth tokens; treating as empty", e)
Napier.w("Failed to read encrypted auth tokens; treating as empty", e)
emptyMap()
}
}
@ -70,5 +85,26 @@ class EncryptedSharedPrefsAuthTokenStore(
companion object {
private const val PREFS_NAME = "hammer_auth_tokens"
private const val TOKENS_KEY = "tokens"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private fun openEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
private fun resetEncryptedPrefs(context: Context) {
context.deleteSharedPreferences(PREFS_NAME)
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
.deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
}
}

Some files were not shown because too many files have changed in this diff Show more