Compare commits

...

24 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
6590a6b21a Move the download under the opening paragraph
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) <noreply@anthropic.com>
2026-10-07 23:19:32 +02:00
66d8b82648 Put the download where someone looking for it will look
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) <noreply@anthropic.com>
2026-10-07 23:17:49 +02:00
Anas Rashid
c801d88ef3
Merge pull request #7 from anas-rashid/release/archive-0.4.0-apk
Archive the signed 0.4.0, and add the F-Droid recipe
2026-10-07 19:21:22 +02:00
331e0544bc Archive the signed 0.4.0, and add the F-Droid recipe
The signing key is on this machine now, so 0.4.0 is signed and archived like
every release before it. The certificate is the same one:

  CN=Ganjoor for Android, O=anas-rashid, C=PK
  SHA-256 d3b5a144b600dd6d9ae8236f0a1bd630767e88d7eaa1573edaadbf8e651fe494

verified against 0.3.0's before archiving, which is the point of checking: a
different key would have forced every existing install to be removed first, and
releases/README.md promises that never happens.

The 0.3.0 row also gets its real commit; it still said HEAD, which stops meaning
anything the moment another release lands on top of it.

metadata/com.ganjoor.android.yml is the recipe F-Droid's build server uses,
kept with the source it describes rather than only in a fork of fdroiddata.
UpdateCheckMode is Tags, so each new tag is picked up without editing it again.
F-Droid signs with their own key, which is why the release build has to succeed
with no keystore present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 19:17:08 +02:00
a725e66a9a Converge the two remotes after 0.4.0
Gitea and GitHub each made their own merge commit for the 0.4.0 release branch.
Both have the same tree — the same release, merged twice — so this reconciles
the history and leaves one main again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 19:02:36 +02:00
Anas Rashid
31279bc5da
Merge pull request #6 from anas-rashid/release/0.4.0
Release 0.4.0
2026-10-07 19:01:30 +02:00
Anas Rashid
23efc2afa3
Merge pull request #5 from anas-rashid/test/dictionary-fits-beside
Test the rule that chooses the dictionary's container
2026-10-07 19:01:20 +02:00
270c28c9c9 Merge pull request 'Release 0.4.0' (#7) from release/0.4.0 into main
Reviewed-on: #7
2026-10-07 16:53:58 +00:00
3407f010c9 Release 0.4.0
Twenty commits since v0.3.0, and the tag still pointed at the old one: nothing
could be published while main declared a version that was already taken.

0.4.0 rather than 0.3.1 — this is not a patch. Tablets and unfolded foldables
read in columns now, the dictionary and the reading settings moved beside the
poem instead of over it, couplets sit in cards and share a line where there is
room, pages fill in where they load, and the design system that describes all of
it lives in design/.

Changelog 6 in all three languages, as F-Droid reads them from fastlane/.

The release APK is not archived in releases/ yet: the signing key is not on this
machine, and every release so far shares one key so that each upgrades the last
in place. Signing and archiving wait for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:47:06 +02:00
3486a89b56 Test the rule that chooses the dictionary's container
The guard added in #3 decides whether the dictionary opens beside the poem or as
a sheet over it, and until now nothing proved it. I tried four times to verify it
by hand and failed every time: reaching it needs a tap landing on the glyphs of
one Persian word inside a SelectionContainer, and adb's synthetic taps either
became a long press and opened the selection menu, or landed between words and
opened the couplet's actions instead. The verses are not exposed to uiautomator
either, so there is nothing to aim at.

The arithmetic behind the rule can be tested even though the gesture cannot, so
it moves into a pure function and gets four cases: a tablet keeps the panel, a
foldable open in portrait does not, a phone never gets it however wide the page
is measured, and the boundary sits exactly at the panel plus the minimum measure
(616dp passes, 615dp does not).

No behaviour changes — this is the same expression, named and covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:43:01 +02:00
Anas Rashid
701b984f4b
Merge pull request #4 from anas-rashid/fix/dictionary-panel-crushes-narrow-page
Open the dictionary as a sheet where a panel would crush the poem
2026-10-07 18:34:27 +02:00
41d776a35d Merge main, keeping only the part of this branch that is still needed
main has moved on since this branch opened, and most of what it carried has been
answered better there:

- the reading settings no longer need a fallback to the sheet, because they fold
  the columns away and take the room that frees (aab8b2e);
- the two panels no longer share a width, because the dictionary is now 216dp
  against the settings' 360dp.

Both were dropped: the conflicting files are taken from main as they stand.

What survives is the reason this branch exists. Which container the dictionary
uses is still decided by the width of the *window*, and that is the wrong
question — what matters is what is left of the page once the columns have taken
theirs. On a 700dp foldable that is about 450dp, and a panel beside it leaves
the verse a couple of characters a line. It now opens beside the poem only while
the page keeps 400dp, and falls back to the sheet below that, which covers the
foot of the poem but leaves every line whole.

Recalculated for the narrower panel: a tablet keeps the panel (~900dp page less
216dp leaves 684dp), a foldable in portrait does not (236dp).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:33:24 +02:00
bcdad2e699 Merge fix/one-side-panel-at-a-time
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
ce26676d4e Merge fix/recitation-keeps-playing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:28:34 +02:00
f60f9b4079 One side panel at a time, and a narrower one for the dictionary
Two fixes to the left of the reader.

The dictionary and the reading settings both want that side of the screen, and
opening the second put two panels there at once — or, where there was no longer
room for two, left the dictionary as a sheet in the middle of the page while the
settings sat beside it. Either way the reader is asked to look in two places.
The settings now replace the dictionary; closing them leaves the poem, which is
where the reader was.

The dictionary also no longer takes the settings' full 360dp. It is read against
the line it came from, so the verse should keep the width: 216dp, a little over
half. The settings keep 360dp, which costs nothing — they fold the columns away
and take the room that frees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:25:13 +02:00
936fd3cf3b Open the dictionary as a sheet where a panel would crush the poem
On a book-style foldable held open in portrait — 700dp, inside the 600–839dp
band — tapping a word made the poem unreadable. The panel is a fixed 360dp and
the columns keep their widths, so the page was left about 75dp: the verse broke
to one or two characters a line, and the breadcrumbs and the metre shattered the
same way.

The panel was chosen from the width of the window. That is the wrong question.
What matters is what is left of the page once the columns have taken their room,
so both panels now ask that instead:

- the dictionary opens beside the poem only while the page keeps 400dp;
- reading settings open beside the browser only while it keeps its own 600dp,
  below which the panel would squeeze the columns and the poem into less than
  the layout is built for.

Where they do not fit, the bottom sheet is used, exactly as on a phone: it
covers the foot of the poem but leaves every line whole. A tablet is unaffected
— verified at 2560x1600 that the panel still opens and the poem still reads.

The two panels now share one width, and the design notes record the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:04:39 +02:00
19 changed files with 428 additions and 15 deletions

View File

@ -13,6 +13,15 @@ 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 240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script, online or fully offline. and Arabic script, online or fully offline.
### ⬇ [Download ganjoor-0.4.1.apk](releases/ganjoor-0.4.1.apk)
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.
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. Jetpack Compose, Material 3, `minSdk 24`. No account, no tracking, no server of its own.
## Reading ## Reading

View File

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

View File

@ -97,6 +97,22 @@ fun PoemScreen(
// finds, so the couplet's own actions live at its foot rather than behind a tap between words. // finds, so the couplet's own actions live at its foot rather than behind a tap between words.
var tapped by remember { mutableStateOf<WordTap?>(null) } var tapped by remember { mutableStateOf<WordTap?>(null) }
// One side panel at a time. The dictionary and the reading settings both want the left of the
// screen, and opening the second put two panels there at once — or, where there was no longer
// room for two, left the dictionary as a sheet in the middle of the page while the settings
// sat beside it. Either way the reader is asked to look in two places. The settings replace
// the dictionary instead; closing them leaves the poem, which is where the reader was.
val sidePanelOpen = LocalSidePanelOpen.current
LaunchedEffect(sidePanelOpen) { if (sidePanelOpen) tapped = null }
BoxWithConstraints(Modifier.fillMaxSize()) {
// Whether the dictionary gets a column of its own is not a question about the window but
// about what is left of this page once the columns have taken theirs. On a book-style
// foldable held open in portrait the page is already down to ~450dp, and a panel beside it
// left the verse a couple of characters a line. Where it does not fit, the sheet is the
// better answer: it covers the foot of the poem but leaves the lines whole.
val roomForPanel = dictionaryFitsBeside(maxWidth, wide)
Load( Load(
key = fullUrl, key = fullUrl,
block = { Ganjoor.poem(fullUrl) }, block = { Ganjoor.poem(fullUrl) },
@ -231,7 +247,7 @@ fun PoemScreen(
} }
} }
} }
if (wide) { if (roomForPanel) {
AnimatedVisibility( AnimatedVisibility(
visible = tapped != null, visible = tapped != null,
enter = expandHorizontally() + fadeIn(), enter = expandHorizontally() + fadeIn(),
@ -249,7 +265,7 @@ fun PoemScreen(
} }
} }
if (!wide) { if (!roomForPanel) {
tapped?.let { tap -> tapped?.let { tap ->
WordSheet( WordSheet(
word = tap.word, word = tap.word,
@ -259,6 +275,7 @@ fun PoemScreen(
} }
} }
} }
}
/** The positions that make a line of verse; anything else (Single, Paragraph, Comment) is prose. */ /** The positions that make a line of verse; anything else (Single, Paragraph, Comment) is prose. */
private val VERSE_POSITIONS = setOf(Verse.RIGHT, Verse.LEFT, Verse.CENTERED_1, Verse.CENTERED_2) private val VERSE_POSITIONS = setOf(Verse.RIGHT, Verse.LEFT, Verse.CENTERED_1, Verse.CENTERED_2)

View File

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ganjoor.android.R import com.ganjoor.android.R
import com.ganjoor.android.data.Definition import com.ganjoor.android.data.Definition
@ -48,6 +49,37 @@ import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Pronunciation import com.ganjoor.android.data.Pronunciation
import com.ganjoor.android.ui.theme.readingStyle import com.ganjoor.android.ui.theme.readingStyle
/**
* How wide the dictionary sits beside the poem.
*
* Narrower than the reading settings, deliberately: the settings fold the columns away and take
* the room that frees, while the dictionary is read *against* the line it came from, so the verse
* keeps the width instead. A definition is short; a hemistich is not.
*/
val DictionaryPanelWidth = 216.dp
/**
* The narrowest the page may be left once the dictionary opens beside it. Below this the panel is
* not worth its room: on a book-style foldable held open in portrait the columns have already
* taken theirs, and a panel there left the verse a couple of characters a line.
*/
val MinPageMeasure = 400.dp
/**
* Whether the dictionary earns a column of its own beside a page [pageWidth] wide.
*
* Not a question about the window but about the page: by the time a poem is open the columns have
* already taken their room, and on a book-style foldable held open in portrait what is left is
* about 450dp. A panel beside that leaves the verse a couple of characters a line, so below
* [MinPageMeasure] the bottom sheet is used instead — it covers the foot of the poem but leaves
* every line whole.
*
* A pure function so the rule can be tested: the gesture that reaches it cannot be driven
* reliably, but the arithmetic behind it can.
*/
internal fun dictionaryFitsBeside(pageWidth: Dp, wide: Boolean): Boolean =
wide && pageWidth - DictionaryPanelWidth >= MinPageMeasure
/** English prose inside an otherwise right-to-left sheet. */ /** English prose inside an otherwise right-to-left sheet. */
@Composable @Composable
private fun LeftToRight(content: @Composable () -> Unit) { private fun LeftToRight(content: @Composable () -> Unit) {
@ -102,7 +134,7 @@ fun WordPanel(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
BackHandler(onBack = onDismiss) BackHandler(onBack = onDismiss)
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow, color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.width(360.dp).fillMaxHeight(), modifier = Modifier.width(DictionaryPanelWidth).fillMaxHeight(),
) { ) {
Column(Modifier.statusBarsPadding()) { Column(Modifier.statusBarsPadding()) {
Row( Row(

View File

@ -1,12 +1,12 @@
package com.ganjoor.android.ui.theme package com.ganjoor.android.ui.theme
import android.os.Build
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.FontVariation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
@ -15,24 +15,40 @@ import com.ganjoor.android.R
import com.ganjoor.android.ui.ReadingFont import com.ganjoor.android.ui.ReadingFont
/** /**
* Both files are variable fonts with a `wght` axis, registered at four weights so a reader can * Both files are variable fonts, registered at four weights so a reader can thicken the text —
* thicken the text — thin naskh strokes wash out on a lit screen, especially in the dark themes. * 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 * Only `wght` is asked for, and deliberately: both files carry that one axis and nothing else.
* Android falls back to synthesising the heavier weights, which still darkens the text. * `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) @OptIn(ExperimentalTextApi::class)
private fun variable(resId: Int, weight: FontWeight) = Font( private fun variable(resId: Int, weight: FontWeight) = Font(
resId = resId, resId = resId,
weight = weight, weight = weight,
variationSettings = FontVariation.Settings(weight, FontStyle.Normal), variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
) )
private val weights = private val weights =
listOf(FontWeight.Normal, FontWeight.Medium, FontWeight.SemiBold, FontWeight.Bold) 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 * The English UI only. Libron is a reading serif (OFL, github.com/nicoverbruggen/libron) and

View File

@ -0,0 +1,41 @@
package com.ganjoor.android
import androidx.compose.ui.unit.dp
import com.ganjoor.android.ui.dictionaryFitsBeside
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule that decides whether the dictionary opens beside the poem or as a sheet over it.
*
* Driving the gesture that reaches it — a tap landing on the glyphs of one Persian word — is not
* something adb does reliably, so the arithmetic is tested directly instead.
*/
class DictionaryPanelFitTest {
@Test
fun `a tablet has room beside the poem`() {
// 1280dp window, less the poets rail and two list columns, leaves the page about 900dp.
assertTrue(dictionaryFitsBeside(900.dp, wide = true))
}
@Test
fun `a foldable open in portrait does not`() {
// 700dp window, less the rail and the newest column, leaves about 450dp — and 450 less
// the 216dp panel is 234dp, which is a cramped phone column, not a measure for a verse.
assertFalse(dictionaryFitsBeside(450.dp, wide = true))
}
@Test
fun `a phone never gets the panel, however the page is measured`() {
assertFalse(dictionaryFitsBeside(420.dp, wide = false))
assertFalse(dictionaryFitsBeside(2000.dp, wide = false))
}
@Test
fun `the boundary is the panel plus the minimum measure`() {
assertTrue(dictionaryFitsBeside(616.dp, wide = true)) // 400 + 216, exactly
assertFalse(dictionaryFitsBeside(615.dp, wide = true))
}
}

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

@ -45,6 +45,10 @@ On large screens, tapping a word opens **WordPanel** on the left instead of the
- The poem moves over to make room, so nothing being read is covered. - The poem moves over to make room, so nothing being read is covered.
- The tapped word stays highlighted in the verse (`secondary-container` / `on-secondary-container`), on phones too. - The tapped word stays highlighted in the verse (`secondary-container` / `on-secondary-container`), on phones too.
- ✕ or Back closes the panel. - ✕ or Back closes the panel.
- The panel only opens where the page keeps at least **400dp** beside it. On a book-style
foldable held open in portrait the columns have already taken their room, and a panel
there left the verse a couple of characters a line. Below that the sheet is used instead:
it covers the foot of the poem but leaves the lines whole.
## Reading settings ## Reading settings

View File

@ -0,0 +1,19 @@
Tablets and unfolded foldables now read in columns: the poets on the right, then
a column for each level of the open book, and the poem in whatever room is left.
Each column scrolls on its own and narrows as you read, so the poem keeps the
middle. A button in the top bar folds them away.
Where there is room, a couplet's two hemistichs sit on one line, as Ganjoor sets
them on a desktop. Each couplet now sits in a soft card.
The dictionary and the reading settings open beside the poem rather than over
it, so nothing you are reading is covered.
Pages fill in where they load instead of blanking the screen, and Home and the
reading settings stay reachable while they do.
Tapping a word no longer carries the comma or question mark after it into the
lookup.
A reading keeps playing when you scroll down the poem, and a seek bar moves it
back and forth.

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,17 @@
تبلت‌ها و گوشی‌های تاشوی باز حالا ستونی خوانده می‌شوند: شاعران در سمت راست، سپس
یک ستون برای هر لایهٔ کتاب باز، و شعر در هر فضایی که می‌ماند. هر ستون جداگانه
پیمایش می‌شود و با خواندن باریک‌تر می‌شود تا شعر میانه را نگه دارد. دکمه‌ای در
نوار بالا آن‌ها را جمع می‌کند.
هر جا جا باشد، دو مصرع یک بیت در یک سطر می‌نشینند، همان‌گونه که گنجور روی رایانه
می‌چیند. هر بیت اکنون در کارتی ملایم می‌نشیند.
لغت‌نامه و تنظیمات خواندن کنار شعر باز می‌شوند، نه رویش، تا چیزی از آنچه
می‌خوانید پوشیده نشود.
صفحه‌ها همان‌جا که بار می‌شوند پر می‌شوند و صفحه سفید نمی‌ماند؛ خانه و تنظیمات
خواندن در همان حال هم در دسترس‌اند.
ضربه بر یک واژه دیگر ویرگول یا علامت پرسش پس از آن را به جست‌وجو نمی‌برد.
خوانش با پیمایش شعر قطع نمی‌شود، و نواری تازه آن را پس و پیش می‌برد.

View File

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

View File

@ -0,0 +1,17 @@
ٹیبلٹ اور کھلے فولڈ ایبل اب ستونوں میں پڑھے جاتے ہیں: شعرا دائیں طرف، پھر کھلی
کتاب کی ہر سطح کے لیے ایک ستون، اور باقی جگہ میں کلام۔ ہر ستون الگ سکرول ہوتا ہے
اور پڑھتے وقت تنگ ہو جاتا ہے تاکہ کلام درمیان میں رہے۔ اوپر کی پٹی کا ایک بٹن
انہیں سمیٹ دیتا ہے۔
جہاں گنجائش ہو، شعر کے دونوں مصرعے ایک ہی سطر میں آتے ہیں، جیسے گنجور کمپیوٹر پر
دکھاتا ہے۔ ہر شعر اب ایک ہلکے کارڈ میں بیٹھتا ہے۔
لغت نامہ اور مطالعے کی ترتیبات کلام کے ساتھ کھلتی ہیں، اُس پر نہیں، تاکہ جو آپ
پڑھ رہے ہیں وہ ڈھکا نہ جائے۔
صفحات وہیں بھرتے ہیں جہاں لوڈ ہوتے ہیں، خالی اسکرین نہیں آتی؛ ہوم اور مطالعے کی
ترتیبات اُس دوران بھی دستیاب رہتی ہیں۔
کسی لفظ پر ٹیپ اب اُس کے بعد والا کوما یا سوالیہ نشان ساتھ نہیں لے جاتی۔
سکرول کرنے پر تلاوت بند نہیں ہوتی، اور ایک نئی سیک بار اُسے آگے پیچھے کرتی ہے۔

View File

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

View File

@ -0,0 +1,35 @@
# F-Droid metadata for Ganjoor for Android.
#
# This file is not read by the app or the build; it is the recipe F-Droid's build
# server uses, kept here so it travels with the source it describes. To submit or
# update the listing, copy it into a fork of gitlab.com/fdroid/fdroiddata as
# metadata/com.ganjoor.android.yml and open a merge request.
#
# F-Droid builds from source and signs with their own key, which is why the
# release build must succeed with no keystore present — see app/build.gradle.kts.
Categories:
- Reading
License: MIT
AuthorName: Muhammad Anas Rashid
SourceCode: https://github.com/anas-rashid/ganjoorandroid
IssueTracker: https://github.com/anas-rashid/ganjoorandroid/issues
Changelog: https://github.com/anas-rashid/ganjoorandroid/releases
AutoName: گنجور
RepoType: git
Repo: https://github.com/anas-rashid/ganjoorandroid.git
Builds:
- versionName: 0.4.0
versionCode: 6
commit: v0.4.0
subdir: app
gradle:
- yes
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 0.4.0
CurrentVersionCode: 6

View File

@ -12,7 +12,9 @@ from source and signs with its own key, so these are for direct installation onl
| 0.2.0 | 2 | `d92445f` | Share a poem, couplet or selection; look a word up from any app's selection menu; the optional AI assistant. | | 0.2.0 | 2 | `d92445f` | Share a poem, couplet or selection; look a word up from any app's selection menu; the optional AI assistant. |
| 0.2.1 | 3 | `ec05989` | Assistant answers in the page rather than over it, a visible chevron for each couplet's actions, and answers remembered so scrolling doesn't re-ask. | | 0.2.1 | 3 | `ec05989` | Assistant answers in the page rather than over it, a visible chevron for each couplet's actions, and answers remembered so scrolling doesn't re-ask. |
| 0.2.2 | 4 | `d7568d1` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. | | 0.2.2 | 4 | `d7568d1` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
| 0.3.0 | 5 | `HEAD` | 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.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 ## Checksums
@ -22,6 +24,8 @@ from source and signs with its own key, so these are for direct installation onl
5163554eb01845c131c31a9da7bdd748170bb9b5f82edaece754b4cf40b02b0c ganjoor-0.2.1.apk 5163554eb01845c131c31a9da7bdd748170bb9b5f82edaece754b4cf40b02b0c ganjoor-0.2.1.apk
e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk
9c38d5cacf9e809c2189ba581f8aa6eb4072d4fa0316f00d66434461cf4b1459 ganjoor-0.3.0.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 Installing: `adb install -r releases/ganjoor-<version>.apk`. A phone holding a *debug* build has

BIN
releases/ganjoor-0.4.0.apk Normal file

Binary file not shown.

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()