diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml
index cdaefc9..49adacd 100644
--- a/.github/workflows/release-apk.yml
+++ b/.github/workflows/release-apk.yml
@@ -68,6 +68,46 @@ jobs:
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > "$KEYSTORE_PATH"
echo "Decoded keystore to $KEYSTORE_PATH"
+ # Derive the Android versionName + versionCode from the release tag so the
+ # installed APK reports the same version the GitHub Release advertises.
+ # Without this, every build ships the csproj defaults (versionName 1.0,
+ # versionCode 1): Obtainium then shows "1.0" against a "v0.2" release, and
+ # Android refuses in-place updates because the versionCode never changes.
+ # versionName = tag with leading 'v', pre-release (-...) and build (+...) stripped
+ # versionCode = major*100000000 + minor*10000 + patch (monotonic with semver;
+ # room for minor/patch < 10000, major <= 20 to stay under Android's
+ # 2,100,000,000 versionCode cap). Fails fast on out-of-range/bad tags.
+ # Manual (workflow_dispatch) runs only upload to the run (never attached to a
+ # release), so they use a fixed 0.0.0-dev / versionCode 1 that can never overtake a
+ # real release and block its in-place update.
+ - name: Compute version from release tag
+ id: ver
+ run: |
+ if [ "${{ github.event_name }}" != "release" ]; then
+ echo "display=0.0.0-dev" >> "$GITHUB_OUTPUT"
+ echo "code=1" >> "$GITHUB_OUTPUT"
+ echo "Non-release build: versionName=0.0.0-dev versionCode=1"
+ exit 0
+ fi
+ VERSION="${{ github.event.release.tag_name }}"
+ VERSION="${VERSION#v}" # v0.2.1 -> 0.2.1
+ VERSION="${VERSION%%+*}" # 0.2.1+build -> 0.2.1 (strip build metadata)
+ VERSION="${VERSION%%-*}" # 0.2.1-rc1 -> 0.2.1 (strip pre-release)
+ IFS='.' read -r MAJ MIN PAT <<< "$VERSION"
+ MAJ=${MAJ:-0}; MIN=${MIN:-0}; PAT=${PAT:-0}
+ if ! [[ "$MAJ" =~ ^[0-9]+$ && "$MIN" =~ ^[0-9]+$ && "$PAT" =~ ^[0-9]+$ ]]; then
+ echo "::error::Release tag '${{ github.event.release.tag_name }}' is not a numeric semver (parsed major='$MAJ' minor='$MIN' patch='$PAT')." >&2
+ exit 1
+ fi
+ if [ "$MAJ" -gt 20 ] || [ "$MIN" -gt 9999 ] || [ "$PAT" -gt 9999 ]; then
+ echo "::error::Version $VERSION out of versionCode range (require major<=20, minor/patch<=9999)." >&2
+ exit 1
+ fi
+ CODE=$(( 10#$MAJ * 100000000 + 10#$MIN * 10000 + 10#$PAT ))
+ echo "display=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "code=$CODE" >> "$GITHUB_OUTPUT"
+ echo "Resolved versionName=$VERSION versionCode=$CODE"
+
# Publish a SIGNED .apk (sideload). AndroidKeyStore=true + the signing
# properties make dotnet sign with our release keystore instead of the
# auto-generated debug key. AndroidPackageFormat=apk is the default but is
@@ -80,6 +120,8 @@ jobs:
-p:JavaSdkDirectory="$JAVA_HOME"
-p:AndroidSdkDirectory="$ANDROID_SDK_ROOT"
-p:AcceptAndroidSdkLicenses=True
+ -p:ApplicationDisplayVersion=${{ steps.ver.outputs.display }}
+ -p:ApplicationVersion=${{ steps.ver.outputs.code }}
-p:AndroidPackageFormat=apk
-p:AndroidKeyStore=true
-p:AndroidSigningKeyStore="$KEYSTORE_PATH"
@@ -97,6 +139,8 @@ jobs:
-p:JavaSdkDirectory="$JAVA_HOME"
-p:AndroidSdkDirectory="$ANDROID_SDK_ROOT"
-p:AcceptAndroidSdkLicenses=True
+ -p:ApplicationDisplayVersion=${{ steps.ver.outputs.display }}
+ -p:ApplicationVersion=${{ steps.ver.outputs.code }}
-p:AndroidPackageFormat=aab
-p:AndroidKeyStore=true
-p:AndroidSigningKeyStore="$KEYSTORE_PATH"
diff --git a/README.md b/README.md
index 3518e01..e168c04 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,32 @@ over telnet, using [TelnetNegotiationCore](https://www.nuget.org/packages/Telnet
Android-primary. See [`docs/superpowers/specs`](docs/superpowers/specs) for the
design spec and visual-design brief.
+## Installing on Android
+
+Signed APKs are attached to every [GitHub Release](https://github.com/SharpMUSH/SharpClient/releases).
+You can sideload the `.apk` directly, but the easiest way to install **and stay
+updated** is [Obtainium](https://github.com/ImranR98/Obtainium), which tracks this
+repo's releases for you:
+
+1. Install Obtainium.
+2. Add an app with this source URL:
+ ```
+ https://github.com/SharpMUSH/SharpClient
+ ```
+ or, from your phone, paste this Obtainium deep link (GitHub can't render it as
+ a clickable link, so copy it as-is):
+ ```
+ obtainium://add/https://github.com/SharpMUSH/SharpClient
+ ```
+3. Obtainium picks up the universal signed APK from each release and notifies you
+ when a new version ships.
+
+Each release ships a single universal APK, so no ABI/architecture filter is
+needed. The APK's `versionName`/`versionCode` are derived from the release tag
+(e.g. `v0.2` → `0.2`), so Obtainium and Android both see the correct version and
+can update in place. If a release is marked **pre-release** on GitHub, enable
+"include prereleases" for the app in Obtainium or it will be skipped.
+
## Solution layout
| Project | Kind | Purpose |
diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj
index a4dc0dd..1f3863f 100644
--- a/src/SharpClient.App/SharpClient.App.csproj
+++ b/src/SharpClient.App/SharpClient.App.csproj
@@ -69,11 +69,13 @@
-
+