Compare commits

..
8 Commits
Author SHA1 Message Date
anasandClaude Opus 5 5d12a1c29c Merge 0.3.0: pinned poets, list view, and the unofficial-client statement
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 14:59:02 +02:00
anasandClaude Opus 5 16bcd6949f Say plainly that this is an unofficial client, and name what it is built on
Someone arriving from ganjoor.net had to infer from the credits that this is a
separate project. The About page now opens with it, before the licence list,
in all three interface languages: independent, unofficial, maintained by Anas
Rashid, not affiliated with or endorsed by the owners of ganjoor.net, who carry
no responsibility for it. The README leads with the same paragraph and points
issues here rather than at the Ganjoor project.

The README also gains a full inventory of the open source this app is built on
— Ganjoor and GanjoorService, ganjoor-data, Daneshjoo, Wiktionary and Urdu
Wiktionary, wiktextract, the three fonts, and every library — each with its
repository and terms. licenses/README.md already carried most of it, but a
reader looking for "which repos does this use" should not have to open a second
file to find out.

GanjoorService is listed with the point that matters: GPL-3.0, no code used,
only data over HTTPS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 14:58:28 +02:00
Anas RashidandClaude Opus 5 b4a8efe9d4 Refresh the 0.3.0 checksum after a clean rebuild
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:39:45 +02:00
Anas RashidandClaude Opus 5 929aee2a02 Download a poet from the list, and show when they are already here
A download control on each row in the list view: the arrow starts it, a spinner
replaces it while it runs and cancels on a tap, and a green tick says the poems
are already on the device. Only text is fetched — recitations stream when
played, and nothing about that changes.

The tick sits in a box the size of an IconButton although nothing about it is
tappable. A bare icon lands where the button's padding would have put it, so
the ticks and the arrows did not line up down the column.

The green is derived from the scheme's own luminance rather than fixed:
Material has no success colour, and one green goes muddy on sepia and glares on
OLED black.

Deleting stays on the downloads page, where the sizes are. A tap beside a
poet's name should not be the thing that throws their poems away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:36:59 +02:00
Anas RashidandClaude Opus 5 a5cc0421b6 Archive 0.3.0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:28:12 +02:00
Anas RashidandClaude Opus 5 c9269713d1 Pin poets to the home screen
Three views, chosen by the chips: the poets you pinned, Ganjoor's own order,
and alphabetical. Press and hold a poet anywhere to pin or unpin — tapping
still opens them, and a pin marker sits beside the name.

Pinned is the default. With nothing pinned it is Ganjoor's order untouched, so
a fresh install never opens to an empty screen; the hint about holding a poet
shows only while the shelf is empty, because once there is something on it the
shelf explains itself.

Pins keep the order they were made in rather than being sorted. A shelf someone
arranges themselves should stay where they put it, and a new pin appearing in
the middle of the row is disorienting.

Cards or a list, switched from the app bar and remembered. The same storage as
bookmarks: a list of a few urls needs no schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:26:26 +02:00
Anas RashidandClaude Opus 5 d7568d1f49 Credit Material Symbols, and state the assistant's terms
Two gaps in the licence file, both from work done after it was written.

The share, lookup and assistant glyphs are Material Symbols path data copied
into our own vector files. The Libraries table credits the Material icons
library, which is not the same thing as copying its paths, so the drawables now
have their own entry and appear in the app's About page.

The assistant was not mentioned at all. It ships no vendor SDK, no key and no
default endpoint, so nothing non-free is distributed — but a reader may point
it at a service whose terms are their own, and what a model writes back belongs
to neither Ganjoor nor this app. Written down for F-Droid's sake as much as the
reader's.

No new dependencies came with any of it: the assistant reuses the HTTP client
and JSON parser already here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:19:00 +02:00
Anas RashidandClaude Opus 5 e3cad61e9e Keep every release build
Three so far, each signed with the same key so any of them upgrades any other
without losing bookmarks: 0.1.0 as it stood last night, 0.2.0 with share and
the assistant, and 0.2.1 with answers in the page.

These are binaries in git history, which is permanent and makes every clone
carry them. Gitea's own releases attach a build to a tag without that cost;
keeping them here is deliberate, so a version can be reinstalled without
finding the commit and rebuilding it. Tags v0.1.0, v0.2.0 and v0.2.1 mark the
commits each was built from, so they stay reproducible either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 20:12:04 +02:00
28 changed files with 552 additions and 42 deletions

No files matched your search

