Compare commits

...

8 Commits

Author SHA1 Message Date
12a1cdf1b6 Converge the remotes after 0.4.1
Both forges merged the download-link PR on their own, each with its own merge
commit, while 0.4.1 was being cut here. The content was already in: this joins
the three histories back into one main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 00:35:19 +02:00
38996943b5 Release 0.4.1
One fix, reported from a Xiaomi on Android 16: the font weight control did
nothing. Both bundled faces carry a single variable axis, wght, and the app was
asking for two — a font with no italic axis may refuse the whole request rather
than the part it cannot honour, and on a platform that does, every weight drew
at Regular with no error to show for it. The same declaration had quietly broken
the heavier weights on Android 7.0 and 7.1 as well.

A patch release, not a minor one: nothing else changed.

README.md and releases/README.md were written by tools/update_release_docs.py
rather than by hand, which is what it is for — and ReleaseDocsTest now has
something real to check, since the version, the archived APK and the checksum
all moved together.

The 0.4.0 row also gets its real commit in place of HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 00:34:07 +02:00
b6f103dc73 Merge fix/naskh-font-weight
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 00:31:02 +02:00
5a338d0e0a Merge tools/release-docs-in-step
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 00:31:02 +02:00
f49d9f080f Ask the font only for the axis it has
Reported from a Xiaomi on Android 16: the naskh weight control does nothing.

Both bundled faces carry exactly one variable axis, wght 400-700, and nothing
else. The code asked for two. FontVariation.Settings(weight, style) sends ital
alongside wght, and a font with no italic axis is entitled to refuse the whole
request rather than the part it cannot honour. AOSP ignores the stray axis,
which is why this renders correctly on an emulator and not on the device.

When a platform does refuse it, nothing visibly fails. Each entry in the family
declares the weight it was asked for, so the text matches an entry claiming 700,
draws with 400 glyphs, and is never a candidate for synthetic bolding either —
the one fallback that would have covered it is ruled out by the declaration.
Every weight then looks identical, with no error anywhere.

So: request wght only.

The same declaration broke API 24 and 25 outright, where variable axes do not
exist at all. The comment claimed Android would synthesise the heavier weights
there; it would not, for the reason above. Below 26 the family is now a single
entry at its real weight, so asking for bold synthesises bold.

Measured on an API 30 emulator, same poem, only the preference changed: naskh
+42% ink at Bold, nastaliq +11%, and the two faces differ from each other, so
both files load and both respond. Whether it settles HyperOS is for the device
to say — this removes the cause I can see from here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 00:29:03 +02:00
e0537e3455 Keep the README's download in step with the build, and prove it
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) <noreply@anthropic.com>
2026-10-07 23:32:10 +02:00
709a876348 Merge pull request 'Put the download where someone looking for it will look' (#9) from docs/download-link into main
Reviewed-on: #9
2026-10-07 21:21:08 +00:00
Anas Rashid
37be11c7b3
Merge pull request #8 from anas-rashid/docs/download-link
Docs/download link
2026-10-07 23:20:24 +02:00
10 changed files with 233 additions and 13 deletions

View File

@ -13,9 +13,9 @@ 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)
### ⬇ [Download ganjoor-0.4.1.apk](releases/ganjoor-0.4.1.apk)
Android 7.0 and up · 31.7 MB · [older releases](releases/) · `sha256 1c64cefa…fb863ab9fc7`
Android 7.0 and up · 31.7 MB · [older releases](releases/) · `sha256 6c85da41…ce357615daa`
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.

View File

