Compare commits

...
Author SHA1 Message Date
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
Anas RashidandClaude Opus 5 ec05989f7d Release 0.2.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:51:20 +02:00
Anas RashidandClaude Opus 5 5daebcd87a Show the assistant's answers in the page, and make a couplet's options findable
Translations appear under the summary they translate. Leaving the poem to read
one — to a sheet over the top, or to another app entirely — breaks the reading,
which is the thing the feature is meant to help with. Ganjoor prints a summary
under each couplet as well as under the poem, so both now carry a translate
button, the couplet one only once a server is configured: a button under every
couplet earns its space only if it can answer.

A couplet's own actions were effectively unreachable. They opened on a tap that
landed between words, and on a full line of poetry a tap almost always lands on
a word, which opens the dictionary instead — three attempts here failed before
one worked. There is now a chevron at the end of every couplet, and the same
actions sit at the foot of the word sheet, inside its scroll rather than after
it, where they were pushed past the bottom of the sheet with no way to reach
them.

Answers are remembered. A LazyColumn disposes what scrolls out of view, which
restarted the request behind it: one tap on translate sent two, confirmed
against a stub server, and scrolling away and back would have sent more. On a
metered API that is money.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:49:15 +02:00
Anas RashidandClaude Opus 5 d92445f21e Release 0.2.0
Pre-1.0 while the app is still being shaped: 0.1.0 was the build after the
dictionary work, this is the one with share and the optional assistant.