+67 -1
View File
@@ -2,6 +2,14 @@
<div dir="rtl">گنجور — خوانندهٔ شعر پارسی برای اندروید</div>
> **Unofficial.** This is an independent, unofficial Android client for
> [ganjoor.net](https://ganjoor.net). It is a **separate project** from ganjoor.net and its
> GitHub repositories ([`ganjoor/ganjoor`](https://github.com/ganjoor),
> `GanjoorService`, and the rest), built and maintained by **Anas Rashid**. It is not
> affiliated with, endorsed by, or managed by the owners of ganjoor.net, and they bear no
> responsibility for it. Please direct any issue with this app
> [here](https://github.com/anas-rashid/ganjoorandroid/issues) — never to the Ganjoor project.
An Android reader for [Ganjoor](https://ganjoor.net), the open archive of Persian poetry —
240 poets and ~135,000 poems, laid out for comfortable long-form reading in Persian, Urdu
and Arabic script, online or fully offline.
@@ -164,6 +172,61 @@ guide](https://f-droid.org/en/docs/Submitting_to_F-Droid_Quick_Start_Guide/):
Two things to do before submitting: tag a release (`v0.1.0`), and check that F-Droid's build
server supports **AGP 9.4.1** — it is new, and that is the most likely thing to hold up a merge.
## Open source this app is built on
Every third-party project this app uses, with its repository and terms. None of these projects
endorse or maintain this app. Full licence texts are in [`licenses/`](licenses/) and ship inside
the APK.
### Content and data
| Project | Repository / source | Terms | How it is used |
|---|---|---|---|
| Ganjoor | [ganjoor.net](https://ganjoor.net) | Classical Persian verse, long out of copyright | The poems themselves |
| GanjoorService | [ganjoor/GanjoorService](https://github.com/ganjoor/GanjoorService) | GPL-3.0 | **No code used.** Only `api.ganjoor.net` over HTTPS, for poem search, opening lines and poet portraits — so this app is not a derivative work |
| ganjoor-data | [anas-rashid/ganjoor-data](https://github.com/anas-rashid/ganjoor-data) | **No licence file stated** | The static JSON export every poem is read from |
### Dictionary
| Project | Repository / source | Terms | How it is used |
|---|---|---|---|
| Daneshjoo Dictionary | [0xdolan/Daneshjoo](https://github.com/0xdolan/Daneshjoo) | Repository states MIT (see caveat below) | Persian → English definitions |
| Wiktionary | [en.wiktionary.org](https://en.wiktionary.org) | CC BY-SA 3.0 | Persian/Urdu/Arabic → English definitions, IPA, form→lemma index |
| Urdu Wiktionary | [ur.wiktionary.org](https://ur.wiktionary.org) | CC BY-SA 3.0 | The only Urdu → Urdu definitions, plus vowelled spelling and syllable split |
| wiktextract / kaikki.org | [tatuylonen/wiktextract](https://github.com/tatuylonen/wiktextract) | See repository | Produces the machine-readable Wiktionary exports the build consumes |
| readmdict | [readmdict](https://pypi.org/project/readmdict/) | See project | Build-time only — reads Daneshjoo's `.mdx` |
Because four of the five dictionary sources are CC BY-SA 3.0, **the generated `dictionary.db` is
CC BY-SA 3.0**. The Daneshjoo caveat: the repository states MIT, but the underlying lexicon is a
published Iranian dictionary, so that relicensing is worth verifying before relying on it.
### Fonts
| Project | Repository | Terms |
|---|---|---|
| Noto Naskh Arabic | [notofonts/arabic](https://github.com/notofonts/arabic) | SIL OFL 1.1 |
| Noto Nastaliq Urdu | [notofonts/nastaliq](https://github.com/notofonts/nastaliq) | SIL OFL 1.1 |
| Libron | [nicoverbruggen/libron](https://github.com/nicoverbruggen/libron) | SIL OFL 1.1 |
### Libraries and icons
All Apache-2.0.
| Project | Repository |
|---|---|
| Jetpack Compose, AndroidX (core, activity, lifecycle, navigation) | [androidx/androidx](https://github.com/androidx/androidx) |
| Kotlin standard library | [JetBrains/kotlin](https://github.com/JetBrains/kotlin) |
| kotlinx.serialization | [Kotlin/kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) |
| kotlinx.coroutines | [Kotlin/kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) |
| OkHttp | [square/okhttp](https://github.com/square/okhttp) |
| Okio | [square/okio](https://github.com/square/okio) |
| Coil 3 | [coil-kt/coil](https://github.com/coil-kt/coil) |
| Accompanist (drawablepainter, via Coil) | [google/accompanist](https://github.com/google/accompanist) |
| Material Symbols (share, lookup, assistant glyphs, copied as vector paths) | [google/material-design-icons](https://github.com/google/material-design-icons) |
No Google Play Services, Firebase, analytics or trackers. The only permission requested is
`INTERNET`.
## Licensing
The app code is MIT (see [`LICENSE`](LICENSE)). Every other component — the poems, the three
@@ -175,4 +238,7 @@ The two things worth knowing up front: the **data set** carries no licence file,
**GanjoorService** is GPL-3.0 but none of its code is used here — only data over HTTPS — so this
app is not a derivative work of it.
Not affiliated with or endorsed by Ganjoor.
This app is an **unofficial, independent** client, maintained by **Anas Rashid**. It is not
affiliated with, endorsed by, or managed by ganjoor.net or its maintainers, and it is a wholly
separate project from the Ganjoor GitHub repositories. The same statement is shown in the app
itself, at **Reading settings → About & licences**.
+3 -3
View File
@@ -16,7 +16,7 @@ val keystoreProperties = Properties().apply {
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.2.1"
archivesName = "ganjoor-0.3.0"
}
android {
@@ -32,8 +32,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 = 3
versionName = "0.2.1"
versionCode = 5
versionName = "0.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -18,6 +18,8 @@ import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.ui.GanjoorApp
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.LocalPinnedPoets
import com.ganjoor.android.data.PinnedPoets
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme
@@ -56,12 +58,14 @@ class MainActivity : ComponentActivity() {
val settings = remember { settings }
val bookmarks = remember { Bookmarks(applicationContext) }
val assistant = remember { AssistantSettings(applicationContext) }
val pinned = remember { PinnedPoets(applicationContext) }
// The client reads this flag on every request, so keep it in step with the setting.
Ganjoor.offline = settings.value.offline
CompositionLocalProvider(
LocalSettings provides settings,
LocalBookmarks provides bookmarks,
LocalAssistant provides assistant,
LocalPinnedPoets provides pinned,
// The poetry is Persian, Urdu and Arabic throughout, so the whole app reads and
// navigates right-to-left whichever UI language is selected.
LocalLayoutDirection provides LayoutDirection.Rtl,
@@ -0,0 +1,43 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
/**
* The poets someone keeps to hand, by the url that identifies them.
*
* Kept in the order they were pinned rather than sorted: a shelf someone arranges themselves
* should stay where they put it, and a new pin appearing in the middle of the row is disorienting.
*
* ponytail: the same SharedPreferences and the same shape as [Bookmarks] — a list of a few strings
* doesn't need a database or a schema, and the two have no reason to diverge.
*/
class PinnedPoets(context: Context) {
private val prefs = context.getSharedPreferences("ganjoor", Context.MODE_PRIVATE)
val items = mutableStateListOf<String>().apply {
prefs.getString(KEY, null)
?.split(SEPARATOR)
?.filter { it.isNotBlank() }
?.let(::addAll)
}
fun contains(fullUrl: String) = fullUrl in items
fun toggle(fullUrl: String) {
if (!items.remove(fullUrl)) items.add(fullUrl)
// commit, so a pin survives the process being killed right after it's made.
prefs.edit(commit = true) { putString(KEY, items.joinToString(SEPARATOR)) }
}
private companion object {
const val KEY = "pinnedPoets"
/** A newline cannot appear in a Ganjoor url, so it needs no escaping. */
const val SEPARATOR = "\n"
}
}
val LocalPinnedPoets = staticCompositionLocalOf<PinnedPoets> { error("No PinnedPoets provided") }
@@ -137,6 +137,14 @@ private val CREDITS = listOf(
"apache-2.0.txt",
"https://google.github.io/accompanist/",
),
Credit(
"Material Symbols",
"The Android Open Source Project — the share, lookup and assistant glyphs, drawn from " +
"their path data rather than pulled in as a library",
"Apache License 2.0",
"apache-2.0.txt",
"https://fonts.google.com/icons",
),
Credit(
"Ganjoor for Android",
"This app",
@@ -173,6 +181,14 @@ fun AboutScreen(onUp: () -> Unit, onHome: () -> Unit) {
) { insets ->
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = insets) {
item {
// Stated before anything else: readers arriving from ganjoor.net should not have
// to infer from the credits below that this is someone else's app.
Text(
text = stringResource(R.string.about_unofficial),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
Text(
text = stringResource(R.string.about_intro),
style = MaterialTheme.typography.bodyMedium,
@@ -2,6 +2,10 @@ package com.ganjoor.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import com.ganjoor.android.data.LocalPinnedPoets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items as listItems
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -51,6 +55,11 @@ import coil3.compose.AsyncImage
import java.text.Collator
import java.util.Locale
import com.ganjoor.android.R
import com.ganjoor.android.ui.theme.downloaded
import com.ganjoor.android.data.Offline
import com.ganjoor.android.data.Downloads
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material.icons.filled.CheckCircle
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.PoetRef
@@ -70,6 +79,7 @@ fun PoetsScreen(
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
ViewAction()
LanguageAction()
IconButton(onClick = onBookmarks) {
Icon(Icons.Default.Favorite, stringResource(R.string.bookmarks))
@@ -92,39 +102,39 @@ fun PoetsScreen(
Load(key = Unit, block = { Ganjoor.manifest() }) { manifest ->
val settings = LocalSettings.current
val sort = settings.value.poetSort
val poets = remember(query, manifest, sort) {
val pinned = LocalPinnedPoets.current
val poets = remember(query, manifest, sort, pinned.items.toList()) {
val matches =
if (query.isBlank()) manifest.poets
else manifest.poets.filter {
it.nickname.contains(query.trim(), ignoreCase = true)
}
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
// the ordering to a collator rather than String.compareTo.
if (sort == PoetSort.Name) {
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
} else {
matches
when (sort) {
// Persian letters don't sort correctly by code point (آ vs ا, ی vs ي), so hand
// the ordering to a collator rather than String.compareTo.
PoetSort.Name -> {
val collator = Collator.getInstance(Locale.forLanguageTag("fa"))
matches.sortedWith { a, b -> collator.compare(a.nickname, b.nickname) }
}
// Pinned first, in the order they were pinned, then the rest untouched. With
// nothing pinned this is Ganjoor's order, which is why it can be the default.
PoetSort.Pinned -> pinnedFirst(matches, pinned.items)
PoetSort.Default -> matches
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val padding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = insets.calculateTopPadding() + 8.dp,
bottom = insets.calculateBottomPadding() + 16.dp,
)
// The chips and the hint are the same whichever shape the poets take.
val header: @Composable () -> Unit = {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PoetSort.entries.forEach { option ->
FilterChip(
selected = sort == option,
@@ -133,9 +143,51 @@ fun PoetsScreen(
)
}
}
// Only while the shelf is empty: once there is something on it, it explains
// itself, and a standing instruction is just clutter.
if (sort == PoetSort.Pinned && pinned.items.isEmpty()) {
Text(
text = stringResource(R.string.pin_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
items(poets, key = { it.id }) { poet ->
PoetCard(poet) { onPoet(poet.fullUrl) }
}
if (settings.value.poetGrid) {
LazyVerticalGrid(
columns = GridCells.Adaptive(132.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) { header() }
items(poets, key = { it.id }) { poet ->
PoetCard(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = padding,
) {
item { header() }
listItems(poets, key = { it.id }) { poet ->
PoetRow(
poet = poet,
pinned = pinned.contains(poet.fullUrl),
onClick = { onPoet(poet.fullUrl) },
onPin = { pinned.toggle(poet.fullUrl) },
)
}
}
}
}
@@ -176,9 +228,113 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit, onSearchPo
}
}
/** Switches the poets between cards and a list, and remembers which. */
@Composable
private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
Card(modifier = Modifier.clickable(onClick = onClick)) {
private fun ViewAction() {
val settings = LocalSettings.current
val grid = settings.value.poetGrid
IconButton(onClick = { settings.update { it.copy(poetGrid = !grid) } }) {
Icon(
painter = painterResource(
if (grid) R.drawable.ic_view_list else R.drawable.ic_view_grid
),
contentDescription = stringResource(if (grid) R.string.view_list else R.string.view_grid),
)
}
}
/** The pin marker, shown only on a poet someone chose to keep. */
@Composable
private fun PinMark(modifier: Modifier = Modifier) {
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.unpin_poet),
tint = MaterialTheme.colorScheme.primary,
modifier = modifier.size(16.dp),
)
}
/**
* Fetch a poet's poems, or say they are already here. Three states and no menu: downloading shows
* its progress and cancels on a tap, downloaded is a green tick, and anything else offers the
* download. Deleting stays on the downloads page, where the sizes are — a tap next to a poet's
* name should never be the thing that throws their poems away.
*/
@Composable
private fun DownloadAction(slug: String) {
val progress = Downloads.running[slug]
val saved = remember(slug, Downloads.revision) { Offline.isSaved(slug) }
when {
progress != null -> IconButton(onClick = { Downloads.cancel(slug) }) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
)
}
// In a box the size of an IconButton, even though nothing here is tappable: a bare icon
// sits where the button's padding would have put it, so the ticks and the arrows would
// not line up down the column.
saved -> Box(
modifier = Modifier.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.downloaded_poet),
tint = MaterialTheme.colorScheme.downloaded,
)
}
else -> IconButton(onClick = { Downloads.start(slug) }) {
Icon(
painter = painterResource(R.drawable.ic_download),
contentDescription = stringResource(R.string.download),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** One poet as a row, for readers who would rather scan names than faces. */
@Composable
private fun PoetRow(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
ListItem(
headlineContent = { Text(poet.nickname, style = MaterialTheme.typography.titleMedium) },
leadingContent = {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Text(poet.nickname.take(1), color = MaterialTheme.colorScheme.onSecondaryContainer)
AsyncImage(
model = poet.imageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
},
trailingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pinned) PinMark()
DownloadAction(poet.slug)
}
},
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin),
)
}
@Composable
private fun PoetCard(poet: PoetRef, pinned: Boolean, onClick: () -> Unit, onPin: () -> Unit) {
Card(modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onPin)) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -204,13 +360,31 @@ private fun PoetCard(poet: PoetRef, onClick: () -> Unit) {
modifier = Modifier.fillMaxSize(),
)
}
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pinned) PinMark()
Text(
text = poet.nickname,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* Pinned poets first, in the order they were pinned, then everyone else as Ganjoor has them.
*
* With nothing pinned this is Ganjoor's order untouched, which is what lets it be the default
* without anyone opening the app to an empty screen.
*/
internal fun pinnedFirst(poets: List<PoetRef>, pins: List<String>): List<PoetRef> {
if (pins.isEmpty()) return poets
val (kept, rest) = poets.partition { it.fullUrl in pins }
return kept.sortedBy { pins.indexOf(it.fullUrl) } + rest
}
@@ -42,6 +42,12 @@ enum class Language(val tag: String, val label: String) {
/** How the poet grid is ordered. */
enum class PoetSort(@StringRes val label: Int) {
/**
* Whoever the reader pinned, first. With nothing pinned it is Ganjoor's order, so this can be
* the default without anyone ever opening the app to an empty screen.
*/
Pinned(R.string.sort_pinned),
/** The data set's own order, which is Ganjoor's — best known poets first. */
Default(R.string.sort_default),
Name(R.string.sort_name),
@@ -57,7 +63,8 @@ data class Prefs(
val offline: Boolean = false,
/** True black backgrounds, applied to whichever dark theme is in use. */
val oled: Boolean = false,
val poetSort: PoetSort = PoetSort.Default,
val poetSort: PoetSort = PoetSort.Pinned,
val poetGrid: Boolean = true,
)
/** Reading preferences, kept in SharedPreferences and read once at startup. */
@@ -75,7 +82,8 @@ class Settings(context: Context) {
offline = prefs.getBoolean("offline", false),
// "Black" used to be a sixth theme; it is a flag on the dark ones now.
oled = prefs.getBoolean("oled", prefs.getString("theme", null) == "Black"),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Default),
poetSort = enumOrDefault(prefs.getString("poetSort", null), PoetSort.Pinned),
poetGrid = prefs.getBoolean("poetGrid", true),
)
)
private set
@@ -94,6 +102,7 @@ class Settings(context: Context) {
putBoolean("offline", p.offline)
putBoolean("oled", p.oled)
putString("poetSort", p.poetSort.name)
putBoolean("poetGrid", p.poetGrid)
}
}
}
@@ -9,6 +9,7 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@@ -202,3 +203,13 @@ fun GanjoorTheme(
content = content,
)
}
/**
* The green on a poet whose poems are already on the device.
*
* Material's palette has no success colour, and a fixed green goes muddy on the sepia schemes and
* glares on OLED black. Picking by the surface's luminance covers all five schemes without each
* of them having to declare one.
*/
val ColorScheme.downloaded: Color
get() = if (surface.luminance() < 0.5f) Color(0xFF81C784) else Color(0xFF2E7D32)
+10
View File
@@ -0,0 +1,10 @@
<!-- Material "push_pin", marking a poet kept on the home screen. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z" />
</vector>
@@ -0,0 +1,10 @@
<!-- Material "grid_view", for switching the poets back to cards. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,3v8h8V3H3zM9,9H5V5h4V9zM3,13v8h8v-8H3zM9,19H5v-4h4V19zM13,3v8h8V3H13zM19,9h-4V5h4V9zM13,13v8h8v-8H13zM19,19h-4v-4h4V19z" />
</vector>
@@ -0,0 +1,11 @@
<!-- Material "view_list", for switching the poets to a list of names. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M3,14h4v-4H3V14zM3,19h4v-4H3V19zM3,9h4V5H3V9zM8,14h13v-4H8V14zM8,19h13v-4H8V19zM8,5v4h13V5H8z" />
</vector>
+8
View File
@@ -59,6 +59,7 @@
<string name="load_more">بیشتر</string>
<string name="done">تمام</string>
<string name="about">درباره و پروانه‌ها</string>
<string name="about_unofficial">غیررسمی. «گنجور برای اندروید» یک کارخواه اندرویدیِ مستقل و غیررسمی برای ganjoor.net است. این برنامه پروژه‌ای جدا از ganjoor.net و مخزن‌های گیت‌هاب آن است و به‌دست انس رشید ساخته و نگهداری می‌شود. هیچ وابستگی، تأیید یا مدیریتی از سوی صاحبان ganjoor.net ندارد و آنان هیچ مسئولیتی در قبال آن بر عهده ندارند.</string>
<string name="about_intro">گنجور برای اندروید نرم‌افزار آزاد است. شعرها، قلم‌ها و همهٔ کتابخانه‌هایی که این برنامه بر آن‌ها ساخته شده در زیر آمده‌اند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید.</string>
<string name="no_definition">برای این واژه مدخلی یافت نشد.</string>
<string name="source_wiktionary">ویکی‌واژه — فارسی به انگلیسی (CC BY-SA 3.0)</string>
@@ -107,4 +108,11 @@
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
<string name="this_couplet">این بیت</string>
<string name="couplet_options">گزینه‌های این بیت</string>
<string name="sort_pinned">برگزیده</string>
<string name="pin_poet">نگه‌داشتن در بالا</string>
<string name="unpin_poet">برداشتن</string>
<string name="pin_hint">برای نگه‌داشتن یک شاعر در اینجا، انگشت را روی او نگه دارید</string>
<string name="view_grid">نمایش کارتی</string>
<string name="view_list">نمایش فهرستی</string>
<string name="downloaded_poet">پیش‌تر دریافت شده</string>
</resources>
+8
View File
@@ -59,6 +59,7 @@
<string name="load_more">مزید</string>
<string name="done">مکمل</string>
<string name="about">تعارف اور لائسنس</string>
<string name="about_unofficial">غیر سرکاری۔ ”گنجور فار اینڈرائیڈ“ ganjoor.net کا ایک خودمختار اور غیر سرکاری اینڈرائیڈ کلائنٹ ہے۔ یہ ganjoor.net اور اس کی گِٹ ہب ریپازٹریز سے بالکل الگ منصوبہ ہے، جسے انس رشید نے بنایا اور وہی اسے سنبھالتے ہیں۔ اسے ganjoor.net کے مالکان کی نہ تائید حاصل ہے، نہ وہ اسے چلاتے ہیں، اور نہ ہی اس کے ذمہ دار ہیں۔</string>
<string name="about_intro">گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔</string>
<string name="no_definition">اس لفظ کا کوئی اندراج نہیں ملا۔</string>
<string name="source_wiktionary">ویکی لغت — فارسی سے انگریزی (CC BY-SA 3.0)</string>
@@ -107,4 +108,11 @@
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
<string name="this_couplet">یہ شعر</string>
<string name="couplet_options">اس شعر کے اختیارات</string>
<string name="sort_pinned">منتخب</string>
<string name="pin_poet">اوپر رکھیں</string>
<string name="unpin_poet">ہٹا دیں</string>
<string name="pin_hint">کسی شاعر کو یہاں رکھنے کے لیے اُس پر دبا کر رکھیں</string>
<string name="view_grid">کارڈ کی صورت</string>
<string name="view_list">فہرست کی صورت</string>
<string name="downloaded_poet">پہلے سے محفوظ ہے</string>
</resources>
+8
View File
@@ -64,6 +64,7 @@
<string name="load_more">Load more</string>
<string name="done">Done</string>
<string name="about">About &amp; licences</string>
<string name="about_unofficial">Unofficial. Ganjoor for Android is an independent, unofficial Android client for ganjoor.net. It is a separate project from ganjoor.net and its GitHub repositories, built and maintained by Anas Rashid. It is not affiliated with, endorsed by, or managed by the owners of ganjoor.net, who bear no responsibility for it.</string>
<string name="about_intro">Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence.</string>
<string name="no_definition">No entry for this word.</string>
<string name="source_wiktionary">Wiktionary — Persian to English (CC BY-SA 3.0)</string>
@@ -112,4 +113,11 @@
<string name="assistant_translate_prompt">Translate this:</string>
<string name="this_couplet">This couplet</string>
<string name="couplet_options">Options for this couplet</string>
<string name="sort_pinned">Pinned</string>
<string name="pin_poet">Pin to the top</string>
<string name="unpin_poet">Unpin</string>
<string name="pin_hint">Press and hold a poet to keep them here</string>
<string name="view_grid">Show as cards</string>
<string name="view_list">Show as a list</string>
<string name="downloaded_poet">Already downloaded</string>
</resources>
@@ -0,0 +1,39 @@
package com.ganjoor.android
import com.ganjoor.android.data.PoetRef
import com.ganjoor.android.ui.pinnedFirst
import org.junit.Assert.assertEquals
import org.junit.Test
class PinnedPoetsTest {
private fun poets(vararg slugs: String) =
slugs.mapIndexed { i, slug -> PoetRef(id = i, nickname = slug, fullUrl = "/$slug") }
private val all = poets("hafez", "moulavi", "saadi", "ferdousi")
/** The default view must never be empty, which is the whole reason it can be the default. */
@Test
fun `no pins leaves ganjoor's order untouched`() {
assertEquals(all, pinnedFirst(all, emptyList()))
}
@Test
fun `pinned poets come first, in the order they were pinned`() {
val order = pinnedFirst(all, listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("saadi", "hafez", "moulavi", "ferdousi"), order)
}
/** A pin for a poet who isn't in the list — filtered out by a search — must not crash. */
@Test
fun `a pin with no matching poet is ignored`() {
val order = pinnedFirst(poets("hafez"), listOf("/saadi", "/hafez")).map { it.nickname }
assertEquals(listOf("hafez"), order)
}
@Test
fun `the unpinned keep their relative order`() {
val order = pinnedFirst(all, listOf("/ferdousi")).map { it.nickname }
assertEquals(listOf("ferdousi", "hafez", "moulavi", "saadi"), order)
}
}
@@ -0,0 +1,2 @@
Credits the Material Symbols glyphs the app draws its share, lookup and
assistant icons from, and states the terms around the optional AI assistant.
@@ -0,0 +1,11 @@
Keep the poets you read on the home screen. Press and hold any poet to pin
them; pinned poets sit first, in the order you pinned them.
The home screen now opens on that shelf, with Ganjoor's own order and an
alphabetical listing a tap away. Until you pin anything the shelf is simply
Ganjoor's order, so there is never an empty screen.
Poets can be shown as cards or as a list, whichever reads better.
Each poet in the list view carries a download button, and a green tick once their
poems are on the device. Text only — recitations are still streamed when you play them.
@@ -0,0 +1,2 @@
سپاس‌گزاری از Material Symbols که نشانه‌های هم‌رسانی، جست‌وجو و دستیار از روی
آن کشیده شده‌اند، و روشن‌کردن شرایط مربوط به دستیار هوش مصنوعیِ اختیاری.
@@ -0,0 +1,11 @@
شاعرانی را که می‌خوانید روی صفحهٔ نخست نگه دارید. انگشت را روی هر شاعر نگه دارید
تا برگزیده شود؛ برگزیدگان به همان ترتیبی که انتخاب کرده‌اید، نخست می‌آیند.
صفحهٔ نخست حالا با همین قفسه باز می‌شود و ترتیب خود گنجور و فهرست الفبایی یک
ضربه فاصله دارند. تا چیزی برنگزیده‌اید، قفسه همان ترتیب گنجور است، پس هرگز
صفحه‌ای خالی نمی‌بینید.
شاعران را می‌توان کارتی یا فهرستی دید، هرکدام که خواناتر است.
هر شاعر در نمایش فهرستی یک دکمهٔ دریافت دارد، و پس از ذخیره‌شدن شعرهایش یک تیک سبز.
فقط متن؛ خوانش‌ها همچنان هنگام پخش از اینترنت می‌آیند.
@@ -0,0 +1,2 @@
Material Symbols کا اعتراف، جن سے شیئر، لغت اور معاون کے نشان لیے گئے ہیں، اور
اختیاری مصنوعی ذہانت کے معاون سے متعلق شرائط کی وضاحت۔
@@ -0,0 +1,12 @@
جن شاعروں کو آپ پڑھتے ہیں انہیں صفحۂ اوّل پر رکھیں۔ کسی بھی شاعر پر دبا کر رکھیں
تو وہ منتخب ہو جائے گا؛ منتخب شاعر اُسی ترتیب سے سب سے پہلے آتے ہیں جس ترتیب سے
آپ نے انہیں رکھا۔
صفحۂ اوّل اب اسی فہرست سے کھلتا ہے، اور گنجور کی اپنی ترتیب اور حروفِ تہجی ایک
ٹیپ کی دوری پر ہیں۔ جب تک آپ کچھ منتخب نہ کریں، یہ گنجور ہی کی ترتیب رہتی ہے،
سو خالی صفحہ کبھی نہیں ملتا۔
شاعروں کو کارڈ کی صورت یا فہرست کی صورت، جو بہتر لگے، دیکھا جا سکتا ہے۔
فہرست کی صورت میں ہر شاعر کے ساتھ ڈاؤن لوڈ کا بٹن ہے، اور اُن کا کلام محفوظ ہو جانے پر
سبز نشان۔ صرف متن؛ تلاوتیں اب بھی سنتے وقت آن لائن چلتی ہیں۔
+25
View File
@@ -19,6 +19,22 @@ bundled rather than merely linked.
Nothing in Ganjoor's repositories restricts AI-assisted use.
## The AI assistant
Optional, off by default, and the app ships no vendor SDK, no API key and no default endpoint.
Nothing is sent anywhere until a reader configures a server themselves.
| What | Terms |
| --- | --- |
| The client | Ours, MIT. It speaks the OpenAI chat-completions shape, which is a request format, not anyone's code, plus Anthropic's `/v1/messages` shape for Claude |
| A self-hosted server (Ollama, LM Studio, llama.cpp, LocalAI) | Each is separately installed free software; this app only makes HTTP requests to it |
| A hosted service (OpenAI, DeepSeek, Gemini, Claude) | Whatever the reader agreed with that service. Their terms and privacy policy govern what they do with the text sent |
| What a model writes back | Generated text, not Ganjoor's and not ours. Shown as an aid, never stored next to the poem |
For F-Droid: nothing here is a non-free dependency, since no such code ships. A reader *may*
point it at a non-free network service, which is why the setting is off by default and the
privacy note sits on its own settings page.
## Dictionary
Five sources, each row in the database tagged with the one it came from so the app can name it
@@ -67,6 +83,15 @@ Every dependency is Apache-2.0. The full text is in
No Google Play Services, Firebase, analytics or tracking of any kind. The only Android
permission requested is `INTERNET`.
## Icons
The share, lookup and assistant glyphs in `app/src/main/res/drawable/` are drawn from
[Material Symbols](https://fonts.google.com/icons) path data, copied into our own vector files
rather than pulled in as a dependency. Apache 2.0, the same terms as the Material icons listed
under Libraries. The assistant glyph is Material's `auto_awesome`; the sparkle that has come to
mean "ask a model" is used by Gemini and others, but their marks are trademarks and none is used
here.
## Artwork
The launcher icon — an eight-point *shamsa* — is original work for this project, MIT like the
+28
View File
@@ -0,0 +1,28 @@
# Builds
Every release, kept so a version can be reinstalled without rebuilding it. All are signed with
the same release key, so any of them upgrades any other in place without losing bookmarks.
Each is reproducible: check out the commit and run `./gradlew assembleRelease`. F-Droid builds
from source and signs with its own key, so these are for direct installation only.
| Version | Code | Commit | What it added |
| --- | --- | --- | --- |
| 0.1.0 | 1 | `9445f44` | The base app: browsing, bookmarks, offline, themes, the bundled dictionary with pronunciation, and Ganjoor's chapter order. |
| 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.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. |
## Checksums
```
76121689da069d6ea2e2c6587d4b371ceb51d21bfe069a583d346d3da970337a ganjoor-0.1.0.apk
43d78b773efca2b1aa2bf790c689b3086389d66c4ea0ffb72c9dba8bddb50770 ganjoor-0.2.0.apk
5163554eb01845c131c31a9da7bdd748170bb9b5f82edaece754b4cf40b02b0c ganjoor-0.2.1.apk
e473269044710813d2cf4119de663fe180f887239df7bdb1ccee0239ef84fd3e ganjoor-0.2.2.apk
9c38d5cacf9e809c2189ba581f8aa6eb4072d4fa0316f00d66434461cf4b1459 ganjoor-0.3.0.apk
```
Installing: `adb install -r releases/ganjoor-<version>.apk`. A phone holding a *debug* build has
to have it uninstalled first — Android will not replace a debug signature with a release one.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.