How a version of AVR-Remote gets from master to users. Written down after the 1.6.0 release,
because none of it was recorded anywhere and most of it is not guessable from the repository.
.github/workflows/releasebuild.yml triggers on a tag matching v*. It builds with JDK 17, signs
the release APK from the repository secrets, renames it to avr-remote-<tag>.apk and attaches it to
a GitHub Release, whose description it fills from the release notes (see below). A tag containing
rc produces a pre-release.
The description is generated from app/src/main/assets/whatsnew.html, the same file the in-app
dialog renders: misc/release-notes.py cuts out the block whose <b>version</b> heading matches the
tag without its leading v and turns the <li> items into Markdown bullets. So the notes are
written once, in step 2 below, and both places get them.
This is a deliberate trade, and it costs. The workflow used to set no description at all, so the
1.6.0 and 1.6.1 pages were written by hand afterwards — and written longer than the in-app dialog:
around 3400 and 2400 characters, with an opening paragraph, a warning quote, ### Fixed / ### Changed headings and the reasoning behind each fix. The generated bullets for 1.6.1 are 863
characters and flat. What was bought is that nobody has to remember the second step; what was given
up is that depth. If a release deserves the long form, write it into the release page by hand after
the workflow has run — but then it lives only there, which is the state this replaced.
The conversion is lossy in one more way worth knowing: a link inside an entry keeps its text and
loses its href, so entries have to read as prose rather than as "see here".
A missing block costs the description, never the release. The script then prints nothing,
exits 1, and the workflow falls back to GitHub's generated list of merged pull requests — which is a
diff summary, not a user-facing one. That is the intended behaviour for an rc tag, and a warning
sign for anything else: by the time the step runs the tag is pushed and the APK is built, so failing
there would leave a tag with no release at all. The two things that trigger it are notes that were
never written and a heading that does not match the tag exactly — <b>v1.6.1</b> or <b>1.6.1a</b>
for tag v1.6.1 do not match. Only whitespace inside the <b> is tolerated.
There is no Play Store automation. No fastlane, no gradle-play-publisher, no service account — the Play upload has always been done by hand in the Play Console, and still has to be. The GitHub Release is where the APK comes from; the Console is where it goes.
The signingConfigs block in app/build.gradle is conditional. It is only created when either
~/keystore.properties is readable or KEY_PASSWORD is set in the environment. With neither, the
block is skipped entirely and the build produces app-release-unsigned.apk — silently, on a local
build. If a release APK cannot be installed, this is why. In CI the same situation is loud rather
than silent, but for an unhelpful reason: the Rename APK step moves app-release.apk, which does
not exist, and the job fails there instead of at signing.
| Source | Where |
|---|---|
| CI | Repository secrets KEY_JKS (base64 of the JKS), KEY_PASSWORD, KEY_ALIAS, STORE_PASSWORD. The workflow decodes KEY_JKS to key.jks and hardcodes STORE_FILE=../key.jks, relative to the app module. |
| Local | ~/keystore.properties with keyAlias / keyPassword / storeFile / storePassword, or the same four values as KEY_ALIAS / KEY_PASSWORD / STORE_FILE / STORE_PASSWORD environment variables. |
The keystore itself is not in the repository and must never be. misc/genkey.sh is the script it
was presumably created with, and its alias is pskiwi — but that is the script's default, not proof:
the alias CI actually signs with comes from the KEY_ALIAS secret and cannot be read from here.
apksigner verify --print-certs on a released APK is the way to find out for real.
The keystore is a single point of failure. Verify this in the Play Console rather than trusting it: as of the 1.6.0 release the app is not enrolled in Play App Signing, which means Google holds no copy. Lose the keystore and the app can never be updated again — only republished under a new package name, losing every install, rating and review. Keep an offline backup, or enrol in Play App Signing (uploading the existing key keeps everything else unchanged).
-
Raise the version.
versionCodeandversionNamelive inapp/src/main/AndroidManifest.xmland nowhere else — there is noversionCodeinapp/build.gradle. Both must be raised; Play rejects aversionCodethat is not higher than the last one. -
Add release notes in two places, which say the same thing at different lengths.
app/src/main/assets/whatsnew.htmlis the in-app dialog and the GitHub release description — a new<b>version</b>block at the top of the list, with the version written exactly as in the tag but without thev.AboutActivityrenders the file, andAVRRemoteopens it automatically on the first start after an update, but only once a receiver is configured: the check inAVRRemote.showChangeLog()isgetConnectionConfig().isDefined() && AVRSettings.isShowChangeLog(this), and it waits until the permission dialogs of that start are answered.isShowChangeLog()comparespackageInfo.versionCodeagainst the storedAVRLastVersionCodepreference, so an unchangedversionCodemeans nobody ever sees the notes.English only. The app ships two locales, but this file is not one of the localised resources — there is a single
assets/whatsnew.html, and German readers get the English text in the dialog just as they do on the release page. Adding a German block would show it to everyone, twice. The German wording belongs inversion.xmlbelow, where the Console splits it by language tag.version.xmlis the Play Store wording, 500 characters per language. It is not checked in;.gitignorecovers it, because it is input for the Console rather than part of the app. Its shape:<de-DE> … </de-DE> <en-US> … </en-US>
Deliberately not a well-formed XML document — two roots, no declaration — because that is what the Console expects when it splits the text by language tag. The app ships exactly these two locales (
res/valuesandres/values-de).Credit every entry with the GitHub login of whoever made the change, in trailing parentheses:
(pskiwi), or(#13/netmindz)when there is a pull request or issue to point at. Get the names fromgit log --format='%h %an <%ae> %s' <last-tag>..HEAD --no-mergesand map the addresses to logins withgh pr view <n> --json author. Do not take the login from the merge commit subject: PR #9 reads "Merge pull request #9 from EMATech/master", butEMATechis the organisation that owns the fork — the author isrdoursenaud.Write for users, not for the diff. Two kinds of entry are easy to leave out and both matter more than any feature:
- A raised
minSdkstrands devices. 1.6.0 went fromminSdk 8to 24, so everything below Android 7.0 stays on 1.5.1 forever. Say so in the first bullet. - Rewrites of the receiver-facing code, even when nothing is meant to change for the user. The 1.6.0 replacement of Apache HttpClient could only be tested against the hardware that was available; a user on an untested model needs to know that a missing input name is worth reporting.
Last, read back what the release page will show — before tagging, not after:
python3 misc/release-notes.py v1.6.0
- A raised
-
Remove the items in TODO.md that this release closes — that file records open work only, the history records what was done. If
@array/modelNamesgained entries since the last release, append them toRELEASEDinReleasedModelNamesTest: from now on they are stored in users' settings, and a class that disappears would drop those users toAVRGenericsilently. -
Commit, tag, push. The tag convention is
v<versionName>:git tag v1.6.0 git push origin master --tags
-
Watch the build, then fetch the APK and verify the signature before handing it to anyone. Read the release description in the same pass: a list of merged pull requests instead of the notes means the block was not found and the fallback took over (see above).
gh run watch gh release view v1.6.0 gh release download v1.6.0 $ANDROID_HOME/build-tools/<version>/apksigner verify --print-certs avr-remote-v1.6.0.apk
-
Upload to the Play Console by hand — see below.
-
Upload the APK from the GitHub Release to a production release.
-
Paste the release notes from the local
version.xml(see step 2) — the<de-DE>and<en-US>blocks, without the comment. Play truncates silently at 500 characters per language, so count first, stripping the comment or the tag names quoted inside it throw the match off:```sh python3 -c "import re; s=re.sub(r'<!--.*?-->','',open('version.xml').read(),flags=re.S); \ [print(t, len(b.strip())) for t,b in re.findall(r'<(\S+?)>(.*?)</\1>', s, re.S)]" ``` -
Check that no declaration on the App content page has gone red since last time. An outstanding declaration blocks all changes, including store-listing edits.
-
If
minSdkwas raised, note that the device catalogue shrinks accordingly and the affected users silently stop receiving updates. -
Still outstanding from 1.6.0: replace the store-listing icon with
misc/play-store-icon-512.png. The listing still shows the raster icon from 2010; the adaptive icon shipped in the APK is not used for the listing. Regenerate the PNG frommisc/play-store-icon.svgif the artwork changes — the command is a comment at the top of that file, and the artwork has four copies in the tree that have to move together (app/src/main/res/drawable/ic_launcher_foreground.xml,app/src/main/assets/icon.svg,docs/avr-icon.svgand the store SVG).
Not part of a release — the listing has its own review and can be edited at any time. The text lives
in store-listing.txt, both languages, short and full description, with the
character limits and a command to count them. Unlike version.xml it is checked in, because it
stays in the store for years and the next edit should start from it.
Google rejected the listing in August 2026 with "Die Funktionen Ihrer App werden in Ihrem Store-Eintrag nicht deutlich beschrieben", and was right: the old text was two sentences, a model list and disclaimers, and never said what the app does. The rewrite puts the features first and the model list last. Keep that order.
The model list there comes from @array/modelNames in res/values/lists.xml, not from the previous
listing — that one had missed seven models and dropped the dashes from five. That makes four
copies of the model list in the tree: lists.xml, README.md, docs/index.md and
store-listing.txt. Only the first is pinned by a test (ModelConfiguratorTest,
against the classes in models/); the other three drift silently, and two of them had. Regenerate
them from lists.xml rather than editing by hand:
python3 -c "import re; s=open('app/src/main/res/values/lists.xml').read(); \
m=re.search(r'<string-array name=\"modelNames\".*?</string-array>', s, re.S); \
print(', '.join(i.strip() for i in re.findall(r'<item>(.*?)</item>', m.group(0), re.S)))"The Console attaches "recommended actions" to every release. They are generic, they do not know what the app is, and they come back each time. Decided once, so they do not have to be re-argued:
-
"Enable R8 to improve memory and performance" — no.
ModelConfigurator.java:84resolves the 60 receiver classes by name,Class.forName("de.pskiwi.avrremote.models." + avrModel). They have no static references, so shrinking removes all of them and every user falls back toAVRGeneric.What makes that bad is not that features disappear — it is that they do not.
AVRGenericextendsAbstractModel, which answerstruetohasZones(),hasQuick()andhasLevels()and reportsTYPE_7CHlevels, andAVRGeneric.getZoneCount()returns 3. So every receiver would be driven as a generic 3-zone, 7-channel device with quick-select: a phantom zone on 2-zone models, a missing one on 4-zone models, the generic input and surround lists instead of the model's own, and quick-select offered where the hardware has none. OnlysupportsDAB()is an outright loss. The app would look like it works and send the wrong commands.ModelConfiguratorTestdoes not protect against this: it runs on the JVM against unminified classes, so the build stays green and the damage only appears in the field. Turning R8 on would need-keep class de.pskiwi.avrremote.models.** { *; }and verification on a device with a real model selected — for close to nothing, because the app has no runtime dependencies and the APK is 343 KB. Shrinking libraries is where R8 normally earns its keep, and there are none. See also CLAUDE.md → Receiver models.
Everything below was checked in August 2026 and none of it is under our control, so re-read the sources rather than this list — Play policy moves and this file will not.
- Target API level. Play requires API 35 today and API 36 from 31 August 2026 for new apps
and updates. The app is at
targetSdk 37, so it clears both with a year to spare and has already taken the step that matters: API 37 brings mandatory Local Network Protection, which gates the subnet scan and every receiver socket — see TODO.md → Android 17: Local Network Protection and CONNECTION.md. The Play deadline for API 37 is expected around August 2027; Google has not announced it yet. 1.7.0 (tagged 2026-09-27) is the first release withtargetSdk 37and is out. (requirements, local network permission) - Package name registration — 30 September 2026. Part of the Android developer verification
programme. Google auto-registered nearly all existing Play apps in March 2026; confirm on the Play
Console home page that
de.pskiwi.avrremoteis listed as registered. Unregistered apps face removal, starting regionally in late 2026. (details) - Data safety form. Mandatory, must stay accurate. The app collects nothing and requests only
INTERNET,ACCESS_NETWORK_STATE,ACCESS_LOCAL_NETWORKandPOST_NOTIFICATIONS(ACCESS_WIFI_STATEwent in 1.7.0). (details) - Content rating (IARC). Since the policy update of 15 July 2026 unrated apps are not allowed on Play. Fill in the questionnaire if it has lapsed. (announcement)
Apps that were on Play before August 2021 may keep shipping APKs; the App Bundle requirement applies to apps published since then (and, since June 2023, to Android TV updates). AVR-Remote predates the cutoff, which is why the workflow still produces an APK.
If the Console ever refuses the APK, switching is not a one-line change: an AAB requires enrolling
in Play App Signing, which is irreversible. Add bundleRelease to the workflow, publish
app/build/outputs/bundle/release/app-release.aab, and upload the existing keystore to Google so
that existing installs keep updating.