The APK is named for its version, because a file called app-release.apk tells
whoever receives it nothing. Changelogs in all three languages, one per
versionCode, which is the layout F-Droid reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:21:53 +02:00
anas 2f9cb0e211 Merge pull request 'feature/ai-assistant' (#2) from feature/ai-assistant into main
Reviewed-on: #2
2026-10-04 17:16:53 +00:00
25 changed files with 371 additions and 91 deletions

No files matched your search

+10 -2
View File
@@ -14,6 +14,11 @@ val keystoreProperties = Properties().apply {
if (file.exists()) file.inputStream().use { load(it) }
}
// A delivered file should say what it is without being opened.
base {
archivesName = "ganjoor-0.2.2"
}
android {
namespace = "com.ganjoor.android"
compileSdk {
@@ -24,8 +29,11 @@ android {
applicationId = "com.ganjoor.android"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
// 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 = 4
versionName = "0.2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -166,6 +166,27 @@ object Assistant {
.build()
}
/**
* Answers already received, so scrolling a poem does not ask twice for the same thing. A
* LazyColumn disposes what scrolls out of view, which restarts the request behind it; on a
* metered API that is money, and on a model running locally it is a wait the reader already
* sat through. Access-ordered, so the oldest falls out first.
*/
private val answers = object : LinkedHashMap<String, String>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, String>) = size > 32
}
internal fun cacheKey(prompt: String, language: AssistantLanguage, text: String) =
"$prompt|${language.code}|$text"
@Synchronized
internal fun cached(key: String): String? = answers[key]
@Synchronized
internal fun remember(key: String, reply: String) {
answers[key] = reply
}
/** Anthropic is the one service that does not speak the OpenAI shape. */
internal fun isClaude(baseUrl: String) = baseUrl.contains("anthropic.com", ignoreCase = true)
@@ -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",
@@ -1,17 +1,19 @@
package com.ganjoor.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -20,10 +22,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -36,57 +38,59 @@ import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
/**
* Asks the reader's own assistant and shows what comes back.
* Asking the reader's own assistant, and showing what comes back.
*
* The answer isn't stored: it's a reading aid, and a model's paraphrase of a thousand-year-old
* poem has no business being cached next to the poem itself. Sharing it on is one tap, for anyone
* who wants to keep it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
AssistantAnswer(text = text, prompt = prompt)
}
}
/**
* A button that asks the reader's own assistant, or hands the question to another app when no
* server is configured. Both paths are one tap and neither requires any setting up, which is the
* point: the feature is optional, so it cannot assume anyone switched it on.
* The reply, in the page. A translation belongs under the thing it translates — leaving the poem
* to read it, whether to a sheet over the top or to another app entirely, breaks the reading.
*
* With a server configured the answer arrives here. Without one there is nothing to ask, so the
* question goes to whichever assistant is installed; that hand-off is the only path that leaves
* the app, and only because the alternative is no answer at all.
*/
@Composable
fun AssistantAction(prompt: String, text: String, label: Int, instruction: Int) {
fun AssistantInline(prompt: String, text: String, label: Int, instruction: Int) {
val assistant = LocalAssistant.current
val context = LocalContext.current
val ask = stringResource(instruction)
var open by remember { mutableStateOf(false) }
var asked by remember(text) { mutableStateOf(false) }
TextButton(onClick = {
if (assistant.serverReady) open = true else context.shareText("$ask\n\n$text")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(label))
Column(modifier = Modifier.fillMaxWidth()) {
if (!asked) {
TextButton(onClick = {
if (assistant.serverReady) asked = true else context.shareText("$ask\n\n$text")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(label))
}
}
AnimatedVisibility(visible = asked) {
AssistantReply(prompt = prompt, text = text)
}
}
if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false })
}
/** The same answer as a plain screen, for the selection-menu activity. */
/** The question and its answer, with nothing around them. */
@Composable
fun AssistantAnswer(text: String, prompt: String = "translate") {
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
val settings = LocalAssistant.current
val context = LocalContext.current
val language = settings.language
val answer by produceState<Result<String>?>(null, prompt, text, language) {
value = Assistant.ask(
val key = Assistant.cacheKey(prompt, language, text)
val answer by produceState<Result<String>?>(Assistant.cached(key)?.let(Result.Companion::success), key) {
// Already answered once: an item scrolling back into view must not ask again.
if (value != null) return@produceState
val result = Assistant.ask(
settings = settings,
system = Prompts.system(language),
user = when (prompt) {
@@ -97,14 +101,68 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
else -> text
},
)
result.getOrNull()?.let { Assistant.remember(key, it) }
value = result
}
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
Column(modifier = modifier.fillMaxWidth().padding(top = 4.dp)) {
when (val result = answer) {
null -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp).padding(end = 8.dp),
)
Text(
text = stringResource(R.string.assistant_working),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else -> if (result.isSuccess) {
// A reply in Urdu or English needs its own direction, not the reader's.
val reply = result.getOrDefault("")
InDirectionOf(reply) {
Text(
text = reply,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
}
TextButton(onClick = { context.shareText(reply) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
} else {
Text(
text = result.exceptionOrNull()?.message
?: stringResource(R.string.assistant_not_set_up),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
/** The same reply as a sheet, for the selection-menu screen, which has no page to sit in. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
@@ -112,34 +170,7 @@ fun AssistantAnswer(text: String, prompt: String = "translate") {
maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
)
val result = answer
when {
result == null -> Column {
CircularProgressIndicator(modifier = Modifier.padding(bottom = 8.dp))
Text(stringResource(R.string.assistant_working))
}
result.isSuccess -> {
// A reply in Urdu or English needs its own direction, not the reader's.
val reply = result.getOrDefault("")
InDirectionOf(reply) {
Text(reply, style = MaterialTheme.typography.bodyLarge)
}
TextButton(onClick = { context.shareText(reply) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
}
else -> Text(
text = result.exceptionOrNull()?.message
?: stringResource(R.string.assistant_not_set_up),
color = MaterialTheme.colorScheme.error,
)
}
AssistantReply(prompt = prompt, text = text)
}
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
@@ -53,6 +55,7 @@ import com.ganjoor.android.data.Bookmark
import com.ganjoor.android.data.breadcrumbs
import com.ganjoor.android.data.wordAt
import com.ganjoor.android.data.Ganjoor
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.LocalBookmarks
import com.ganjoor.android.data.Poem
import com.ganjoor.android.data.PoemRef
@@ -71,7 +74,9 @@ fun PoemScreen(
) {
BackHandler(onBack = onUp)
var tappedWord by remember { mutableStateOf<String?>(null) }
// The couplet travels with the word: the dictionary sheet is the one gesture every reader
// finds, so the couplet's own actions live at its foot rather than behind a tap between words.
var tapped by remember { mutableStateOf<Pair<String, Bookmark>?>(null) }
Load(key = fullUrl, block = { Ganjoor.poem(fullUrl) }) { poem ->
val prefs = LocalSettings.current.value
@@ -148,7 +153,7 @@ fun PoemScreen(
style = style,
showSummaries = prefs.showSummaries,
source = Bookmark(fullUrl, poem.title, poem.fullTitle),
onWord = { tappedWord = it },
onWord = { word, passage -> tapped = word to passage },
)
}
@@ -181,8 +186,12 @@ fun PoemScreen(
}
}
tappedWord?.let { word ->
WordSheet(word = word, onDismiss = { tappedWord = null })
tapped?.let { (word, passage) ->
WordSheet(
word = word,
passage = passage,
onDismiss = { tapped = null },
)
}
}
@@ -262,17 +271,18 @@ private fun Couplet(
style: androidx.compose.ui.text.TextStyle,
showSummaries: Boolean,
source: Bookmark,
onWord: (String) -> Unit,
onWord: (String, Bookmark) -> Unit,
) {
// Tap, not long-press: long-press belongs to the text selection this sits inside.
var actionsOpen by remember(couplet) { mutableStateOf(false) }
val passage = source.copy(excerpt = couplet.joinToString("\n") { it.text })
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
couplet.forEach { verse ->
VerseText(
verse = verse,
style = style,
onWord = onWord,
onWord = { onWord(it, passage) },
// A tap that lands between words still opens the couplet's own actions.
onElsewhere = { actionsOpen = !actionsOpen },
)
@@ -285,12 +295,49 @@ private fun Couplet(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
modifier = Modifier.padding(top = 4.dp),
)
// Ganjoor writes these in Persian. Offered only once an assistant is set up:
// a button under every couplet earns its space only if it can answer, and the
// couplet's own actions are behind a tap between words that few will find.
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
}
}
}
// A visible way in. The tap between words still works, but on a full line of poetry it
// almost never lands there, so the actions were effectively unreachable without this.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = { actionsOpen = !actionsOpen },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = if (actionsOpen) Icons.Default.KeyboardArrowUp
else Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.couplet_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (actionsOpen) {
PassageActions(source.copy(excerpt = couplet.joinToString("\n") { it.text }))
PassageActions(passage)
// Below the buttons rather than among them: the answer needs the full width.
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
}
@@ -358,7 +405,7 @@ internal fun wordTappedAt(
/** Save this passage, or copy it. Saving keeps the link back to the poem; copying doesn't. */
@Composable
private fun PassageActions(passage: Bookmark) {
internal fun PassageActions(passage: Bookmark) {
val bookmarks = LocalBookmarks.current
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
@@ -395,12 +442,6 @@ private fun PassageActions(passage: Bookmark) {
)
Text(stringResource(R.string.share))
}
AssistantAction(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
@@ -419,7 +460,7 @@ private fun PoemSummary(summary: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AssistantAction(
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
@@ -2,6 +2,7 @@ package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -31,7 +32,9 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Definition
import com.ganjoor.android.data.Bookmark
import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Pronunciation
import com.ganjoor.android.ui.theme.readingStyle
@@ -62,11 +65,38 @@ private fun sourceLabel(source: String) = when (source) {
else -> R.string.source_daneshjoo
}
/** What the dictionary knows about a tapped word, as a sheet over the poem. */
/**
* What the dictionary knows about a tapped word, as a sheet over the poem.
*
* When the word came from a couplet, that couplet's own actions sit at the foot. Tapping a word
* is the one gesture every reader finds; saving, copying or sharing the line used to be behind a
* tap that landed between words, which on a full line of poetry almost never happens.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WordSheet(word: String, onDismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss) { WordLookup(word) }
fun WordSheet(word: String, onDismiss: () -> Unit, passage: Bookmark? = null) {
ModalBottomSheet(onDismissRequest = onDismiss) {
// Inside the lookup's own scroll, not after it: the lookup scrolls, so anything placed
// below it is pushed past the bottom of the sheet with no way to reach it.
WordLookup(word) {
if (passage == null) return@WordLookup
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
Text(
text = stringResource(R.string.this_couplet),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
PassageActions(passage)
if (LocalAssistant.current.serverReady) {
AssistantInline(
prompt = "explain",
text = passage.excerpt.orEmpty(),
label = R.string.assistant_explain,
instruction = R.string.assistant_ask_prompt,
)
}
}
}
}
/**
@@ -75,7 +105,7 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
* to sit over, and a scrim with no content under it is just a grey window.
*/
@Composable
fun WordLookup(word: String) {
fun WordLookup(word: String, footer: @Composable ColumnScope.() -> Unit = {}) {
val prefs = LocalSettings.current.value
// A suggestion replaces what is being looked up, so the sheet can be followed like a trail.
var current by remember(word) { mutableStateOf(word) }
@@ -191,5 +221,8 @@ fun WordLookup(word: String) {
}
}
}
}
footer()
}
}
+2
View File
@@ -105,4 +105,6 @@
<string name="assistant_ask_prompt">این شعر فارسی را ترجمه کن و معنایش را توضیح بده:</string>
<string name="assistant_title">هوش مصنوعی</string>
<string name="assistant_translate_prompt">این متن را ترجمه کن:</string>
<string name="this_couplet">این بیت</string>
<string name="couplet_options">گزینه‌های این بیت</string>
</resources>
+2
View File
@@ -105,4 +105,6 @@
<string name="assistant_ask_prompt">اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں:</string>
<string name="assistant_title">مصنوعی ذہانت</string>
<string name="assistant_translate_prompt">اس کا ترجمہ کریں:</string>
<string name="this_couplet">یہ شعر</string>
<string name="couplet_options">اس شعر کے اختیارات</string>
</resources>
+2
View File
@@ -110,4 +110,6 @@
<string name="assistant_ask_prompt">Translate this Persian poetry and explain what it means:</string>
<string name="assistant_title">AI assistant</string>
<string name="assistant_translate_prompt">Translate this:</string>
<string name="this_couplet">This couplet</string>
<string name="couplet_options">Options for this couplet</string>
</resources>
@@ -1,8 +1,11 @@
package com.ganjoor.android
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -30,6 +33,31 @@ class AssistantTest {
assertEquals("the rose", Assistant.reply(body, claude = true))
}
@Test
fun `the cache keeps answers apart by prompt, language and text`() {
val a = Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x")
assertEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("explain", AssistantLanguage.Urdu, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.English, "x"))
assertNotEquals(a, Assistant.cacheKey("translate", AssistantLanguage.Urdu, "y"))
}
/** Scrolling a long poem must not re-ask for something already answered. */
@Test
fun `a remembered answer comes back`() {
val key = Assistant.cacheKey("summary", AssistantLanguage.Urdu, "remembered")
assertNull(Assistant.cached(key))
Assistant.remember(key, "the answer")
assertEquals("the answer", Assistant.cached(key))
}
@Test
fun `the cache does not grow without bound`() {
repeat(40) { Assistant.remember("bulk-$it", "reply $it") }
assertNull(Assistant.cached("bulk-0"))
assertEquals("reply 39", Assistant.cached("bulk-39"))
}
@Test
fun `an empty reply is an error, not an empty answer`() {
assertThrows(IOException::class.java) { Assistant.reply("""{"choices":[]}""", false) }
@@ -0,0 +1,8 @@
Share a poem, a couplet, or any selected text, with a link back to ganjoor.net.
Look up a word from the text-selection menu, in this app or any other.
An optional AI assistant, off by default: connect your own Ollama, LM Studio,
llama.cpp or LocalAI server, or a service such as OpenAI, DeepSeek, Gemini or
Claude, to translate Ganjoor's summaries and explain a couplet. Nothing is sent
anywhere until you set one up, and no key ships with the app.
@@ -0,0 +1,8 @@
An assistant's translation now appears under the summary it translates, in the
page, instead of over it. Ganjoor writes a summary under each couplet as well
as under the poem, and both can be translated.
A couplet's save, copy, share and explain are reachable again: a chevron at the
end of every couplet opens them, and they also sit at the foot of the word
lookup. They used to need a tap that landed between words, which on a line of
poetry almost never happens.
@@ -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,8 @@
هم‌رسانی شعر، بیت، یا هر متنی که انتخاب کرده‌اید، همراه با پیوند به گنجور.
جست‌وجوی معنی واژه از منوی انتخاب متن، در این برنامه و در هر برنامهٔ دیگری.
دستیار هوش مصنوعی، به‌صورت اختیاری و به‌طور پیش‌فرض خاموش: سرور خودتان
(Ollama، LM Studio، llama.cpp، LocalAI) یا سرویسی مانند OpenAI، DeepSeek،
Gemini یا Claude را وصل کنید تا خلاصه‌های گنجور را ترجمه و بیت‌ها را شرح دهد.
تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ کلیدی همراه برنامه نیست.
@@ -0,0 +1,7 @@
ترجمهٔ دستیار حالا زیر همان خلاصه‌ای که ترجمه می‌کند، در خودِ صفحه نشان داده
می‌شود، نه روی آن. گنجور هم زیر هر بیت خلاصه می‌نویسد و هم زیر شعر؛ هر دو را
می‌توان ترجمه کرد.
ذخیره، رونوشت، هم‌رسانی و شرحِ هر بیت دوباره در دسترس است: نشانهٔ پیکان در
انتهای هر بیت آن‌ها را باز می‌کند و در پای جست‌وجوی واژه هم هست. پیش از این
باید جایی میان دو واژه ضربه می‌زدید، که در یک مصراع شعر تقریباً هرگز رخ نمی‌دهد.
@@ -0,0 +1,2 @@
سپاس‌گزاری از Material Symbols که نشانه‌های هم‌رسانی، جست‌وجو و دستیار از روی
آن کشیده شده‌اند، و روشن‌کردن شرایط مربوط به دستیار هوش مصنوعیِ اختیاری.
@@ -0,0 +1,8 @@
کلام، شعر، یا کوئی بھی منتخب متن شیئر کریں، گنجور کے لنک کے ساتھ۔
متن منتخب کرنے کے مینو سے لفظ کا مطلب دیکھیں — اس ایپ میں اور کسی بھی دوسری ایپ میں۔
مصنوعی ذہانت کا اختیاری معاون، جو پہلے سے بند ہے: اپنا سرور (Ollama، LM Studio،
llama.cpp، LocalAI) یا OpenAI، DeepSeek، Gemini یا Claude جیسی سروس جوڑیں تاکہ
گنجور کے خلاصوں کا ترجمہ ہو اور اشعار کی تشریح ہو۔ جب تک آپ خود ترتیب نہ دیں کچھ
کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی کلید نہیں آتی۔
@@ -0,0 +1,8 @@
معاون کا ترجمہ اب اسی خلاصے کے نیچے، صفحے ہی میں دکھایا جاتا ہے جس کا ترجمہ ہو
رہا ہے — اوپر کسی الگ پردے میں نہیں۔ گنجور ہر شعر کے نیچے بھی خلاصہ لکھتا ہے
اور پورے کلام کے نیچے بھی؛ دونوں کا ترجمہ ہو سکتا ہے۔
ہر شعر کے محفوظ کرنے، نقل، شیئر اور تشریح کے بٹن دوبارہ قابلِ رسائی ہیں: ہر شعر
کے آخر میں ایک نشان انہیں کھولتا ہے، اور وہ لفظ کی لغت کے نیچے بھی موجود ہیں۔
پہلے ان کے لیے دو الفاظ کے درمیان ٹیپ کرنا پڑتا تھا، جو شعر کی سطر میں تقریباً
کبھی نہیں ہوتا۔
@@ -0,0 +1,2 @@
Material Symbols کا اعتراف، جن سے شیئر، لغت اور معاون کے نشان لیے گئے ہیں، اور
اختیاری مصنوعی ذہانت کے معاون سے متعلق شرائط کی وضاحت۔
+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
+26
View File
@@ -0,0 +1,26 @@
# 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 | `HEAD` | Credits Material Symbols for the glyphs, and states the terms around the optional assistant. |
## 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
```
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.