From 66d8b826489caabf3ae566ca05d188d53b868cf0 Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 23:17:49 +0200 Subject: [PATCH 1/3] Put the download where someone looking for it will look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The APK has been in releases/ all along, but nothing on the front page said so, and the first thing most people want from an app's repository is the app. A Download section above the fold: the current build as a relative link that works on any forge, the raw URL spelled out for anywhere the relative one does not resolve, the checksum to verify it, and a pointer to releases/ for every earlier version. It also says plainly that Android will call the developer unknown and ask to install anyway. That warning is what Android says about every app installed outside a store, and read cold it looks like the app is unsafe rather than merely unlisted — better to say so first than to let someone find out and assume the worst. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 1a92b50..c027955 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,32 @@ and Arabic script, online or fully offline. Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own. +## Download + +**[ganjoor-0.4.0.apk](releases/ganjoor-0.4.0.apk)** — 31.7 MB, `minSdk 24` (Android 7.0 and up). + +Direct link, if you are reading this somewhere the one above does not resolve: + +``` +https://github.com/anas-rashid/ganjoorandroid/raw/main/releases/ganjoor-0.4.0.apk +``` + +Every version ever released is kept in [`releases/`](releases/) with its checksum, and all of +them are signed with the same key, so any one upgrades any other in place without losing your +bookmarks. Check what you downloaded: + +```sh +sha256sum ganjoor-0.4.0.apk +# 1c64cefa0fb2b51bf73386460ec1b88bda3e20832fa6926c2f7f1fb863ab9fc7 +``` + +Android will warn that the developer is unknown and ask you to install anyway. That is what it +says about **any** app installed outside a store; this one is signed with its own certificate +rather than distributed through Play. Allow installs from your browser once, and it will go +through. + +To build it yourself instead, see [Build](#build). + ## Reading Poems are set as couplets: the two hemistichs of each line stack on a phone, the first aligned From 6590a6b21ad9d3f75e72bb09ad63682bfd3c2b83 Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 23:19:32 +0200 Subject: [PATCH 2/3] Move the download under the opening paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below the tech line is already too far down. Someone who has just read what the app is wants the app next, not the toolkit it is written in. One line now, as a heading so it reads as a button rather than prose, with the facts that decide whether to tap it beside it — Android version, size, older releases, checksum — and the install warning under it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c027955..d01ea4d 100644 --- a/README.md +++ b/README.md @@ -13,34 +13,17 @@ An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persia 240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu and Arabic script, online or fully offline. +### ⬇ [Download ganjoor-0.4.0.apk](releases/ganjoor-0.4.0.apk) + +Android 7.0 and up · 31.7 MB · [older releases](releases/) · `sha256 1c64cefa…fb863ab9fc7` + +Android will call the developer unknown and offer to install anyway — it says that about every +app installed outside a store. Allow installs from your browser once and it will go through. +Every release is signed with the same key, so a new one upgrades the last without losing your +bookmarks. [Build it yourself](#build) instead if you would rather. + Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own. -## Download - -**[ganjoor-0.4.0.apk](releases/ganjoor-0.4.0.apk)** — 31.7 MB, `minSdk 24` (Android 7.0 and up). - -Direct link, if you are reading this somewhere the one above does not resolve: - -``` -https://github.com/anas-rashid/ganjoorandroid/raw/main/releases/ganjoor-0.4.0.apk -``` - -Every version ever released is kept in [`releases/`](releases/) with its checksum, and all of -them are signed with the same key, so any one upgrades any other in place without losing your -bookmarks. Check what you downloaded: - -```sh -sha256sum ganjoor-0.4.0.apk -# 1c64cefa0fb2b51bf73386460ec1b88bda3e20832fa6926c2f7f1fb863ab9fc7 -``` - -Android will warn that the developer is unknown and ask you to install anyway. That is what it -says about **any** app installed outside a store; this one is signed with its own certificate -rather than distributed through Play. Allow installs from your browser once, and it will go -through. - -To build it yourself instead, see [Build](#build). - ## Reading Poems are set as couplets: the two hemistichs of each line stack on a phone, the first aligned From e0537e34550c774f23fffcb07a68b757852b877e Mon Sep 17 00:00:00 2001 From: anas Date: Wed, 7 Oct 2026 23:32:10 +0200 Subject: [PATCH 3/3] Keep the README's download in step with the build, and prove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A README that advertises last version is worse than one that says nothing: someone follows the link, installs an old build, and has no way to know. tools/update_release_docs.py rewrites the download heading, the size and the checksum in README.md and the checksum list in releases/README.md from whatever app/build.gradle.kts declares and whatever is actually archived. It takes no arguments on purpose — the documentation can only describe the release the build produced. ReleaseDocsTest is what makes forgetting to run it a failed build rather than a quiet wrong answer: the offered version must match versionName, the APK must be archived, and every checksum in releases/README.md must match the file beside it. The declared test inputs are not decoration. Without them Gradle sees only Kotlin sources, calls the task up to date and never re-runs it — I checked, and a README rolled back to 0.3.0 passed green. With README.md and releases/README.md declared, the same edit fails on the right assertion and passes again when restored. Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 9 ++ .../com/ganjoor/android/ReleaseDocsTest.kt | 78 +++++++++++++++++ tools/update_release_docs.py | 87 +++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt create mode 100755 tools/update_release_docs.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e4dbac..3905ab4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.api.tasks.PathSensitivity import java.util.Properties plugins { @@ -75,6 +76,14 @@ android { } } +// ReleaseDocsTest reads these, so a change to either has to re-run the tests. Without this +// Gradle sees only Kotlin sources, calls the task up to date, and a README that has fallen +// behind the build sails through green. +tasks.withType().configureEach { + inputs.file(rootProject.file("README.md")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootProject.file("releases/README.md")).withPathSensitivity(PathSensitivity.RELATIVE) +} + dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) diff --git a/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt b/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt new file mode 100644 index 0000000..c580e75 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/ReleaseDocsTest.kt @@ -0,0 +1,78 @@ +package com.ganjoor.android + +import java.io.File +import java.security.MessageDigest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The download the README offers must be the release the build actually produces. + * + * A README that advertises last version is worse than one that says nothing: someone follows the + * link, installs an old build, and has no way to know. tools/update_release_docs.py keeps these in + * step; this is what makes forgetting to run it a failed build rather than a quiet wrong answer. + */ +class ReleaseDocsTest { + + private val root: File = + generateSequence(File(System.getProperty("user.dir")!!)) { it.parentFile } + .first { File(it, "settings.gradle.kts").exists() } + + private fun read(path: String) = File(root, path).readText() + + private fun sha256(file: File): String = + MessageDigest.getInstance("SHA-256").digest(file.readBytes()) + .joinToString("") { "%02x".format(it) } + + private val declaredVersion: String by lazy { + Regex("versionName\\s*=\\s*\"([^\"]+)\"") + .find(read("app/build.gradle.kts"))!! + .groupValues[1] + } + + @Test + fun `the README offers the version the build declares`() { + val offered = Regex("### . \\[Download ganjoor-([^\\]]+)\\.apk]") + .find(read("README.md")) + ?.groupValues?.get(1) + assertEquals( + "README.md offers a different version than app/build.gradle.kts declares — " + + "run tools/update_release_docs.py", + declaredVersion, + offered, + ) + } + + @Test + fun `the APK the README links to is archived`() { + val apk = File(root, "releases/ganjoor-" + declaredVersion + ".apk") + assertTrue(apk.name + " is linked from README.md but not in releases/", apk.exists()) + } + + @Test + fun `the README short checksum matches the archived APK`() { + val short = Regex("`sha256 ([0-9a-f]+)…([0-9a-f]+)`") + .find(read("README.md"))!! + .groupValues + val full = sha256(File(root, "releases/ganjoor-" + declaredVersion + ".apk")) + assertTrue( + "README.md's checksum does not match releases/ganjoor-" + declaredVersion + ".apk", + full.startsWith(short[1]) && full.endsWith(short[2]), + ) + } + + @Test + fun `every checksum in releases matches the APK beside it`() { + val listed = Regex("^([0-9a-f]{64}) (ganjoor-.+\\.apk)$", RegexOption.MULTILINE) + .findAll(read("releases/README.md")) + .map { it.groupValues[1] to it.groupValues[2] } + .toList() + assertTrue("no checksums found in releases/README.md", listed.isNotEmpty()) + listed.forEach { (digest, name) -> + val apk = File(root, "releases/" + name) + assertTrue(name + " is listed in releases/README.md but missing", apk.exists()) + assertEquals(name + " does not match its listed checksum", digest, sha256(apk)) + } + } +} diff --git a/tools/update_release_docs.py b/tools/update_release_docs.py new file mode 100755 index 0000000..9290bad --- /dev/null +++ b/tools/update_release_docs.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Bring the download line in README.md and the checksum list in releases/README.md +into step with whatever version app/build.gradle.kts declares. + +Run it after building and copying the signed APK into releases/: + + ./gradlew :app:assembleRelease + cp app/build/outputs/apk/release/ganjoor--release.apk releases/ganjoor-.apk + python3 tools/update_release_docs.py + +It reads the version from the build file rather than taking an argument, so the +documentation can only ever describe the release the build actually produces. +ReleaseDocsTest fails if either file falls behind, so forgetting this is caught +rather than shipped. +""" +import hashlib +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def version() -> str: + build = (ROOT / "app/build.gradle.kts").read_text(encoding="utf-8") + m = re.search(r'versionName\s*=\s*"([^"]+)"', build) + if not m: + sys.exit("could not find versionName in app/build.gradle.kts") + return m.group(1) + + +def main() -> None: + v = version() + apk = ROOT / "releases" / f"ganjoor-{v}.apk" + if not apk.exists(): + sys.exit(f"{apk.relative_to(ROOT)} is missing — build and copy it in first") + + digest = hashlib.sha256(apk.read_bytes()).hexdigest() + megabytes = apk.stat().st_size / 1_000_000 + + # README.md — the download heading and the line of facts under it. + readme = ROOT / "README.md" + text = readme.read_text(encoding="utf-8") + text, n = re.subn( + r"### ⬇ \[Download ganjoor-[^\]]+\]\(releases/ganjoor-[^)]+\)", + f"### ⬇ [Download ganjoor-{v}.apk](releases/ganjoor-{v}.apk)", + text, + count=1, + ) + if n != 1: + sys.exit("could not find the download heading in README.md") + short = f"{digest[:8]}…{digest[-11:]}" + text, n = re.subn( + r"Android 7\.0 and up · [^·]+· \[older releases\]\(releases/\) · `sha256 [^`]+`", + f"Android 7.0 and up · {megabytes:.1f} MB · [older releases](releases/) · `sha256 {short}`", + text, + count=1, + ) + if n != 1: + sys.exit("could not find the download facts line in README.md") + readme.write_text(text, encoding="utf-8") + + # releases/README.md — one checksum line per archived APK, in version order. + index = ROOT / "releases" / "README.md" + listing = index.read_text(encoding="utf-8") + line = f"{digest} ganjoor-{v}.apk" + if line not in listing: + # replace an existing line for this version, or append to the block + existing = re.search(rf"^[0-9a-f]{{64}} ganjoor-{re.escape(v)}\.apk$", listing, re.M) + if existing: + listing = listing[: existing.start()] + line + listing[existing.end():] + else: + last = None + for last in re.finditer(r"^[0-9a-f]{64} ganjoor-[^\n]+$", listing, re.M): + pass + if not last: + sys.exit("could not find the checksum block in releases/README.md") + listing = listing[: last.end()] + "\n" + line + listing[last.end():] + index.write_text(listing, encoding="utf-8") + + print(f"{v} {megabytes:.1f} MB {digest}") + print("README.md and releases/README.md are in step") + + +if __name__ == "__main__": + main()