Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
245362d58e | ||
|
|
95e78e0657 | ||
|
|
cd856da6a0 | ||
|
|
9a40aedb7d | ||
|
|
30eb70daa3 | ||
|
|
6be6cec201 | ||
|
|
4f5f3fac10 | ||
|
|
7f7112caf2 | ||
|
|
4c00e8edb7 | ||
|
|
44f0acec94 | ||
|
|
41b724e072 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
-2
@@ -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")
|
||||
}
|
||||
|
||||
+8
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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> `
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user