@ -1,3 +1,4 @@
import org.gradle.api.tasks.PathSensitivity
import java.util.Properties
plugins {
@ -16,7 +17,7 @@ val keystoreProperties = Properties().apply {
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.4.0"
archivesName = "ganjoor-0.4.1"
}
android {
@ -32,8 +33,8 @@ android {
// Pre-1.0 while the app is still being shaped. versionCode only ever climbs: F-Droid
// refuses an update that does not, and one changelog file per code lives in
// fastlane/metadata/android/*/changelogs/.
versionCode = 6
versionName = "0.4.0"
versionCode = 7
versionName = "0.4.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -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<Test>().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)

View File

@ -1,12 +1,12 @@
package com.ganjoor.android.ui.theme
import android.os.Build
import androidx.compose.material3.Typography
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
@ -15,24 +15,40 @@ import com.ganjoor.android.R
import com.ganjoor.android.ui.ReadingFont
/**
* Both files are variable fonts with a `wght` axis, registered at four weights so a reader can
* thicken the text — thin naskh strokes wash out on a lit screen, especially in the dark themes.
* Both files are variable fonts, registered at four weights so a reader can thicken the text —
* thin naskh strokes wash out on a lit screen, especially in the dark themes.
*
* Real axis interpolation needs API 26+; on 24 and 25 the variation settings are ignored and
* Android falls back to synthesising the heavier weights, which still darkens the text.
* Only `wght` is asked for, and deliberately: both files carry that one axis and nothing else.
* `FontVariation.Settings(weight, style)` would also send `ital`, and a font with no italic axis
* is entitled to refuse the whole request rather than the part it cannot honour. A platform that
* does so leaves every weight drawing at 400 — and silently, because each entry here declares the
* weight it was asked for, so the text is never a candidate for synthetic bolding either.
*/
@OptIn(ExperimentalTextApi::class)
private fun variable(resId: Int, weight: FontWeight) = Font(
resId = resId,
weight = weight,
variationSettings = FontVariation.Settings(weight, FontStyle.Normal),
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
)
private val weights =
listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold)
val Naskh = FontFamily(weights.map { variable(R.font.noto_naskh_arabic, it) })
val Nastaliq = FontFamily(weights.map { variable(R.font.noto_nastaliq_urdu, it) })
/**
* Variable axes need API 26. Below that the settings above are dropped, and declaring four
* weights of the same file would make the heavier ones unreachable: Android would match the
* entry claiming 700, draw it at 400, and skip synthetic bolding because the entry said it was
* already bold. One entry at its real weight instead, so asking for bold actually synthesises it.
*/
private fun scriptFamily(resId: Int): FontFamily =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
FontFamily(weights.map { variable(resId, it) })
} else {
FontFamily(Font(resId, FontWeight.Normal))
}
val Naskh = scriptFamily(R.font.noto_naskh_arabic)
val Nastaliq = scriptFamily(R.font.noto_nastaliq_urdu)
/**
* The English UI only. Libron is a reading serif (OFL, github.com/nicoverbruggen/libron) and

View File

@ -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))
}
}
}

View File

@ -0,0 +1,10 @@
The font weight control now works on every device.
Both bundled faces carry one variable axis, wght, and nothing else — but the app
asked for two, sending an italic axis alongside it. A font with no italic axis
may refuse the whole request rather than the part it cannot honour, and on a
platform that does, every weight drew at Regular with nothing to show for it.
Reported on Android 16.
On Android 7.0 and 7.1, where variable fonts do not exist at all, the heavier
weights were unreachable for a related reason. They now work there too.

View File

@ -0,0 +1,9 @@
تنظیم ضخامت قلم اکنون روی همهٔ دستگاه‌ها کار می‌کند.
هر دو قلم همراه برنامه تنها یک محور متغیر دارند، wght، و بس — اما برنامه دو
محور می‌خواست و محور ایتالیک را هم می‌فرستاد. قلمی که محور ایتالیک ندارد می‌تواند
کل درخواست را نپذیرد، نه فقط بخشی را که از عهده‌اش برنمی‌آید؛ روی چنین سیستمی هر
ضخامتی با همان حالت معمولی کشیده می‌شد. این مورد روی اندروید ۱۶ گزارش شد.
روی اندروید ۷٫۰ و ۷٫۱ که اصلاً قلم متغیر ندارند، ضخامت‌های بیشتر به دلیلی
نزدیک به همین در دسترس نبودند. آنجا هم درست شد.

View File

@ -0,0 +1,9 @@
فونٹ کی موٹائی کا انتخاب اب ہر آلے پر کام کرتا ہے۔
ایپ کے ساتھ آنے والے دونوں فونٹس میں صرف ایک متغیر محور ہے، wght، اور بس — مگر
ایپ دو مانگ رہی تھی اور ساتھ اٹالک محور بھی بھیج رہی تھی۔ جس فونٹ میں اٹالک محور
نہ ہو وہ پورا مطالبہ رد کر سکتا ہے، نہ کہ صرف وہ حصہ جو وہ پورا نہیں کر سکتا؛ ایسے
نظام پر ہر موٹائی عام ہی کی طرح بنتی رہی۔ یہ اینڈرائیڈ ۱۶ پر رپورٹ ہوا۔
اینڈرائیڈ ۷٫۰ اور ۷٫۱ پر، جہاں متغیر فونٹ ہوتے ہی نہیں، زیادہ موٹائیاں اسی سے
ملتی جلتی وجہ سے ناقابلِ رسائی تھیں۔ وہاں بھی اب درست ہے۔

View File

@ -14,6 +14,7 @@ from source and signs with its own key, so these are for direct installation onl
| 0.2.2 | 4 | `d7568d1` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
| 0.3.0 | 5 | `5d12a1c` | Pin poets to the home screen: three views (pinned, Ganjoor's order, alphabetical), as cards or a list, with a download control on each row. |
| 0.4.0 | 6 | `270c28c` | Columns on tablets and unfolded foldables; the dictionary and reading settings beside the poem rather than over it; couplets in cards, two hemistichs to a line where there is room; skeletons while pages load; a seek bar on recitations. |
| 0.4.1 | 7 | `HEAD` | The font weight control works on every device: the app was asking the bundled faces for an italic axis they do not have, and a font may refuse the whole request rather than the part it cannot honour. |
## Checksums
@ -24,6 +25,7 @@ from source and signs with its own key, so these are for direct installation onl
e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk
9c38d5cacf9e809c2189ba581f8aa6eb4072d4fa0316f00d66434461cf4b1459 ganjoor-0.3.0.apk
1c64cefa0fb2b51bf73386460ec1b88bda3e20832fa6926c2f7f1fb863ab9fc7 ganjoor-0.4.0.apk
6c85da41bf1416e34d6cb9c9eda9b4867e01b6793f21e476a6e3ece357615daa ganjoor-0.4.1.apk
```
Installing: `adb install -r releases/ganjoor-<version>.apk`. A phone holding a *debug* build has

BIN
releases/ganjoor-0.4.1.apk Normal file

Binary file not shown.

87
tools/update_release_docs.py Executable file
View File

@ -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-<v>-release.apk releases/ganjoor-<v>.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()