Compare commits

...
Author SHA1 Message Date
Bailey Dixon 245362d58e Merge remote-tracking branch 'origin/dev' into feature/android-release-notes-expansion 2026-08-31 13:21:40 -04:00
Bailey Dixon 95e78e0657 Merge pull request #509 from Codename-11/chore/android-build-lane
chore(android): serialize Windows build lanes
2026-08-31 13:14:56 -04:00
Bailey Dixon cd856da6a0 Merge remote-tracking branch 'origin/dev' into feature/android-release-notes-expansion
# Conflicts:
#	app/src/test/kotlin/com/hermesandroid/relay/screenshots/ChangelogHistoryScreenshotTest.kt
#	app/src/test/kotlin/com/hermesandroid/relay/screenshots/WhatsNewToastScreenshotTest.kt
2026-08-31 12:46:18 -04:00
Bailey Dixon 9a40aedb7d chore(android): serialize Windows build lanes 2026-08-31 12:46:03 -04:00
Bailey Dixon 30eb70daa3 Merge pull request #506 from Codename-11/fix/android-whats-new-tests
test(android): refresh v1.14 What's New assertions
2026-08-31 12:14:32 -04:00
Bailey Dixon 6be6cec201 test(android): refresh v1.14 What's New assertions 2026-08-31 12:03:24 -04:00
Bailey Dixon 4f5f3fac10 Merge remote-tracking branch 'origin/dev' into feature/android-release-notes-expansion
# Conflicts:
#	CHANGELOG.md
2026-08-31 11:43:28 -04:00
Bailey Dixon 7f7112caf2 feat(android): expand in-app release notes 2026-08-31 11:42:53 -04:00
Bailey Dixon 4c00e8edb7 Merge pull request #499 from Codename-11/dependabot/github_actions/dev/actions/setup-java-6
chore(deps): bump actions/setup-java from 5 to 6
2026-08-31 11:08:59 -04:00
Bailey Dixon 44f0acec94 Merge pull request #505 from Codename-11/fix/android-bot-chat-binding-lifecycle-refresh
fix(android): render Bot Chat history after binding
2026-08-31 11:08:15 -04:00
dependabot[bot] 41b724e072 chore(deps): bump actions/setup-java from 5 to 6
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 6.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 11:56:27 +00:00
34 changed files with 922 additions and 280 deletions
+4 -4
View File
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
@@ -101,7 +101,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
@@ -151,7 +151,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
@@ -215,7 +215,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
fi
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
+2 -2
View File
@@ -137,7 +137,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
@@ -176,7 +176,7 @@ jobs:
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
- name: Set up JDK 17
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Changed
- **Android What's New now provides a readable, complete release record.** One overall title and summary lead into selected highlights, every remaining user-visible addition, improvement, and fix, and relevant compatibility boundaries. Toast counts and previews are derived from that same inventory, so View all no longer promises details the expanded dialog and history cannot show.
### Fixed
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
+7
View File
@@ -61,6 +61,13 @@ configuration between invocations and do not add `--no-daemon` to normal dev
commands; a different heap or Java home starts a separate daemon and discards
the warm-process benefit.
On Windows, all repository dev helpers serialize Android build and device work
through one machine-wide lane shared by every Hermes-Relay worktree. Use
`scripts/android-lane.ps1` for ad hoc Gradle, connected-test, and APK-install
commands, and keep Android Studio idle while another owner holds the lane. See
[Windows Android build lane](docs/android-build-lane.md) for the coordinator
gate, status command, and exceptional recovery modes.
Use the narrowest command that proves the change:
1. `scripts/dev.bat compile` for a Kotlin compile check.
+22
View File
@@ -1,5 +1,27 @@
# Hermes-Relay — Dev Log
## 2026-08-31 — Complete, readable Android release notes
Android release metadata now keeps one overall title and summary plus a complete
typed inventory of user-visible additions, improvements, and fixes. Stable
change ids prevent duplicate records, selected highlights lead the expanded
view, and compatibility boundaries remain visible without turning the compact
notice into technical release documentation. Toast counts and previews are
derived from the same non-highlighted changes the dialog and history render, so
View all reaches every counted item. Older bundled changelog entries retain
their existing rendering path.
The Android 1.14.0 record was migrated to the complete schema and reconciled
against its released Android changelog: four highlights, two additional
improvements, ten fixes, and three compatibility notes. The dialog, full
history, large-text toast, seven Android resource catalogs, legacy text
fallback, Play note, listing copy, release-prep instructions, and validation
tests were updated together.
Structured release-note tests, focused Kotlin tests, Android locale and
collection-API gates, rendered 360×640 dark-theme screenshots including 135%
text, sideload APK assembly, Android lint, and diff checks passed.
## 2026-08-27 — Fixed issue ownership and bounded PR intake
The automated issue first-response lane now assigns only `Codename-11` when the
+30 -27
View File
@@ -531,13 +531,14 @@ the new app version and a higher `appVersionCode`.
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/changelog.json` — curated source for the in-app
**What's New** dialog and Android release history. Prepend the newest entry
with one explicit `highlight` (`title`, plain-language `summary`, and 1–3
user-benefit bullets), up to two quieter `improvements`, Android-only
`playNotes`, a `toastDigest` with counts and 0–2 short previews for noteworthy
items beyond the hero, and the existing technical `sections` used by older clients.
Do not derive the highlight mechanically from `CHANGELOG.md`; choosing the
release's main reason to care is an editorial release-prep decision.
**What's New** dialog and Android release history. Prepend one schema-3 entry
with a single descriptive release `title`, a plain-language `summary`, and a
complete `changes` inventory. Every user-visible change has a stable `id`, a
`kind` (`added`, `improved`, or `fixed`), a short title, a useful explanation,
and an optional `highlight: true`; select 1–4 highlights. Add `compatibility`
bullets only when users need an availability, migration, flavor, or Plugin
boundary, plus Android-only `playNotes`. The app derives toast counts and
previews from the same inventory and renders every change exactly once.
- `app/src/main/assets/whats_new.txt` — legacy in-app fallback generated from
the newest structured entry. Do not edit it independently.
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
@@ -551,8 +552,8 @@ the new app version and a higher `appVersionCode`.
block and the Gradle Play Publisher note are generated from `playNotes`.
After editing the newest structured entry, run
`python scripts/check-android-release-notes.py --write`, then run it again
without `--write` to validate the 1–3 / 0–2 editorial limits, current Android
version, GitHub-release/changelog headings, derived files, and Play's
without `--write` to validate complete unique change records, 1–4 highlights,
the current Android version, GitHub-release/changelog headings, derived files, and Play's
**500-character** limit. Frame Play copy around the release's themes, not a
feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
@@ -570,28 +571,30 @@ authoring contract; do not maintain a separate prompt file.
boundary that users must understand. Do not generate from commit titles or
a mixed-surface changelog block alone.
2. Before editing release files, show a temporary coverage ledger in the task
output. Map every selected source change to exactly one placement:
`hero`, `secondary`, or `full-only`. Include the change kind (`feature`,
`change`, or `fix`) and a short reason. The ledger is review evidence, not a
committed public artifact; no selected source item may disappear silently.
3. Choose exactly one `hero`: the strongest user-facing reason to care about
the release. Its summary is one plain-language outcome, and its 1–3 bullets
are distinct user benefits rather than implementation steps or filler.
4. Use `secondary` for other important user-visible features and fixes. The
`toastDigest` counts only these items, excluding the hero. Preview the
strongest 1–2 secondary items in short phrases. If there are no legitimate
secondary items, set both counts to `0` and `preview` to `[]`; the app hides
the footer. Never invent an item to satisfy the layout.
5. Use `full-only` for technically relevant details that belong in
`RELEASE_NOTES.md` or `CHANGELOG.md` but would make the collapsed update card
noisy. Preserve user-relevant trust and compatibility limits; omit branches,
worktrees, CI mechanics, debugging history, and private/operator context.
output. Map every selected Android source change to one stable change id and
one kind (`added`, `improved`, or `fixed`), and mark whether it is a
highlight. The ledger is review evidence, not a committed public artifact;
no selected user-visible change may disappear silently or be counted twice.
3. Write one release title that describes the release as a whole. Do not let a
narrow feature name, internal project label, or poetic codename replace the
title users see in the toast and history. Follow it with a one- or two-sentence
summary that gives the release's overall outcome without becoming a feature dump.
4. Select 1–4 highlights from the complete change inventory. A highlight is a
strong reason to care, not a second copy of the change: the app presents it
once in the highlight section and derives the remaining counts and previews
from non-highlighted changes.
5. Include every meaningful user-visible addition, improvement, and fix in
`changes`, using plain titles and enough explanation for someone to recognize
the affected behavior. Internal refactors, tests, CI mechanics, branch work,
and debugging history stay in `RELEASE_NOTES.md`, `CHANGELOG.md`, or engineering
records unless they materially change reliability, security, or compatibility.
6. Write each surface for its audience:
- `RELEASE_NOTES.md`: concise Summary plus Added/Changed/Fixed; keep the
deterministic Download and Install/Verify scaffolding intact.
- `CHANGELOG.md`: complete, crisp public history for the released surface.
- `changelog.json`: curated hero, optional improvements, digest, Play copy,
and compatibility `sections` for older clients.
- `changelog.json`: overall title/summary, complete typed changes, selected
highlights, compatibility boundaries, and Play copy. Counts and previews
are derived; never author a parallel digest.
- `playNotes`: Android-only themes within the rendered 500-character limit.
7. Before presenting the draft, check that wording begins with user outcomes,
avoids unexplained implementation terminology, uses exact public product
@@ -1,3 +1,3 @@
v1.14.0 - Connections that follow you
v1.14.0 - Connections, delegated work, Git, and voice
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
+106 -41
View File
@@ -1,55 +1,120 @@
{
"schema": 2,
"schema": 3,
"versions": [
{
"version": "1.14.0",
"title": "Connections that follow you",
"title": "Connections, delegated work, Git, and voice",
"date": "2026-08-30",
"highlight": {
"title": "Reliable routes",
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard or Relay authentication.",
"bullets": [
"Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.",
"Preview delegated agent work and use the optional native Git workspace.",
"Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls."
]
},
"improvements": [
"Wake-word detection now packages a compatible native runtime for every supported ABI.",
"Protected Relay health checks no longer appear as broken routes."
],
"toastDigest": {
"additionalFeatureCount": 2,
"fixCount": 10,
"preview": [
"delegated-agent previews",
"safer voice and sessions"
]
},
"playNotes": "Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.",
"sections": [
"summary": "Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.",
"changes": [
{
"header": "Use the best available route",
"bullets": [
"Resolve Dashboard, Relay, and optional API health independently across LAN, Tailscale, and public HTTPS.",
"Keep same-origin Relay ingress on the exact Dashboard origin that owns authentication."
]
"id": "route-aware-connections",
"kind": "improved",
"title": "Connections recover independently",
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.",
"highlight": true
},
{
"header": "Follow active work",
"bullets": [
"Preview delegated-agent lifecycle, progress, tools, and available read-only child history from the parent chat.",
"Review Git status, diffs, branches, staging, commits, and remotes through the optional Relay plugin."
]
"id": "delegated-agent-previews",
"kind": "added",
"title": "Follow delegated-agent activity",
"summary": "See lifecycle, progress, tool previews, and available read-only child history from the parent chat.",
"highlight": true
},
{
"header": "Keep voice and conversations owned correctly",
"bullets": [
"Serialize microphone handoff and keep Voice Focus Stop and steering accessible across active phases.",
"Preserve fresh profile drafts, provisional Thread ownership, Clarify answers, passive observation, and Assistant privacy through reconnects."
]
"id": "native-git-workspace",
"kind": "added",
"title": "Work with repositories from Android",
"summary": "Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.",
"highlight": true
},
{
"id": "voice-focus-controls",
"kind": "improved",
"title": "Steer voice at any time",
"summary": "Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.",
"highlight": true
},
{
"id": "non-blocking-release-notice",
"kind": "improved",
"title": "Release notes stay out of your way",
"summary": "A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings."
},
{
"id": "single-chat-presentation",
"kind": "improved",
"title": "Chat uses one consistent presentation",
"summary": "The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available."
},
{
"id": "wake-word-runtime",
"kind": "fixed",
"title": "Wake-word detection starts reliably",
"summary": "Compatible native voice components are now packaged for every supported phone architecture."
},
{
"id": "sphere-motion",
"kind": "fixed",
"title": "The visible Sphere keeps moving smoothly",
"summary": "Foreground animation no longer falls back to a stepped ambient pulse."
},
{
"id": "continuous-microphone-handoff",
"kind": "fixed",
"title": "Continuous voice keeps the microphone",
"summary": "The next listening turn waits for barge-in recording to release cleanly."
},
{
"id": "fresh-profile-drafts",
"kind": "fixed",
"title": "New chats keep the selected profile",
"summary": "Fresh drafts no longer reopen an older session or carry a Thread route into another profile."
},
{
"id": "provisional-thread-removal",
"kind": "fixed",
"title": "Provisional Threads can be removed safely",
"summary": "Local removal and later session promotion no longer risk duplicate rows or server history."
},
{
"id": "clarify-custom-answers",
"kind": "fixed",
"title": "Clarify keeps custom answers reachable",
"summary": "Other answers, keyboard Send, and expired prompts now behave consistently."
},
{
"id": "passive-session-observation",
"kind": "fixed",
"title": "Browsing no longer interrupts another client",
"summary": "Passive Android observation does not claim a turn owned by Desktop, TUI, or another client."
},
{
"id": "assistant-recovery-privacy",
"kind": "fixed",
"title": "Assistant sessions recover more clearly",
"summary": "No-speech feedback, recreated session state, and keyguard privacy now remain intact."
},
{
"id": "relay-auth-boundaries",
"kind": "fixed",
"title": "Protected Relay routes report the right problem",
"summary": "Authentication challenges are no longer presented as outages, while unsafe routes still fail closed."
},
{
"id": "connection-session-readiness",
"kind": "fixed",
"title": "Connections and sessions become ready sooner",
"summary": "Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history."
}
]
],
"compatibility": [
"Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.",
"The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.",
"Granular Device Control and the system Voice Focus overlay remain available only in the sideload build."
],
"playNotes": "Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.",
"sections": []
},
{
"version": "1.13.2",
+29 -8
View File
@@ -1,10 +1,31 @@
v1.14.0 - Connections that follow you
v1.14.0 - Connections, delegated work, Git, and voice
Reliable routes
* Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.
* Preview delegated agent work and use the optional native Git workspace.
* Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls.
Summary
* Connections now recover cleanly across networks. You can also follow delegated agents, work with Git repositories, and rely on steadier voice, sessions, Threads, profiles, Assistant, and Clarify controls.
Also improved
* Wake-word detection now packages a compatible native runtime for every supported ABI.
* Protected Relay health checks no longer appear as broken routes.
Highlights
* Connections recover independently — Move between LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication.
* Follow delegated-agent activity — See lifecycle, progress, tool previews, and available read-only child history from the parent chat.
* Work with repositories from Android — Review status, diffs, branches, staging, commits, and remotes from Chat or Settings.
* Steer voice at any time — Stop or redirect Hermes while it is Thinking, Transcribing, or Speaking, including with accessibility controls.
Improved
* Release notes stay out of your way — A dismissible post-update notice keeps startup usable and leaves the complete history available from Settings.
* Chat uses one consistent presentation — The overlapping clean-focus mode was removed while the separate Voice Focus experience remains available.
Fixed
* Wake-word detection starts reliably — Compatible native voice components are now packaged for every supported phone architecture.
* The visible Sphere keeps moving smoothly — Foreground animation no longer falls back to a stepped ambient pulse.
* Continuous voice keeps the microphone — The next listening turn waits for barge-in recording to release cleanly.
* New chats keep the selected profile — Fresh drafts no longer reopen an older session or carry a Thread route into another profile.
* Provisional Threads can be removed safely — Local removal and later session promotion no longer risk duplicate rows or server history.
* Clarify keeps custom answers reachable — Other answers, keyboard Send, and expired prompts now behave consistently.
* Browsing no longer interrupts another client — Passive Android observation does not claim a turn owned by Desktop, TUI, or another client.
* Assistant sessions recover more clearly — No-speech feedback, recreated session state, and keyguard privacy now remain intact.
* Protected Relay routes report the right problem — Authentication challenges are no longer presented as outages, while unsafe routes still fail closed.
* Connections and sessions become ready sooner — Unavailable optional API and Relay routes no longer delay a healthy Dashboard or authenticated session history.
Compatibility
* Standard Chat, sessions, profiles, Manage, and standard voice continue to work without the optional Hermes-Relay Plugin.
* The Git workspace and same-origin Relay extensions require Hermes-Relay Plugin 1.11.0.
* Granular Device Control and the system Voice Focus overlay remain available only in the sideload build.
@@ -294,7 +294,55 @@ fun VersionNotesBlock(
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val highlight = entry.highlight
if (highlight == null) {
if (entry.changes.isNotEmpty()) {
if (showVersionLine) {
Text(
text = entry.versionLine(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
entry.summary?.takeIf { it.isNotBlank() }?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
val highlights = entry.highlightedChanges()
if (highlights.isNotEmpty()) {
ReleaseSectionTitle(stringResource(R.string.changelog_highlights))
ReleaseChangeList(highlights)
}
listOf(
CHANGE_KIND_ADDED to R.string.changelog_added,
CHANGE_KIND_IMPROVED to R.string.changelog_improved,
CHANGE_KIND_FIXED to R.string.changelog_fixed,
).forEach { (kind, label) ->
val changes = entry.remainingChangesOfKind(kind)
if (changes.isNotEmpty()) {
ReleaseSectionTitle(stringResource(label))
ReleaseChangeList(changes)
}
}
if (entry.compatibility.isNotEmpty()) {
ReleaseSectionTitle(stringResource(R.string.changelog_compatibility))
VersionNotesBody(
listOf(WhatsNewGroup(header = null, bullets = entry.compatibility)),
)
}
} else if (highlight == null) {
if (showVersionLine) {
Text(
text = entry.subtitle(),
@@ -340,6 +388,47 @@ fun VersionNotesBlock(
}
}
@Composable
private fun ReleaseSectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 6.dp),
)
}
@Composable
private fun ColumnScope.ReleaseChangeList(changes: List<ChangelogChange>) {
changes.forEach { change ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "•",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = change.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
change.summary?.takeIf { it.isNotBlank() }?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// ──────────────────────────────────────────────────────────────────────────
// Structured changelog model + loader (kotlinx.serialization).
// ──────────────────────────────────────────────────────────────────────────
@@ -363,16 +452,37 @@ data class ChangelogHighlight(
@Serializable
data class ChangelogToastDigest(
val additionalFeatureCount: Int = 0,
val improvementCount: Int = 0,
val fixCount: Int = 0,
val preview: List<String> = emptyList(),
)
const val CHANGE_KIND_ADDED = "added"
const val CHANGE_KIND_IMPROVED = "improved"
const val CHANGE_KIND_FIXED = "fixed"
/** One complete, user-visible change in a release. */
@Serializable
data class ChangelogChange(
val id: String,
val kind: String,
val title: String,
val summary: String? = null,
val highlight: Boolean = false,
) {
fun fallbackText(): String =
summary?.takeIf { it.isNotBlank() }?.let { "$title — $it" } ?: title
}
/** A single released version's user-facing notes. */
@Serializable
data class ChangelogVersion(
val version: String,
val title: String? = null,
val date: String? = null,
val summary: String? = null,
val changes: List<ChangelogChange> = emptyList(),
val compatibility: List<String> = emptyList(),
val highlight: ChangelogHighlight? = null,
val improvements: List<String> = emptyList(),
val toastDigest: ChangelogToastDigest? = null,
@@ -393,8 +503,48 @@ data class ChangelogVersion(
return "v$version$datePart"
}
fun highlightedChanges(): List<ChangelogChange> = changes.filter { it.highlight }
fun remainingChanges(): List<ChangelogChange> = changes.filterNot { it.highlight }
fun remainingChangesOfKind(kind: String): List<ChangelogChange> =
remainingChanges().filter { it.kind == kind }
/** Derive compact toast metadata from the same changes the expanded view renders. */
fun resolvedToastDigest(): ChangelogToastDigest? {
if (changes.isEmpty()) return toastDigest
val remaining = remainingChanges()
if (remaining.isEmpty()) return null
return ChangelogToastDigest(
additionalFeatureCount = remaining.count { it.kind == CHANGE_KIND_ADDED },
improvementCount = remaining.count { it.kind == CHANGE_KIND_IMPROVED },
fixCount = remaining.count { it.kind == CHANGE_KIND_FIXED },
preview = remaining.take(2).map { it.title },
)
}
fun toGroups(): List<WhatsNewGroup> =
highlight?.let { curated ->
if (changes.isNotEmpty()) {
buildList {
val highlights = highlightedChanges()
if (highlights.isNotEmpty()) {
add(WhatsNewGroup("Highlights", highlights.map { it.fallbackText() }))
}
listOf(
CHANGE_KIND_ADDED to "Added",
CHANGE_KIND_IMPROVED to "Improved",
CHANGE_KIND_FIXED to "Fixed",
).forEach { (kind, label) ->
val items = remainingChangesOfKind(kind)
if (items.isNotEmpty()) {
add(WhatsNewGroup(label, items.map { it.fallbackText() }))
}
}
if (compatibility.isNotEmpty()) {
add(WhatsNewGroup("Compatibility", compatibility))
}
}
} else highlight?.let { curated ->
buildList {
add(WhatsNewGroup(curated.title.takeIf { it.isNotBlank() }, curated.bullets))
if (improvements.isNotEmpty()) {
@@ -185,6 +185,10 @@ internal fun WhatsNewToastContent(
) {
val title = stringResource(R.string.whats_new_title)
val highlight = entry.highlight
val digest = entry.resolvedToastDigest()
val releaseTitle = entry.title?.takeIf(String::isNotBlank) ?: highlight?.title.orEmpty()
val releaseSummary = entry.summary?.takeIf(String::isNotBlank)
?: highlight?.summary?.takeIf(String::isNotBlank)
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
@@ -220,14 +224,14 @@ internal fun WhatsNewToastContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = highlight?.title ?: entry.title.orEmpty(),
text = releaseTitle,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
highlight?.summary?.takeIf(String::isNotBlank)?.let { summary ->
releaseSummary?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
@@ -251,9 +255,9 @@ internal fun WhatsNewToastContent(
)
}
}
entry.toastDigest?.takeIf {
it.additionalFeatureCount > 0 || it.fixCount > 0
}?.let { digest ->
digest?.takeIf {
it.additionalFeatureCount > 0 || it.improvementCount > 0 || it.fixCount > 0
}?.let { resolvedDigest ->
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f),
@@ -272,21 +276,30 @@ internal fun WhatsNewToastContent(
) {
Column(modifier = Modifier.weight(1f)) {
val counts = buildList {
if (digest.additionalFeatureCount > 0) {
if (resolvedDigest.additionalFeatureCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_additional_feature_count,
digest.additionalFeatureCount,
digest.additionalFeatureCount,
resolvedDigest.additionalFeatureCount,
resolvedDigest.additionalFeatureCount,
),
)
}
if (digest.fixCount > 0) {
if (resolvedDigest.improvementCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_improvement_count,
resolvedDigest.improvementCount,
resolvedDigest.improvementCount,
),
)
}
if (resolvedDigest.fixCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_fix_count,
digest.fixCount,
digest.fixCount,
resolvedDigest.fixCount,
resolvedDigest.fixCount,
),
)
}
@@ -298,11 +311,11 @@ internal fun WhatsNewToastContent(
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = digest.preview.joinToString(", ") + "…",
text = resolvedDigest.preview.joinToString(", ") + "…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
+10 -1
View File
@@ -584,6 +584,11 @@
<string name="changelog_state_collapsed">Recolhido</string>
<string name="changelog_installed">Instalado</string>
<string name="changelog_also_improved">Também melhorado</string>
<string name="changelog_highlights">Destaques</string>
<string name="changelog_added">Adicionado</string>
<string name="changelog_improved">Melhorado</string>
<string name="changelog_fixed">Corrigido</string>
<string name="changelog_compatibility">Compatibilidade</string>
<string name="changelog_toast_also">Também: %1$s</string>
<string name="changelog_view_all">Ver tudo</string>
<plurals name="changelog_additional_feature_count">
@@ -594,6 +599,10 @@
<item quantity="one">%1$d correção</item>
<item quantity="other">%1$d correções</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d melhoria</item>
<item quantity="other">%1$d melhorias</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Voltar</string>
@@ -3239,7 +3248,7 @@
<string name="whats_new_full_history">Ver histórico completo</string>
<string name="whats_new_no_notes">Nenhuma nota de versão disponível</string>
<string name="whats_new_title">Novidades</string>
<string name="whats_new_subtitle">Destaques da versão mais recente</string>
<string name="whats_new_subtitle">Destaques e detalhes completos da versão</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Acesso temporário à tela expirou</string>
<string name="bridge_notification_auto_disabled_body">Inspeção e controle da tela foram desativados após ociosidade. As outras permissões não mudaram.</string>
@@ -621,6 +621,11 @@
<string name="changelog_state_collapsed">已收起</string>
<string name="changelog_installed">已安装</string>
<string name="changelog_also_improved">其他改进</string>
<string name="changelog_highlights">亮点</string>
<string name="changelog_added">新增</string>
<string name="changelog_improved">改进</string>
<string name="changelog_fixed">修复</string>
<string name="changelog_compatibility">兼容性</string>
<string name="changelog_toast_also">另外:%1$s</string>
<string name="changelog_view_all">查看全部</string>
<plurals name="changelog_additional_feature_count">
@@ -629,6 +634,9 @@
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 项修复</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="other">%1$d 项改进</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">诊断</string>
@@ -3339,7 +3347,7 @@
<string name="whats_new_full_history">查看完整历史记录</string>
<string name="whats_new_no_notes">无可用的发布说明</string>
<string name="whats_new_title">更新内容</string>
<string name="whats_new_subtitle">最新版本亮点</string>
<string name="whats_new_subtitle">亮点和完整版本详情</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">限时屏幕访问已到期</string>
<string name="bridge_notification_auto_disabled_body">空闲后,屏幕检查和控制已关闭。其他授权保持不变。</string>
+10 -1
View File
@@ -621,6 +621,11 @@
<string name="changelog_state_collapsed">Eingeklappt</string>
<string name="changelog_installed">Installiert</string>
<string name="changelog_also_improved">Außerdem verbessert</string>
<string name="changelog_highlights">Highlights</string>
<string name="changelog_added">Hinzugefügt</string>
<string name="changelog_improved">Verbessert</string>
<string name="changelog_fixed">Behoben</string>
<string name="changelog_compatibility">Kompatibilität</string>
<string name="changelog_toast_also">Außerdem: %1$s</string>
<string name="changelog_view_all">Alle anzeigen</string>
<plurals name="changelog_additional_feature_count">
@@ -631,6 +636,10 @@
<item quantity="one">%1$d Fehlerbehebung</item>
<item quantity="other">%1$d Fehlerbehebungen</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d Verbesserung</item>
<item quantity="other">%1$d Verbesserungen</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnose</string>
@@ -3407,7 +3416,7 @@
<string name="whats_new_full_history">Vollständigen Verlauf anzeigen</string>
<string name="whats_new_no_notes">Keine Versionshinweise verfügbar</string>
<string name="whats_new_title">Neuigkeiten</string>
<string name="whats_new_subtitle">Highlights der neuesten Version</string>
<string name="whats_new_subtitle">Highlights und vollständige Versionsdetails</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Zeitgesteuerter Bildschirmzugriff abgelaufen</string>
<string name="bridge_notification_auto_disabled_body">Bildschirmprüfung und -steuerung sind nach Inaktivität aus. Andere Berechtigungen bleiben unverändert.</string>
+10 -1
View File
@@ -556,6 +556,11 @@
<string name="changelog_state_collapsed">Contraído</string>
<string name="changelog_installed">Instalada</string>
<string name="changelog_also_improved">También mejorado</string>
<string name="changelog_highlights">Destacados</string>
<string name="changelog_added">Añadido</string>
<string name="changelog_improved">Mejorado</string>
<string name="changelog_fixed">Corregido</string>
<string name="changelog_compatibility">Compatibilidad</string>
<string name="changelog_toast_also">También: %1$s</string>
<string name="changelog_view_all">Ver todo</string>
<plurals name="changelog_additional_feature_count">
@@ -566,6 +571,10 @@
<item quantity="one">%1$d corrección</item>
<item quantity="other">%1$d correcciones</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d mejora</item>
<item quantity="other">%1$d mejoras</item>
</plurals>
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Atrás</string>
<string name="diag_status">Estado</string>
@@ -3071,7 +3080,7 @@
<string name="whats_new_full_history">Ver historial completo</string>
<string name="whats_new_no_notes">No hay notas de versión disponibles</string>
<string name="whats_new_title">¿Qué hay de nuevo?</string>
<string name="whats_new_subtitle">Novedades de la última versión</string>
<string name="whats_new_subtitle">Destacados y detalles completos de la versión</string>
<string name="bridge_notification_auto_disabled_title">Acceso de pantalla temporizado vencido</string>
<string name="bridge_notification_auto_disabled_body">La inspección y el control de pantalla están desactivados tras la inactividad. Los demás permisos no cambian.</string>
<string name="bridge_notification_control_title">El agente Hermes tiene control del dispositivo.</string>
+9 -1
View File
@@ -621,6 +621,11 @@
<string name="changelog_state_collapsed">折りたたみ済み</string>
<string name="changelog_installed">インストール済み</string>
<string name="changelog_also_improved">その他の改善</string>
<string name="changelog_highlights">ハイライト</string>
<string name="changelog_added">追加</string>
<string name="changelog_improved">改善</string>
<string name="changelog_fixed">修正</string>
<string name="changelog_compatibility">互換性</string>
<string name="changelog_toast_also">その他: %1$s</string>
<string name="changelog_view_all">すべて表示</string>
<plurals name="changelog_additional_feature_count">
@@ -629,6 +634,9 @@
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 件の修正</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="other">%1$d 件の改善</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">診断</string>
@@ -3415,7 +3423,7 @@
<string name="whats_new_full_history">全履歴を表示</string>
<string name="whats_new_no_notes">リリースノートはありません</string>
<string name="whats_new_title">新機能</string>
<string name="whats_new_subtitle">最新リリースのハイライト</string>
<string name="whats_new_subtitle">ハイライトと完全なリリース詳細</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">時間制限付き画面アクセスの期限切れ</string>
<string name="bridge_notification_auto_disabled_body">操作がなかったため、画面の検査と操作をオフにしました。他の許可は変更されません。</string>
+12 -1
View File
@@ -599,6 +599,11 @@
<string name="changelog_state_collapsed">Свернуто</string>
<string name="changelog_installed">Установлено</string>
<string name="changelog_also_improved">Также улучшено</string>
<string name="changelog_highlights">Основное</string>
<string name="changelog_added">Добавлено</string>
<string name="changelog_improved">Улучшено</string>
<string name="changelog_fixed">Исправлено</string>
<string name="changelog_compatibility">Совместимость</string>
<string name="changelog_toast_also">Также: %1$s</string>
<string name="changelog_view_all">Показать все</string>
<plurals name="changelog_additional_feature_count">
@@ -613,6 +618,12 @@
<item quantity="many">%1$d исправлений</item>
<item quantity="other">%1$d исправления</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d улучшение</item>
<item quantity="few">%1$d улучшения</item>
<item quantity="many">%1$d улучшений</item>
<item quantity="other">%1$d улучшения</item>
</plurals>
<string name="diag_title">Диагностика</string>
<string name="diag_back">Назад</string>
<string name="diag_status">Статус</string>
@@ -3275,7 +3286,7 @@
<string name="whats_new_full_history">Просмотреть полную историю</string>
<string name="whats_new_no_notes">Примечания к выпуску недоступны</string>
<string name="whats_new_title">Что нового</string>
<string name="whats_new_subtitle">Последние обновления</string>
<string name="whats_new_subtitle">Основное и полный список изменений</string>
<string name="bridge_notification_auto_disabled_title">Временный доступ к экрану истек</string>
<string name="bridge_notification_auto_disabled_body">Просмотр и управление экраном отключены после бездействия. Другие разрешения не изменены.</string>
<string name="bridge_notification_control_title">Агент Гермес имеет управление устройством</string>
+10 -1
View File
@@ -666,6 +666,11 @@
<string name="changelog_state_collapsed">Collapsed</string>
<string name="changelog_installed">Installed</string>
<string name="changelog_also_improved">Also improved</string>
<string name="changelog_highlights">Highlights</string>
<string name="changelog_added">Added</string>
<string name="changelog_improved">Improved</string>
<string name="changelog_fixed">Fixed</string>
<string name="changelog_compatibility">Compatibility</string>
<string name="changelog_toast_also">Also: %1$s</string>
<string name="changelog_view_all">View all</string>
<plurals name="changelog_additional_feature_count">
@@ -676,6 +681,10 @@
<item quantity="one">%1$d fix</item>
<item quantity="other">%1$d fixes</item>
</plurals>
<plurals name="changelog_improvement_count">
<item quantity="one">%1$d improvement</item>
<item quantity="other">%1$d improvements</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnostics</string>
@@ -3781,7 +3790,7 @@
<string name="whats_new_full_history">View full history</string>
<string name="whats_new_no_notes">No release notes available</string>
<string name="whats_new_title">What\u0027s new</string>
<string name="whats_new_subtitle">Latest release highlights</string>
<string name="whats_new_subtitle">Highlights and complete release details</string>
<!-- Localization guardrail additions for current dev surfaces -->
<string name="bridge_notification_auto_disabled_title">Timed screen access expired</string>
<string name="bridge_notification_auto_disabled_body">Screen inspection and control are off after being idle. Other grants are unchanged.</string>
@@ -19,13 +19,17 @@ import org.robolectric.annotation.GraphicsMode
class ChangelogHistoryScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun latestReleaseIsCuratedAndInstalled() {
@Test fun latestReleaseShowsHighlightsAndCompleteDetails() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("v1.14.0 — Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("Highlights").assertExists()
compose.onNodeWithText("Fixed").assertExists()
compose.onNodeWithText("Wake-word detection starts reliably").assertExists()
compose.onNodeWithText("Compatibility").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
}
@@ -61,9 +61,11 @@ class WhatsNewToastScreenshotTest {
}
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Also: 2 fixes").assertExists()
compose.onNodeWithText("Accurate activity, safer return…").assertExists()
compose.onNodeWithText("Connections, delegated work, Git, and voice").assertExists()
compose.onNodeWithText("Also: 2 improvements · 10 fixes").assertExists()
compose.onNodeWithText(
"Release notes stay out of your way, Chat uses one consistent presentation…",
).assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithContentDescription("Close").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/whats-new-toast-large-text.png")
@@ -84,7 +86,7 @@ class WhatsNewToastScreenshotTest {
}
}
compose.onNodeWithText("Supervised Mode").performClick()
compose.onNodeWithText("Connections, delegated work, Git, and voice").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithContentDescription("Close").performClick()
@@ -106,7 +108,8 @@ class WhatsNewToastScreenshotTest {
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Supervised Mode").performTouchInput { swipeLeft(durationMillis = 300L) }
compose.onNodeWithText("Connections, delegated work, Git, and voice")
.performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
assertTrue(dismissed)
@@ -37,26 +37,25 @@ class ChangelogParserTest {
}
@Test
fun parsesCuratedHighlightAndImprovements() {
fun parsesCompleteReleaseAndDerivesToastDigest() {
val raw = """
{
"schema": 2,
"schema": 3,
"versions": [
{
"version": "1.2.0",
"title": "A useful release",
"date": "2026-06-20",
"highlight": {
"title": "The main reason to care",
"summary": "A plain-language explanation.",
"bullets": ["a", "b", "c"]
},
"improvements": ["d", "e"],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"]
},
"summary": "A plain-language explanation.",
"changes": [
{"id": "new-one", "kind": "added", "title": "New one",
"summary": "Use the new thing.", "highlight": true},
{"id": "better-one", "kind": "improved", "title": "Better one",
"summary": "The old thing is easier."},
{"id": "fixed-one", "kind": "fixed", "title": "Fixed one",
"summary": "The broken thing works."}
],
"compatibility": ["Existing connections keep working."],
"playNotes": "Concise Play copy."
}
]
@@ -64,17 +63,41 @@ class ChangelogParserTest {
""".trimIndent()
val entry = ChangelogStore.parse(raw).versions.single()
val digest = entry.resolvedToastDigest()
assertEquals("The main reason to care", entry.highlight?.title)
assertEquals("A plain-language explanation.", entry.highlight?.summary)
assertEquals(listOf("a", "b", "c"), entry.highlight?.bullets)
assertEquals(listOf("d", "e"), entry.improvements)
assertEquals(1, entry.toastDigest?.additionalFeatureCount)
assertEquals(2, entry.toastDigest?.fixCount)
assertEquals(listOf("Secondary feature", "Important fix"), entry.toastDigest?.preview)
assertEquals("A plain-language explanation.", entry.summary)
assertEquals(listOf("New one"), entry.highlightedChanges().map { it.title })
assertEquals(0, digest?.additionalFeatureCount)
assertEquals(1, digest?.improvementCount)
assertEquals(1, digest?.fixCount)
assertEquals(listOf("Better one", "Fixed one"), digest?.preview)
assertEquals(listOf("Existing connections keep working."), entry.compatibility)
assertEquals("Concise Play copy.", entry.playNotes)
assertEquals("v1.2.0 · 2026-06-20", entry.versionLine())
assertEquals(listOf("The main reason to care", "Also improved"), entry.toGroups().map { it.header })
assertEquals(
listOf("Highlights", "Improved", "Fixed", "Compatibility"),
entry.toGroups().map { it.header },
)
assertEquals(3, entry.toGroups().sumOf { it.bullets.size } - entry.compatibility.size)
}
@Test
fun completeReleaseRendersEveryChangeOnce() {
val entry = ChangelogVersion(
version = "1.2.0",
title = "Complete notes",
summary = "Everything users need to know.",
changes = listOf(
ChangelogChange("a", CHANGE_KIND_ADDED, "First", "First detail", highlight = true),
ChangelogChange("b", CHANGE_KIND_FIXED, "Second", "Second detail"),
),
)
val rendered = entry.toGroups().flatMap { it.bullets }
assertEquals(2, rendered.size)
assertEquals(1, rendered.count { it.startsWith("First") })
assertEquals(1, rendered.count { it.startsWith("Second") })
}
@Test
+98
View File
@@ -0,0 +1,98 @@
# Windows Android build lane
Hermes-Relay uses one machine-wide Android build lane on Windows. Every local
worktree shares the same named operating-system mutex through
`scripts/android-lane.ps1`. When commands use the wrapper, Gradle, Kotlin
compilation, lint, tests, assemblies, connected tests, and APK installation do
not compete for the same host resources and shared caches.
This is a local developer workflow. CI jobs run on separate hosts and are not
affected.
## Normal use
The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire
the lane automatically. For an ad hoc Gradle command, use:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:testSideloadDebugUnitTest `
--tests "*ChatViewModelTest*" `
--console=plain
```
The wrapper waits until the current owner exits and releases the mutex. Windows
also releases the mutex if its owner crashes; the next waiter reports that it
recovered an abandoned lane. Use Ctrl+C to cancel a waiter without stopping the
owner. A bounded wait is available when a caller has its own deadline:
```powershell
.\scripts\android-lane.ps1 --timeout-seconds 900 gradle :app:lintSideloadDebug
```
Check the lane without starting work:
```powershell
.\scripts\android-lane.ps1 status
```
`status` exits 0 when idle and 1 when busy. It intentionally does not expose the
owning process's arguments because Gradle properties can contain credentials.
Use `exec` for a connected/device workflow that must exclude every
wrapper-managed Gradle lane, including explicit APK installation:
```powershell
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py <arguments>
.\scripts\android-lane.ps1 exec adb -t <transport-id> install -r <apk>
```
Raw `gradlew`, Android Studio sync/build/run, and raw `adb install` do not pass
through the mutex. Before using one of those paths, check that the lane is idle
and keep all queued wrappers idle until it finishes. During final integration,
close or pause Android Studio's automatic Gradle activity and let one
coordinator own the sequence.
## Final coordinator gate
Run the final gates serially on the exact candidate head. Keep each focused
test invocation narrow so it releases the lane promptly for other queued work.
The coordinator then runs, in order:
1. Focused sideload unit tests.
2. Focused Google Play unit and policy tests.
3. Android lint.
4. Sideload and Google Play assemblies (or the release-equivalent both-flavor gate).
5. Connected/instrumentation and physical-device checks when the change needs them.
6. The explicit APK install only after every preceding gate passes.
Do not run lint, both-flavor builds, connected checks, or installation in
parallel. A timeout from an outer agent or terminal is not evidence that Gradle
failed; use the wrapper's process exit and Gradle output as the result.
## Resource policy
The normal serialized path retains the repository defaults:
- the shared Gradle user home, wrapper distributions, dependency cache, build
cache, configuration cache, and compatible warm daemon;
- `org.gradle.jvmargs=-Xmx4g`, `org.gradle.daemon=true`,
`org.gradle.caching=true`, `org.gradle.configuration-cache=true`, and
`org.gradle.parallel=true`;
- Gradle's normal worker selection inside the one active invocation.
`--max-workers` limits workers only inside one Gradle invocation. It does not
prevent another worktree, Android Studio, or an APK-install lane from running.
Likewise, `--no-daemon` can still start a single-use daemon to honor the required
JVM settings; it is not a cross-process isolation mechanism.
Use a worktree-specific `GRADLE_USER_HOME`, `--no-build-cache`,
`--max-workers=1`, or `-Pkotlin.compiler.execution.strategy=in-process` only to
recover from a confirmed daemon/cache failure or for an explicitly isolated
exception. Those modes discard normal cache and warm-daemon benefits, can use
more disk and memory, and must still acquire the Android lane.
If recovery is required, first preserve the failing output and verify that no
other owner is active. Do not kill unrelated Java, Gradle, Kotlin, Android
Studio, or ADB processes merely to clear the lane; an OS-abandoned mutex is
recoverable automatically.
+3 -3
View File
@@ -111,8 +111,8 @@ turn is live. The external lane consumes the shared Python fixture and reads
its authoritative history over HTTP.
```powershell
.\gradlew.bat :app:compileSideloadDebugAndroidTestKotlin
.\gradlew.bat :app:assembleSideloadDebug :app:assembleSideloadDebugAndroidTest
.\scripts\android-lane.ps1 gradle :app:compileSideloadDebugAndroidTestKotlin
.\scripts\android-lane.ps1 gradle :app:assembleSideloadDebug :app:assembleSideloadDebugAndroidTest
```
The external test is opt-in through the instrumentation argument
@@ -126,7 +126,7 @@ transport ID, targets only `com.axiomlabs.hermesrelay.sideload`, and performs no
installation unless its corresponding install flag is supplied.
```powershell
python scripts/android-gateway-certify.py `
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py `
--transport-id <adb-transport-id> `
--apk app/build/outputs/apk/sideload/debug/<sideload-apk> `
--test-apk app/build/outputs/apk/androidTest/sideload/debug/<test-apk> `
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8c6660316b05c0ae69e9f4fbdb468e52c2c011bfafe24f090fcdb6862c2f836b",
"main": "2d4e3945dfec3963b26055bccfdfccc2002dc2f296f114a3a5d1d050878c5d76",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1 -1
View File
@@ -91,7 +91,7 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.14.0 - Connections that follow you
v1.14.0 - Connections, delegated work, Git, and voice
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
```
+141
View File
@@ -0,0 +1,141 @@
# Hermes-Relay's machine-wide Windows lane for Gradle and Android device work.
# Every worktree uses the same named mutex. Keep this script dependency-free so
# it can run under the Windows PowerShell bundled with Windows.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$mutexName = "Global\HermesRelay.AndroidBuildLane.v1"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Show-Usage {
Write-Host @"
Usage:
.\scripts\android-lane.ps1 status
.\scripts\android-lane.ps1 [--timeout-seconds <seconds>] gradle <gradle-args...>
.\scripts\android-lane.ps1 [--timeout-seconds <seconds>] exec <command> <args...>
Examples:
.\scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --tests "*ChatViewModelTest*"
.\scripts\android-lane.ps1 exec python scripts/android-gateway-certify.py --help
The default wait is unbounded. Ctrl+C cancels a waiter without affecting the
current owner. Use exec for connected-test or APK-install workflows that must
serialize with wrapper-managed Gradle lanes. Keep Android Studio idle separately.
"@
}
function New-AndroidLaneMutex {
try {
return [System.Threading.Mutex]::new($false, $mutexName)
}
catch [System.UnauthorizedAccessException] {
throw "The machine-wide Hermes-Relay Android lane exists but this Windows account cannot open it. Run all worktrees under the same account or correct the mutex permissions."
}
}
function Test-AcquireMutex {
param(
[System.Threading.Mutex] $Mutex,
[TimeSpan] $Wait
)
try {
return $Mutex.WaitOne($Wait)
}
catch [System.Threading.AbandonedMutexException] {
Write-Warning "The previous Android lane owner exited without releasing the mutex; ownership was recovered."
return $true
}
}
$tokens = @($args)
if ($tokens.Count -eq 0 -or $tokens[0] -in @("help", "--help", "-h")) {
Show-Usage
exit 0
}
$timeoutSeconds = 0
if ($tokens[0] -eq "--timeout-seconds") {
if ($tokens.Count -lt 3) {
throw "--timeout-seconds requires a non-negative integer and a mode."
}
$parsedTimeout = 0
if (-not [int]::TryParse($tokens[1], [ref] $parsedTimeout) -or $parsedTimeout -lt 0) {
throw "--timeout-seconds must be a non-negative integer."
}
$timeoutSeconds = $parsedTimeout
$tokens = @($tokens[2..($tokens.Count - 1)])
}
$mode = $tokens[0]
$mutex = New-AndroidLaneMutex
$acquired = $false
try {
if ($mode -eq "status") {
$acquired = Test-AcquireMutex -Mutex $mutex -Wait ([TimeSpan]::Zero)
if ($acquired) {
Write-Host "Hermes-Relay Android lane: IDLE"
$mutex.ReleaseMutex()
$acquired = $false
exit 0
}
Write-Host "Hermes-Relay Android lane: BUSY"
exit 1
}
if ($mode -notin @("gradle", "exec")) {
throw "Unknown mode '$mode'. Use status, gradle, or exec."
}
if ($mode -eq "exec" -and $tokens.Count -lt 2) {
throw "exec requires a command."
}
$waitDescription = if ($timeoutSeconds -eq 0) { "without a timeout" } else { "for up to $timeoutSeconds seconds" }
Write-Host "Waiting for the machine-wide Hermes-Relay Android lane $waitDescription..."
$startedWaiting = [System.Diagnostics.Stopwatch]::StartNew()
while (-not $acquired) {
$sliceSeconds = 30
if ($timeoutSeconds -gt 0) {
$remaining = $timeoutSeconds - [int][Math]::Floor($startedWaiting.Elapsed.TotalSeconds)
if ($remaining -le 0) {
throw "Timed out waiting for the machine-wide Hermes-Relay Android lane after $timeoutSeconds seconds."
}
$sliceSeconds = [Math]::Min($sliceSeconds, $remaining)
}
$acquired = Test-AcquireMutex -Mutex $mutex -Wait ([TimeSpan]::FromSeconds($sliceSeconds))
if (-not $acquired) {
Write-Host "Still waiting for the Hermes-Relay Android lane ($([int]$startedWaiting.Elapsed.TotalSeconds)s elapsed)..."
}
}
Write-Host "Acquired the machine-wide Hermes-Relay Android lane."
Push-Location $repoRoot
try {
if ($mode -eq "gradle") {
$executable = Join-Path $repoRoot "gradlew.bat"
$commandArguments = if ($tokens.Count -gt 1) { @($tokens[1..($tokens.Count - 1)]) } else { @() }
}
else {
$executable = $tokens[1]
$commandArguments = if ($tokens.Count -gt 2) { @($tokens[2..($tokens.Count - 1)]) } else { @() }
}
& $executable @commandArguments
$commandExitCode = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
}
finally {
Pop-Location
}
exit $commandExitCode
}
finally {
if ($acquired) {
$mutex.ReleaseMutex()
Write-Host "Released the machine-wide Hermes-Relay Android lane."
}
$mutex.Dispose()
}
+14 -4
View File
@@ -86,13 +86,23 @@ def main() -> int:
print("\nAndroid repository checks passed.")
return 0
wrapper = REPO_ROOT / ("gradlew.bat" if sys.platform == "win32" else "gradlew")
gradle = [
str(wrapper),
if sys.platform == "win32":
gradle = [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(REPO_ROOT / "scripts" / "android-lane.ps1"),
"gradle",
]
else:
gradle = [str(REPO_ROOT / "gradlew")]
gradle.extend([
"--console=plain",
"--configuration-cache",
*tasks,
]
])
if not args.skip_tests:
for test_name in FOCUSED_TESTS:
gradle.extend(("--tests", test_name))
+3 -3
View File
@@ -68,9 +68,9 @@ echo ""
echo " Next steps:"
echo " 1. Update CHANGELOG.md, RELEASE_NOTES.md, and prepend the curated Android"
echo " release entry to app/src/main/assets/changelog.json. The newest entry"
echo " requires highlight {title,summary,bullets[1..3]}, improvements[0..2],"
echo " toastDigest {additionalFeatureCount,fixCount,preview[0..2]}, playNotes,"
echo " and the technical sections retained for history."
echo " requires one overall title + summary, a complete changes[] inventory"
echo " with unique ids/kinds/titles/explanations and 1..4 highlights, optional"
echo " compatibility bullets, and playNotes. Toast counts/previews are derived."
echo " 2. Refresh and validate the derived in-app fallback + Play notes:"
echo " python scripts/check-android-release-notes.py --write"
echo " python scripts/check-android-release-notes.py"
+72 -62
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""Validate and synchronize Android release-note surfaces.
The newest ``changelog.json`` entry is the curated source for the in-app
What's New experience. ``--write`` refreshes the legacy text fallback, Google
Play notes, and the matching operator copy in ``docs/play-store-listing.md``.
The technical CHANGELOG and GitHub release body remain fuller records, but
their version headings are checked so release prep cannot silently drift.
The newest ``changelog.json`` entry is the complete user-visible source for the
in-app What's New experience. ``--write`` refreshes the legacy text fallback,
Google Play notes, and the matching operator copy in
``docs/play-store-listing.md``. The technical CHANGELOG and GitHub release body
remain separate records, but their version headings are checked so release
prep cannot silently drift.
"""
from __future__ import annotations
@@ -29,6 +30,12 @@ PLAY_SECTION_RE = re.compile(
r"\(≤500 characters\):\s+```\s*\n)(.*?)(\n```\s*\n## Category)",
re.DOTALL,
)
CHANGE_KINDS = ("added", "improved", "fixed")
CHANGE_KIND_LABELS = {
"added": "Added",
"improved": "Improved",
"fixed": "Fixed",
}
def _read_text(relative: pathlib.Path) -> str:
@@ -44,8 +51,8 @@ def _android_version() -> str:
def _latest_entry() -> dict[str, Any]:
data = json.loads(_read_text(CHANGELOG_JSON))
if not isinstance(data, dict) or data.get("schema") != 2:
raise ValueError("changelog.json must use schema 2")
if not isinstance(data, dict) or data.get("schema") != 3:
raise ValueError("changelog.json must use schema 3")
versions = data.get("versions")
if not isinstance(versions, list) or not versions or not isinstance(versions[0], dict):
raise ValueError("changelog.json must contain at least one version")
@@ -60,60 +67,51 @@ def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list
f"latest changelog version is {version!r}, expected Android {expected_version!r}"
)
for field in ("title", "date"):
for field in ("title", "date", "summary"):
if not isinstance(entry.get(field), str) or not entry[field].strip():
errors.append(f"latest changelog entry requires non-empty {field}")
highlight = entry.get("highlight")
if not isinstance(highlight, dict):
errors.append("latest changelog entry requires a highlight object")
highlight = {}
for field in ("title", "summary"):
if not isinstance(highlight.get(field), str) or not highlight[field].strip():
errors.append(f"latest changelog highlight requires non-empty {field}")
changes = entry.get("changes")
if not isinstance(changes, list) or not changes:
errors.append("latest changelog entry requires a non-empty changes array")
changes = []
bullets = highlight.get("bullets")
if not isinstance(bullets, list):
errors.append("latest changelog highlight bullets must be an array")
bullets = []
elif not 1 <= len(bullets) <= 3:
errors.append("latest changelog highlight must contain 1-3 bullets")
if any(not isinstance(item, str) or not item.strip() for item in bullets):
errors.append("latest changelog highlight bullets must be non-empty strings")
improvements = entry.get("improvements", [])
if not isinstance(improvements, list):
errors.append("latest changelog improvements must be an array")
improvements = []
elif len(improvements) > 2:
errors.append("latest changelog may contain at most 2 improvements")
if any(not isinstance(item, str) or not item.strip() for item in improvements):
errors.append("latest changelog improvements must be non-empty strings")
if len(bullets) + len(improvements) > 5:
errors.append("latest changelog may expose at most 5 total list items")
digest = entry.get("toastDigest")
if not isinstance(digest, dict):
errors.append("latest changelog entry requires a toastDigest object")
digest = {}
digest_counts: list[int] = []
for field in ("additionalFeatureCount", "fixCount"):
count = digest.get(field)
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
errors.append(f"latest changelog toastDigest {field} must be a non-negative integer")
seen_ids: set[str] = set()
highlight_count = 0
for index, change in enumerate(changes):
prefix = f"latest changelog change {index + 1}"
if not isinstance(change, dict):
errors.append(f"{prefix} must be an object")
continue
change_id = change.get("id")
if not isinstance(change_id, str) or not change_id.strip():
errors.append(f"{prefix} requires a non-empty id")
elif change_id in seen_ids:
errors.append(f"latest changelog change id {change_id!r} is duplicated")
else:
digest_counts.append(count)
digest_preview = digest.get("preview")
if not isinstance(digest_preview, list):
errors.append("latest changelog toastDigest preview must be an array")
digest_preview = []
elif any(not isinstance(item, str) or not item.strip() for item in digest_preview):
errors.append("latest changelog toastDigest preview items must be non-empty strings")
if len(digest_counts) == 2:
if sum(digest_counts) == 0 and digest_preview:
errors.append("latest changelog toastDigest preview must be empty when counts are zero")
elif sum(digest_counts) > 0 and not 1 <= len(digest_preview) <= 2:
errors.append("latest changelog toastDigest preview must contain 1-2 items when secondary items exist")
seen_ids.add(change_id)
if change.get("kind") not in CHANGE_KINDS:
errors.append(f"{prefix} kind must be one of {', '.join(CHANGE_KINDS)}")
for field in ("title", "summary"):
if not isinstance(change.get(field), str) or not change[field].strip():
errors.append(f"{prefix} requires non-empty {field}")
is_highlight = change.get("highlight", False)
if not isinstance(is_highlight, bool):
errors.append(f"{prefix} highlight must be a boolean")
elif is_highlight:
highlight_count += 1
if not 1 <= highlight_count <= 4:
errors.append("latest changelog must select 1-4 highlighted changes")
compatibility = entry.get("compatibility", [])
if not isinstance(compatibility, list):
errors.append("latest changelog compatibility must be an array")
elif any(not isinstance(item, str) or not item.strip() for item in compatibility):
errors.append("latest changelog compatibility items must be non-empty strings")
for legacy_field in ("highlight", "improvements", "toastDigest"):
if legacy_field in entry:
errors.append(f"latest changelog must derive presentation instead of defining {legacy_field}")
play_notes = entry.get("playNotes")
if not isinstance(play_notes, str) or not play_notes.strip():
@@ -124,19 +122,31 @@ def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list
def render_whats_new(entry: dict[str, Any]) -> str:
highlight = entry["highlight"]
lines = [
f"v{entry['version']} - {entry['title']}",
"",
highlight["title"],
*(f"* {bullet}" for bullet in highlight["bullets"]),
"Summary",
f"* {entry['summary']}",
]
improvements = entry.get("improvements", [])
if improvements:
lines.extend(("", "Also improved", *(f"* {item}" for item in improvements)))
changes = entry["changes"]
highlights = [change for change in changes if change.get("highlight", False)]
if highlights:
lines.extend(("", "Highlights", *(_render_change(change) for change in highlights)))
remaining = [change for change in changes if not change.get("highlight", False)]
for kind in CHANGE_KINDS:
items = [change for change in remaining if change["kind"] == kind]
if items:
lines.extend(("", CHANGE_KIND_LABELS[kind], *(_render_change(change) for change in items)))
compatibility = entry.get("compatibility", [])
if compatibility:
lines.extend(("", "Compatibility", *(f"* {item}" for item in compatibility)))
return "\n".join(lines) + "\n"
def _render_change(change: dict[str, Any]) -> str:
return f"* {change['title']} — {change['summary']}"
def render_play_notes(entry: dict[str, Any]) -> str:
return f"v{entry['version']} - {entry['title']}\n\n{entry['playNotes'].strip()}\n"
+28 -16
View File
@@ -4,6 +4,11 @@ REM Usage: scripts\dev.bat <command>
cd /d "%~dp0\.."
REM Secondary worktrees normally do not carry local.properties. Reuse the
REM standard per-user SDK location when the caller has not selected one.
if not defined ANDROID_HOME if exist "%LOCALAPPDATA%\Android\Sdk" set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
if not defined ANDROID_SDK_ROOT if defined ANDROID_HOME set "ANDROID_SDK_ROOT=%ANDROID_HOME%"
if "%1"=="" goto help
if "%1"=="build" goto build
if "%1"=="release" goto release
@@ -27,14 +32,15 @@ goto help
:build
echo Building sideload debug APK...
call gradlew.bat :app:assembleSideloadDebug --console=plain
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:assembleSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
echo APK: app\build\outputs\apk\sideload\debug\
goto end
:release
echo Building release APK...
call gradlew.bat assembleRelease
if errorlevel 1 goto end
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle assembleRelease
if errorlevel 1 exit /b %errorlevel%
echo.
echo Release APK:
dir /b app\build\outputs\apk\release\*.apk 2>nul
@@ -43,8 +49,8 @@ goto end
:bundle
echo Building release AAB (for Google Play upload)...
call gradlew.bat bundleRelease
if errorlevel 1 goto end
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle bundleRelease
if errorlevel 1 exit /b %errorlevel%
echo.
echo Release AAB:
dir /b app\build\outputs\bundle\release\*.aab 2>nul
@@ -53,8 +59,8 @@ goto end
:install
echo Building and installing sideload debug to connected device...
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
echo Launching app...
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
@@ -64,8 +70,8 @@ goto end
:run
echo Building, installing, and launching sideload debug...
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
REM so the `.MainActivity` shorthand no longer resolves correctly.
@@ -75,7 +81,8 @@ goto end
:compile
echo Compiling sideload debug Kotlin...
call gradlew.bat :app:compileSideloadDebugKotlin --console=plain
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:compileSideloadDebugKotlin --console=plain
if errorlevel 1 exit /b %errorlevel%
goto end
:testone
@@ -84,35 +91,40 @@ if "%~2"=="" (
exit /b 2
)
echo Running focused sideload test: %~2
call gradlew.bat :app:testSideloadDebugUnitTest --tests "%~2" --console=plain
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --tests "%~2" --console=plain
if errorlevel 1 exit /b %errorlevel%
goto end
:installfast
echo Building arm64 sideload debug and installing to connected phone...
call gradlew.bat :app:installSideloadDebug -Phermes.devAbi=arm64-v8a --console=plain
if errorlevel 1 goto end
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:installSideloadDebug -Phermes.devAbi=arm64-v8a --console=plain
if errorlevel 1 exit /b %errorlevel%
echo Launching app...
adb shell am start -n com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
goto end
:test
echo Running sideload debug unit tests...
call gradlew.bat :app:testSideloadDebugUnitTest --console=plain
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:testSideloadDebugUnitTest --console=plain
if errorlevel 1 exit /b %errorlevel%
goto end
:lint
echo Running sideload debug lint...
call gradlew.bat :app:lintSideloadDebug --console=plain
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle :app:lintSideloadDebug --console=plain
if errorlevel 1 exit /b %errorlevel%
goto end
:prepush
echo Running Android pre-push checks...
python scripts\android-prepush.py
if errorlevel 1 exit /b %errorlevel%
goto end
:clean
echo Cleaning build...
call gradlew.bat clean
call powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\android-lane.ps1 gradle clean
if errorlevel 1 exit /b %errorlevel%
goto end
:devices
@@ -12,85 +12,78 @@ release_notes = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_notes)
def entry(*, bullets: int = 3, improvements: int = 2) -> dict[str, object]:
def entry(*, highlight_count: int = 2) -> dict[str, object]:
changes = [
{
"id": f"change-{index}",
"kind": ("added", "improved", "fixed")[index % 3],
"title": f"Change {index}",
"summary": f"User-facing explanation {index}.",
"highlight": index < highlight_count,
}
for index in range(5)
]
return {
"version": "1.2.3",
"title": "A useful release",
"date": "2026-08-26",
"highlight": {
"title": "The main reason to care",
"summary": "A short explanation in plain language.",
"bullets": [f"Benefit {index}" for index in range(bullets)],
},
"improvements": [f"Improvement {index}" for index in range(improvements)],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"],
},
"summary": "A short explanation in plain language.",
"changes": changes,
"compatibility": ["Works with existing connections."],
"playNotes": "A concise Play Store summary.",
}
class AndroidReleaseNotesTest(unittest.TestCase):
def test_allows_three_highlights_and_two_improvements(self) -> None:
def test_accepts_complete_change_inventory(self) -> None:
self.assertEqual([], release_notes.validate_curated_entry(entry(), "1.2.3"))
def test_rejects_more_than_three_highlights(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=4, improvements=0), "1.2.3")
self.assertIn("latest changelog highlight must contain 1-3 bullets", errors)
def test_rejects_more_than_four_highlights(self) -> None:
errors = release_notes.validate_curated_entry(entry(highlight_count=5), "1.2.3")
self.assertIn("latest changelog must select 1-4 highlighted changes", errors)
def test_rejects_more_than_five_visible_items(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=3, improvements=3), "1.2.3")
self.assertIn("latest changelog may contain at most 2 improvements", errors)
self.assertIn("latest changelog may expose at most 5 total list items", errors)
def test_rejects_duplicate_change_ids(self) -> None:
release = entry()
release["changes"][1]["id"] = "change-0" # type: ignore[index]
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog change id 'change-0' is duplicated", errors)
def test_renders_fallback_with_curated_groups(self) -> None:
rendered = release_notes.render_whats_new(entry(bullets=1, improvements=1))
self.assertIn("The main reason to care\n* Benefit 0", rendered)
self.assertIn("Also improved\n* Improvement 0", rendered)
def test_rejects_unknown_change_kind(self) -> None:
release = entry()
release["changes"][2]["kind"] = "technical" # type: ignore[index]
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog change 3 kind must be one of added, improved, fixed", errors)
def test_renders_complete_fallback_without_duplicate_highlights(self) -> None:
rendered = release_notes.render_whats_new(entry())
self.assertIn("Summary\n* A short explanation in plain language.", rendered)
self.assertIn("Highlights\n* Change 0 — User-facing explanation 0.", rendered)
self.assertIn("Fixed\n* Change 2 — User-facing explanation 2.", rendered)
self.assertEqual(1, rendered.count("Change 0 — User-facing explanation 0."))
self.assertIn("Compatibility\n* Works with existing connections.", rendered)
def test_play_notes_include_version_and_stay_bounded(self) -> None:
rendered = release_notes.render_play_notes(entry())
self.assertTrue(rendered.startswith("v1.2.3 - A useful release"))
self.assertLessEqual(len(rendered), 500)
def test_allows_a_hero_only_release_without_secondary_filler(self) -> None:
def test_allows_a_highlights_only_release(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": [],
}
release["changes"] = release["changes"][:2] # type: ignore[index]
self.assertEqual([], release_notes.validate_curated_entry(release, "1.2.3"))
def test_rejects_preview_when_secondary_counts_are_zero(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": ["Invented filler"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog toastDigest preview must be empty when counts are zero", errors)
def test_requires_a_bounded_preview_for_secondary_items(self) -> None:
def test_rejects_legacy_manual_digest(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["one", "two", "three"],
"preview": ["Duplicated summary"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn(
"latest changelog toastDigest preview must contain 1-2 items when secondary items exist",
errors,
)
self.assertIn("latest changelog must derive presentation instead of defining toastDigest", errors)
if __name__ == "__main__":