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
Anas RashidandClaude Opus 5 d320137063 Drop the "ask an assistant" entry from the selection menu
With no local server configured it opened the same chooser Share does, only
with a prompt in front of the text. Two entries for one chooser makes the
reader choose twice, and the third entry pushed Look up into the overflow;
both now fit in the bar.

Asking moves to the lookup screen's top bar, where it keeps the part that was
actually distinct: a configured server answers in place, and without one the
question still goes out with "translate this and explain what it means"
already written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:14:22 +02:00
Anas RashidandClaude Opus 5 90b527522b Use the AI sparkle for the assistant action
Material's auto_awesome, the shape that has come to mean "ask a model" — the
same one Gemini and the rest use. Theirs are trademarks and have no place in an
F-Droid build; this one ships under the Apache licence already covering the
app's icons.

It now marks the action everywhere it appears: the selection-menu entry, the
lookup screen's top bar, and the translate and explain buttons in the reader,
which were text alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:02:27 +02:00
Anas RashidandClaude Opus 5 2f16b52c2c Use the standard glyphs for share, look up and ask
Share was already the Material share icon in both top bars, but appeared as
bare text in the couplet actions and under an assistant's answer, so the same
action looked like two different things. It now carries the glyph everywhere.

The selection-menu entries had no icons of their own, so the system drew the
app logo three times over. Each alias now has the icon for its verb, from
Material's own path data: the share glyph people know from every other app,
a magnifier for the dictionary, and send for handing a question elsewhere.
Whether the floating toolbar draws them is the system's decision, not ours.

"Ask an assistant" had a magnifier, which belongs to searching. It sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:00:09 +02:00
Anas RashidandClaude Opus 5 65bc9ed0fc Talk to Claude as well as the OpenAI-shaped services
Everything else worth connecting to — Ollama, LM Studio, llama.cpp, LocalAI,
OpenAI, DeepSeek, and Gemini through its compatibility endpoint — speaks the
OpenAI chat-completions shape, so one client covered them all. Anthropic does
not: it is /v1/messages, an x-api-key header, and a reply that arrives as
content blocks rather than a message. That one exception is now handled, keyed
off the host, so the callers never see the difference.

The endpoint note lists the services by name and URL. Knowing the app "supports
OpenAI-compatible servers" is not the same as knowing what to type.

Presets stay self-hosted only: naming a paid service in a one-tap button is
steering people towards it, and the field takes any URL regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:54:19 +02:00
Anas RashidandClaude Opus 5 576a07ae31 Wire up the optional AI assistant
The page existed but nothing reached it. It now has a route, a provider in
MainActivity, and an entry at the foot of the reading-settings sheet beside
About, where it reads as one more optional setting rather than a feature of
the app.

Two actions use it: translating Ganjoor's own Persian summary under a poem,
and explaining a selected couplet. Both go through AssistantAction, which asks
the reader's own server when one is configured and otherwise hands the question
to another installed app. Neither path needs anyone to have set anything up,
which matters because the whole feature is optional.

Urdu for AI is مصنوعی ذہانت, not the transliterated "اے آئی"; the Farsi now
says هوش مصنوعی to match, rather than "smart assistant".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:49:37 +02:00
Anas RashidandClaude Opus 5 3d4f7aae3a Share verses and poems, and reach the dictionary from any app
Selected text now gets three entries in the system selection menu — look up,
ask an assistant, and share — and the poem screen gains a share action of its
own, next to the per-couplet save and copy.

Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so a
custom entry cannot be added to that menu from inside the reader.
ACTION_PROCESS_TEXT goes round it: the system builds part of the menu from
installed activities handling that intent, so these appear here and in every
other app. Three activity-aliases rather than one Ganjoor entry, because the
menu is a place for verbs: a reader who means "look this up" should not have to
pick an app first and then say what they wanted.

"Ask an assistant" cannot name Claude or Gemini — only those apps can put their
own name in that menu. It opens the share chooser with the question already
written, which lists whichever assistant is installed. That hand-off is also
what keeps this acceptable under F-Droid's rules: no vendor SDK, no API key,
nothing but an intent the reader confirms.

The activity is opaque and themed rather than a transparent one hosting a
sheet. PROCESS_TEXT starts it in its own task, so the app the text came from is
not behind this window, and a scrim over nothing is just a grey screen.

WordLookup and AssistantAnswer are split out of their sheets so the same code
serves as a sheet in the reader and a screen here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:45:13 +02:00
anas 9445f44759 Merge pull request 'Dictionary coverage, pronunciation, and chapter order' (#1) from feature/dictionary-coverage-and-chapter-order into main
Reviewed-on: #1
2026-10-04 16:12:01 +00:00
41 changed files with 1473 additions and 26 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"
}
+42 -1
View File
@@ -12,7 +12,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Ganjoorappandroid">
<activity
android:name=".MainActivity"
@@ -26,6 +26,47 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
The selection menu entries, one per action. Android builds part of that menu from
installed ACTION_PROCESS_TEXT activities, so these appear in this app and in every other
one. Three aliases rather than a single Ganjoor entry: the menu is a place for verbs, and
a reader who means "look this up" should not have to pick the app first.
There is no "ask an assistant" entry: with no local server configured it would open the
same chooser Share does, and two entries for one chooser makes the reader choose twice.
Asking lives on the lookup screen instead, where it can use a configured server.
-->
<activity
android:name=".ProcessTextActivity"
android:exported="false"
android:theme="@style/Theme.Ganjoorappandroid" />
<activity-alias
android:name=".LookUpText"
android:icon="@drawable/ic_lookup"
android:targetActivity=".ProcessTextActivity"
android:exported="true"
android:label="@string/look_up">
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".ShareText"
android:icon="@drawable/ic_share"
android:targetActivity=".ProcessTextActivity"
android:exported="true"
android:label="@string/share">
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity-alias>
</application>
</manifest>
@@ -16,6 +16,8 @@ import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.Ganjoor
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.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.theme.GanjoorTheme
@@ -53,11 +55,13 @@ class MainActivity : ComponentActivity() {
setContent {
val settings = remember { settings }
val bookmarks = remember { Bookmarks(applicationContext) }
val assistant = remember { AssistantSettings(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,
// 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,180 @@
package com.ganjoor.android
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toDrawable
import com.ganjoor.android.data.AssistantSettings
import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.ui.AssistantResultSheet
import com.ganjoor.android.ui.LocalSettings
import com.ganjoor.android.ui.Settings
import com.ganjoor.android.ui.WordLookup
import com.ganjoor.android.ui.theme.readingStyle
import com.ganjoor.android.ui.shareText
import com.ganjoor.android.ui.theme.GanjoorTheme
import com.ganjoor.android.ui.theme.windowBackground
import java.util.Locale
/**
* What the app offers on selected text, anywhere on the phone.
*
* One activity behind three aliases — LookUpText, AskAssistantText and ShareText — so the system
* menu shows verbs rather than one Ganjoor entry that then asks what you meant. Which alias was
* tapped arrives as the intent's component name.
*
* Compose stopped routing SelectionContainer through LocalTextToolbar, so an entry can't be added
* to that menu from inside the reader. ACTION_PROCESS_TEXT goes round that: the system builds part
* of the selection menu from activities that handle this intent. Registering one puts Ganjoor
* there — and not only in this app. Select a Persian word in a browser or a messaging app and the
* dictionary is one tap away.
*
* It is an ordinary opaque screen, not a transparent one with a sheet. The intent starts it in its
* own task, so the app the text was selected in is not behind this window; a translucent activity
* showed a grey void with the dim scrim over nothing.
*/
class ProcessTextActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context) {
val config = Configuration(newBase.resources.configuration)
config.setLocale(Locale.forLanguageTag(Settings.language(newBase).tag))
super.attachBaseContext(newBase.createConfigurationContext(config))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Dictionary.init(applicationContext)
val selected = (
intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT_READONLY)
)?.toString()?.trim().orEmpty()
if (selected.isEmpty()) {
finish()
return
}
val sharing = intent.component?.className?.endsWith("ShareText") == true
val settings = Settings(applicationContext)
val systemInDark = resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
// Painted before the first frame, so this doesn't flash white on the way in.
window.setBackgroundDrawable(
windowBackground(settings.value.theme, systemInDark, settings.value.oled).toDrawable()
)
setContent {
val assistant = remember { AssistantSettings(applicationContext) }
CompositionLocalProvider(
LocalSettings provides settings,
LocalAssistant provides assistant,
LocalLayoutDirection provides LayoutDirection.Rtl,
) {
GanjoorTheme(settings.value.theme, settings.value.language, settings.value.oled) {
SelectionScreen(selected, sharing, onClose = ::finish)
}
}
}
}
}
/**
* Share goes straight to the system chooser and finishes, so this screen is only ever seen for a
* lookup. Asking an assistant is a button here rather than an entry of its own in the selection
* menu: with no server configured it opens the same chooser Share does, and two menu entries for
* one chooser is a menu that makes the reader choose twice.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionScreen(selected: String, sharing: Boolean, onClose: () -> Unit) {
val context = LocalContext.current
val assistant = LocalAssistant.current
val prompt = stringResource(R.string.assistant_ask_prompt)
var asking by remember { mutableStateOf(false) }
LaunchedEffect(sharing) {
if (sharing) {
context.shareText(selected)
onClose()
}
}
if (sharing) return
Scaffold(
topBar = {
TopAppBar(
title = { Text(selected, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, stringResource(R.string.back))
}
},
actions = {
// The dictionary knows single words; a whole line is better asked about. Your
// own server answers here; without one the question goes to whichever
// assistant is installed, which is what keeps this F-Droid-clean — no vendor
// SDK, no key, nothing but an intent you confirm.
IconButton(onClick = {
if (assistant.serverReady) asking = true
else context.shareText("$prompt\n\n$selected")
}) {
Icon(
painter = painterResource(R.drawable.ic_ask),
contentDescription = stringResource(R.string.assistant_ask),
)
}
IconButton(onClick = { context.shareText(selected) }) {
Icon(Icons.Default.Share, stringResource(R.string.share))
}
},
)
},
) { insets ->
Column(modifier = Modifier.fillMaxSize().padding(insets)) {
WordLookup(selected)
}
}
if (asking) {
AssistantResultSheet(
prompt = "explain",
text = selected,
onDismiss = { asking = false },
)
}
}
@@ -0,0 +1,279 @@
package com.ganjoor.android.data
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.core.content.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* How the app reaches a language model, if the reader wants one at all.
*
* Nothing here is required: every feature of the app works with this switched off, which is both
* the point and what keeps it acceptable as free software — no account, no bundled vendor SDK,
* no key shipped with the app, and no default endpoint.
*/
enum class AssistantMode { Off, Server, ShareToApp }
/** The language a translation or summary is asked for. */
enum class AssistantLanguage(val code: String, val englishName: String) {
Urdu("ur", "Urdu"),
English("en", "English"),
}
/**
* A server the reader runs or subscribes to. Only the free, self-hosted ones are offered as
* presets; anything else is typed in, so the app never steers anyone towards a paid service.
*/
data class AssistantPreset(val name: String, val baseUrl: String, val model: String)
val ASSISTANT_PRESETS = listOf(
AssistantPreset("Ollama", "http://127.0.0.1:11434/v1", "llama3.2"),
AssistantPreset("LM Studio", "http://127.0.0.1:1234/v1", "local-model"),
AssistantPreset("llama.cpp", "http://127.0.0.1:8080/v1", "local-model"),
AssistantPreset("LocalAI", "http://127.0.0.1:8080/v1", "local-model"),
)
/** Reader-supplied configuration, kept on the device and never sent anywhere but the endpoint. */
class AssistantSettings(context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var mode by mutableStateOf(
runCatching { AssistantMode.valueOf(prefs.getString(MODE, null)!!) }
.getOrDefault(AssistantMode.Off)
)
private set
var baseUrl by mutableStateOf(prefs.getString(BASE_URL, "").orEmpty())
private set
var model by mutableStateOf(prefs.getString(MODEL, "").orEmpty())
private set
/**
* Optional, and only needed by services that demand one — a server on your own machine
* usually doesn't. Stored in the app's private preferences, unencrypted, like any other
* setting; don't put a key here you would mind someone with your unlocked phone reading.
*/
var apiKey by mutableStateOf(prefs.getString(API_KEY, "").orEmpty())
private set
var language by mutableStateOf(
runCatching { AssistantLanguage.valueOf(prefs.getString(LANGUAGE, null)!!) }
.getOrDefault(AssistantLanguage.Urdu)
)
private set
fun update(
mode: AssistantMode = this.mode,
baseUrl: String = this.baseUrl,
model: String = this.model,
apiKey: String = this.apiKey,
language: AssistantLanguage = this.language,
) {
this.mode = mode
this.baseUrl = baseUrl.trim()
this.model = model.trim()
this.apiKey = apiKey.trim()
this.language = language
prefs.edit(commit = true) {
putString(MODE, mode.name)
putString(BASE_URL, this@AssistantSettings.baseUrl)
putString(MODEL, this@AssistantSettings.model)
putString(API_KEY, this@AssistantSettings.apiKey)
putString(LANGUAGE, language.name)
}
}
/** Whether a request can actually be made, as opposed to merely being switched on. */
val serverReady: Boolean
get() = mode == AssistantMode.Server && baseUrl.isNotBlank() && model.isNotBlank()
private companion object {
const val PREFS = "ganjoor"
const val MODE = "assistantMode"
const val BASE_URL = "assistantBaseUrl"
const val MODEL = "assistantModel"
const val API_KEY = "assistantApiKey"
const val LANGUAGE = "assistantLanguage"
}
}
val LocalAssistant = staticCompositionLocalOf<AssistantSettings> { error("No AssistantSettings") }
@Serializable
private data class ChatRequest(
val model: String,
val messages: List<ChatMessage>,
val stream: Boolean = false,
val temperature: Double = 0.2,
)
@Serializable
private data class ChatMessage(val role: String, val content: String)
@Serializable
private data class ChatResponse(val choices: List<Choice> = emptyList())
@Serializable
private data class Choice(val message: ChatMessage? = null)
/**
* Anthropic's own shape. Everything else worth talking to speaks the OpenAI one, so this is the
* single exception the client makes — a different path, a different header, and a reply that
* arrives as a list of content blocks rather than a message.
*/
@Serializable
private data class ClaudeRequest(
val model: String,
val system: String,
val messages: List<ChatMessage>,
@SerialName("max_tokens") val maxTokens: Int = 2048,
)
@Serializable
private data class ClaudeResponse(val content: List<ClaudeBlock> = emptyList())
@Serializable
private data class ClaudeBlock(val type: String = "", val text: String = "")
/**
* Talks to anything that speaks the OpenAI chat-completions shape, which Ollama, LM Studio,
* llama.cpp and LocalAI all do. That one shape is why no vendor library is needed: it is an
* HTTP POST with a JSON body, and the app already has an HTTP client and a JSON parser.
*/
object Assistant {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val media = "application/json; charset=utf-8".toMediaType()
// A model running on a phone or an old laptop can take a while to answer.
private val http by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(180, TimeUnit.SECONDS)
.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)
/** Pulls the reply out of whichever shape came back, so the callers never see the difference. */
internal fun reply(body: String, claude: Boolean): String {
val text = if (claude) {
json.decodeFromString<ClaudeResponse>(body)
.content.firstOrNull { it.type == "text" }?.text
} else {
json.decodeFromString<ChatResponse>(body)
.choices.firstOrNull()?.message?.content
}
return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") }
}
suspend fun ask(
settings: AssistantSettings,
system: String,
user: String,
): Result<String> = withContext(Dispatchers.IO) {
if (!settings.serverReady) {
return@withContext Result.failure(IllegalStateException("not configured"))
}
val claude = isClaude(settings.baseUrl)
val base = settings.baseUrl.trimEnd('/')
val body = if (claude) {
json.encodeToString(
ClaudeRequest(
model = settings.model,
system = system,
messages = listOf(ChatMessage("user", user)),
)
)
} else {
json.encodeToString(
ChatRequest(
model = settings.model,
messages = listOf(
ChatMessage("system", system),
ChatMessage("user", user),
),
)
)
}.toRequestBody(media)
val request = Request.Builder()
.url(if (claude) "$base/messages" else "$base/chat/completions")
.post(body)
.apply {
if (settings.apiKey.isBlank()) return@apply
if (claude) {
header("x-api-key", settings.apiKey)
header("anthropic-version", "2023-06-01")
} else {
header("Authorization", "Bearer ${settings.apiKey}")
}
}
.build()
runCatching {
http.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${text.take(200)}")
reply(text, claude)
}
}
}
}
/** The instruction given to the model, kept here so it can be read and argued with. */
object Prompts {
private const val ROLE =
"You are helping someone read classical Persian poetry from Ganjoor. Answer only with " +
"what was asked, with no preamble and no notes about yourself."
fun system(language: AssistantLanguage) =
"$ROLE Reply in ${language.englishName}."
fun translate(text: String) = "Translate this Persian poetry faithfully:\n\n$text"
fun summarise(text: String) =
"Summarise what this Persian poem says, in a short paragraph:\n\n$text"
fun explain(couplet: String) =
"Explain the meaning and the imagery of this couplet:\n\n$couplet"
/** Ganjoor publishes its own AI summaries in Persian; this carries one across. */
fun translateSummary(summary: String) =
"Translate this Persian commentary on a poem:\n\n$summary"
}
@@ -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",
@@ -0,0 +1,176 @@
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.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
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
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
/**
* 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.
*/
/**
* 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 AssistantInline(prompt: String, text: String, label: Int, instruction: Int) {
val assistant = LocalAssistant.current
val context = LocalContext.current
val ask = stringResource(instruction)
var asked by remember(text) { mutableStateOf(false) }
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)
}
}
}
/** The question and its answer, with nothing around them. */
@Composable
fun AssistantReply(prompt: String, text: String, modifier: Modifier = Modifier) {
val settings = LocalAssistant.current
val context = LocalContext.current
val language = settings.language
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) {
"translate" -> Prompts.translate(text)
"summarise" -> Prompts.summarise(text)
"explain" -> Prompts.explain(text)
"summary" -> Prompts.translateSummary(text)
else -> text
},
)
result.getOrNull()?.let { Assistant.remember(key, it) }
value = result
}
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
)
AssistantReply(prompt = prompt, text = text)
}
}
}
@@ -0,0 +1,213 @@
package com.ganjoor.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.ASSISTANT_PRESETS
import com.ganjoor.android.data.Assistant
import com.ganjoor.android.data.AssistantLanguage
import com.ganjoor.android.data.AssistantMode
import com.ganjoor.android.data.LocalAssistant
import com.ganjoor.android.data.Prompts
import kotlinx.coroutines.launch
/**
* Everything to do with the optional language model, on a page of its own because none of it is
* needed to read a poem.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AssistantScreen(onUp: () -> Unit, onHome: () -> Unit) {
val settings = LocalAssistant.current
val scope = rememberCoroutineScope()
var baseUrl by remember { mutableStateOf(settings.baseUrl) }
var model by remember { mutableStateOf(settings.model) }
var apiKey by remember { mutableStateOf(settings.apiKey) }
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.assistant)) },
navigationIcon = {
IconButton(onClick = onUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
actions = { HomeAction(onHome) },
)
}
) { insets ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(insets)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.assistant_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
SectionLabel(R.string.assistant_mode)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistantMode.entries.forEach { option ->
FilterChip(
selected = settings.mode == option,
onClick = { settings.update(mode = option) },
label = { Text(stringResource(modeLabel(option))) },
)
}
}
Text(
text = stringResource(modeNote(settings.mode)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SectionLabel(R.string.assistant_language)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistantLanguage.entries.forEach { option ->
FilterChip(
selected = settings.language == option,
onClick = { settings.update(language = option) },
label = { Text(option.englishName) },
)
}
}
if (settings.mode == AssistantMode.Server) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionLabel(R.string.assistant_presets)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ASSISTANT_PRESETS.forEach { preset ->
SuggestionChip(
onClick = {
baseUrl = preset.baseUrl
model = preset.model
settings.update(baseUrl = preset.baseUrl, model = preset.model)
},
label = { Text(preset.name) },
)
}
}
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it; settings.update(baseUrl = it) },
label = { Text(stringResource(R.string.assistant_base_url)) },
supportingText = { Text(stringResource(R.string.assistant_base_url_note)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = model,
onValueChange = { model = it; settings.update(model = it) },
label = { Text(stringResource(R.string.assistant_model)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = apiKey,
onValueChange = { apiKey = it; settings.update(apiKey = it) },
label = { Text(stringResource(R.string.assistant_api_key)) },
supportingText = { Text(stringResource(R.string.assistant_api_key_note)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
testing = true
result = null
scope.launch {
val reply = Assistant.ask(
settings = settings,
system = Prompts.system(settings.language),
user = Prompts.translate("سلام"),
)
testing = false
result = reply.fold(
onSuccess = { it.take(160) },
onFailure = { it.message ?: "failed" },
)
}
},
enabled = !testing && settings.serverReady,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(if (testing) R.string.assistant_testing else R.string.assistant_test))
}
result?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text = stringResource(R.string.assistant_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
private fun modeLabel(mode: AssistantMode) = when (mode) {
AssistantMode.Off -> R.string.assistant_off
AssistantMode.Server -> R.string.assistant_server
AssistantMode.ShareToApp -> R.string.assistant_share
}
private fun modeNote(mode: AssistantMode) = when (mode) {
AssistantMode.Off -> R.string.assistant_off_note
AssistantMode.Server -> R.string.assistant_server_note
AssistantMode.ShareToApp -> R.string.assistant_share_note
}
@Composable
private fun SectionLabel(resId: Int) {
Text(
text = stringResource(resId),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 10.dp),
)
}
@@ -42,6 +42,9 @@ object DownloadsRoute
@Serializable
object AboutRoute
@Serializable
object AssistantRoute
/**
* Opens a page with the poet list as the only thing beneath it.
*
@@ -75,6 +78,10 @@ fun GanjoorApp() {
settingsOpen = false
nav.navigate(AboutRoute)
},
LocalOpenAssistant provides {
settingsOpen = false
nav.navigate(AssistantRoute)
},
) {
NavHost(
navController = nav,
@@ -144,6 +151,9 @@ fun GanjoorApp() {
composable<DownloadsRoute> {
DownloadsScreen(onUp = { nav.goHome() })
}
composable<AssistantRoute> {
AssistantScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
}
composable<AboutRoute> {
AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() })
}
@@ -11,10 +11,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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
import androidx.compose.material.icons.filled.FavoriteBorder
@@ -39,6 +43,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -50,7 +55,9 @@ 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
import com.ganjoor.android.data.Verse
import com.ganjoor.android.data.couplets
@@ -67,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
@@ -95,6 +104,7 @@ fun PoemScreen(
},
actions = {
HomeAction(onHome)
ShareAction(poem = poem, couplets = couplets)
BookmarkAction(
url = fullUrl,
title = poem.title,
@@ -109,10 +119,10 @@ fun PoemScreen(
// per-couplet actions save a passage with the reference attached, which a raw copy
// would lose.
//
// ponytail: no dictionary entry in the selection toolbar. Compose 1.10 stopped
// routing SelectionContainer through LocalTextToolbar — a custom TextToolbar is
// simply never asked to show — and the replacement, foundation's contextmenu
// package, is internal. Revisit when that becomes public API.
// The system selection menu gets its own entries from ProcessTextActivity, not from
// here: Compose 1.10 stopped routing SelectionContainer through LocalTextToolbar, so
// a custom TextToolbar is never asked to show. ACTION_PROCESS_TEXT goes round that,
// and reaches every other app's selection menu as a side effect.
SelectionContainer {
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -143,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 },
)
}
@@ -176,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 },
)
}
}
@@ -211,6 +225,27 @@ private fun Breadcrumbs(fullTitle: String, fullUrl: String, onCategory: (String)
}
}
/**
* Shares the poem as text, with its title and link. The verses are joined couplet by couplet so
* the shape survives in apps that know nothing about Persian prosody.
*/
@Composable
private fun ShareAction(poem: Poem, couplets: List<List<Verse>>) {
val context = LocalContext.current
IconButton(onClick = {
val body = couplets.joinToString("\n\n") { couplet ->
couplet.joinToString("\n") { it.text }
}
context.shareText(
text = "${poem.fullTitle}\n\n$body",
url = poem.fullUrl,
subject = poem.fullTitle,
)
}) {
Icon(Icons.Default.Share, stringResource(R.string.share_poem))
}
}
@Composable
private fun BookmarkAction(url: String, title: String, subtitle: String) {
val bookmarks = LocalBookmarks.current
@@ -236,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 },
)
@@ -259,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,
)
}
}
}
@@ -332,9 +405,10 @@ 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
val saved = bookmarks.contains(passage)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -352,6 +426,22 @@ private fun PassageActions(passage: Bookmark) {
}) {
Text(stringResource(R.string.copy))
}
TextButton(onClick = {
context.shareText(
text = passage.excerpt.orEmpty(),
url = passage.url,
subject = passage.title,
)
}) {
// The same glyph as the top bar and as every other Android app: share is a shape
// people recognise before they read the word next to it.
Icon(
imageVector = Icons.Default.Share,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp),
)
Text(stringResource(R.string.share))
}
}
}
@@ -370,6 +460,12 @@ private fun PoemSummary(summary: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AssistantInline(
prompt = "summary",
text = summary,
label = R.string.assistant_translate,
instruction = R.string.assistant_translate_prompt,
)
}
}
@@ -44,6 +44,7 @@ val LocalOpenReadingSettings = staticCompositionLocalOf<() -> Unit> {
/** Opens the About and licences screen; the sheet is the only place that needs it. */
val LocalOpenAbout = staticCompositionLocalOf<() -> Unit> { error("No about host") }
val LocalOpenAssistant = staticCompositionLocalOf<() -> Unit> { error("No assistant host") }
/** Top-bar button that opens the reading settings sheet. */
@Composable
@@ -137,11 +138,13 @@ fun ReadingSettingsSheet(onDismiss: () -> Unit) {
settings.update { it.copy(showSummaries = on) }
}
val openAssistant = LocalOpenAssistant.current
TextButton(onClick = openAssistant, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.assistant_title))
}
val openAbout = LocalOpenAbout.current
TextButton(
onClick = openAbout,
modifier = Modifier.padding(top = 16.dp),
) {
TextButton(onClick = openAbout) {
Text(stringResource(R.string.about))
}
}
@@ -0,0 +1,30 @@
package com.ganjoor.android.ui
import android.content.Context
import android.content.Intent
import com.ganjoor.android.R
/**
* Hands text to whatever the reader has installed: a messaging app, a notes app, a translator, or
* an AI assistant.
*
* ACTION_SEND is the entire mechanism. The system draws the chooser, so the app needs no list of
* apps, no per-vendor integration, no permission, and nothing to update when the reader installs
* something new — which is also why this is the one way to reach a proprietary assistant that
* stays within F-Droid's rules: the app ships no code belonging to it.
*/
fun Context.shareText(text: String, url: String? = null, subject: String? = null) {
// The link matters for poetry: a couplet with no reference is a quote nobody can look up.
val body = url?.let { "$text\n\n${webUrl(it)}" } ?: text
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, body)
if (!subject.isNullOrBlank()) putExtra(Intent.EXTRA_SUBJECT, subject)
}
startActivity(Intent.createChooser(intent, getString(R.string.share)))
}
/** Ganjoor paths are stored as site-relative, the way the API gives them. */
fun webUrl(fullUrl: String): String =
if (fullUrl.startsWith("http")) fullUrl
else "https://ganjoor.net/" + fullUrl.trim('/')
@@ -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
@@ -43,7 +46,7 @@ private fun LeftToRight(content: @Composable () -> Unit) {
/** Lays a definition out the way its own script reads. */
@Composable
private fun InDirectionOf(text: String, content: @Composable () -> Unit) {
internal fun InDirectionOf(text: String, content: @Composable () -> Unit) {
val arabicScript = text.count { it in '\u0600'..'\u06FF' }
val latin = text.count { it in 'A'..'Z' || it in 'a'..'z' }
CompositionLocalProvider(
@@ -62,10 +65,47 @@ private fun sourceLabel(source: String) = when (source) {
else -> R.string.source_daneshjoo
}
/** What the dictionary knows about a tapped word. */
/**
* 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) {
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,
)
}
}
}
}
/**
* The lookup itself. Split out from the sheet because the selection-menu activity shows the same
* thing as a plain screen: it launches in its own task, so there is nothing behind it for a sheet
* to sit over, and a scrim with no content under it is just a grey window.
*/
@Composable
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) }
@@ -79,8 +119,7 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
value = if (definitions?.isEmpty() == true) Dictionary.suggest(current) else emptyList()
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Column(
modifier = Modifier
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
@@ -182,6 +221,8 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
}
}
}
}
footer()
}
}
+15
View File
@@ -0,0 +1,15 @@
<!--
Material "auto_awesome" — the sparkle that has become the generic sign for an AI action, the
same shape Gemini and the rest use. Theirs is a trademark; this is Material's own, under the
Apache licence already covering the icons the app ships.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M19,9l1.25,-2.75L23,5l-2.75,-1.25L19,1l-1.25,2.75L15,5l2.75,1.25L19,9zM11.5,9.5L9,4 6.5,9.5 1,12l5.5,2.5L9,20l2.5,-5.5L17,12l-5.5,-2.5zM19,15l-1.25,2.75L15,19l2.75,1.25L19,23l1.25,-2.75L23,19l-2.75,-1.25L19,15z" />
</vector>
+11
View File
@@ -0,0 +1,11 @@
<!-- Material "search", for the dictionary entry. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>
+11
View File
@@ -0,0 +1,11 @@
<!-- Material "share" — the standard Android share glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/textColorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92s2.92,-1.31 2.92,-2.92 -1.31,-2.92 -2.92,-2.92z" />
</vector>
+35
View File
@@ -72,4 +72,39 @@
<string name="oled_note">روی پوسته‌های تاریک اعمال می‌شود و در نمایشگر OLED باتری کمتری می‌برد</string>
<string name="play_recitation">پخش خوانش</string>
<string name="pause">توقف</string>
<string name="assistant">دستیار هوش مصنوعی</string>
<string name="assistant_intro">اختیاری است. همهٔ بخش‌های برنامه بدون آن کار می‌کنند. تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ حساب یا کلیدی همراه برنامه نیست.</string>
<string name="assistant_mode">شیوهٔ دسترسی به مدل</string>
<string name="assistant_off">خاموش</string>
<string name="assistant_off_note">بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند.</string>
<string name="assistant_server">سرور خودتان</string>
<string name="assistant_server_note">با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد.</string>
<string name="assistant_share">ارسال به یک برنامه</string>
<string name="assistant_share_note">متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود.</string>
<string name="assistant_language">پاسخ به زبان</string>
<string name="assistant_presets">آماده‌ها</string>
<string name="assistant_base_url">نشانی پایه</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">مدل</string>
<string name="assistant_api_key">کلید API (اختیاری)</string>
<string name="assistant_api_key_note">سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود.</string>
<string name="assistant_test">آزمایش اتصال</string>
<string name="assistant_testing">در حال آزمایش…</string>
<string name="assistant_privacy">وقتی ترجمه یا خلاصه می‌خواهید، تنها همان بیت یا شعری که انتخاب کرده‌اید به نشانی‌ای که تنظیم کرده‌اید فرستاده می‌شود.</string>
<string name="assistant_translate">ترجمه</string>
<string name="assistant_summarise">خلاصه</string>
<string name="assistant_explain">شرح این بیت</string>
<string name="assistant_translate_summary">ترجمهٔ خلاصهٔ گنجور</string>
<string name="assistant_not_set_up">برای استفاده، دستیار را در تنظیمات راه‌اندازی کنید.</string>
<string name="assistant_working">در حال پرسش…</string>
<string name="look_up">جست‌وجو در لغت‌نامه</string>
<string name="share">هم‌رسانی</string>
<string name="share_poem">هم‌رسانی این شعر</string>
<string name="selection_actions">چه کاری انجام شود؟</string>
<string name="assistant_ask">پرسش از دستیار</string>
<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>
@@ -10,4 +10,5 @@
<item name="android:windowBackground">@color/window_background</item>
<item name="android:windowSplashScreenBackground">@color/window_background</item>
</style>
</resources>
+1
View File
@@ -4,4 +4,5 @@
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
+35
View File
@@ -72,4 +72,39 @@
<string name="oled_note">تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے</string>
<string name="play_recitation">قرات سنیں</string>
<string name="pause">وقفہ</string>
<string name="assistant">مصنوعی ذہانت کا معاون</string>
<string name="assistant_intro">اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔</string>
<string name="assistant_mode">ماڈل تک رسائی کا طریقہ</string>
<string name="assistant_off">بند</string>
<string name="assistant_off_note">کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔</string>
<string name="assistant_server">آپ کا اپنا سرور</string>
<string name="assistant_server_note">ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔</string>
<string name="assistant_share">کسی ایپ کو بھیجیں</string>
<string name="assistant_share_note">متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔</string>
<string name="assistant_language">جواب کی زبان</string>
<string name="assistant_presets">تیار ترتیبات</string>
<string name="assistant_base_url">بنیادی یو آر ایل</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">ماڈل</string>
<string name="assistant_api_key">API کلید (اختیاری)</string>
<string name="assistant_api_key_note">آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔</string>
<string name="assistant_test">کنکشن جانچیں</string>
<string name="assistant_testing">جانچ جاری…</string>
<string name="assistant_privacy">جب آپ ترجمہ یا خلاصہ مانگتے ہیں تو صرف وہی شعر یا کلام بھیجا جاتا ہے جو آپ نے منتخب کیا۔</string>
<string name="assistant_translate">ترجمہ</string>
<string name="assistant_summarise">خلاصہ</string>
<string name="assistant_explain">اس شعر کی تشریح</string>
<string name="assistant_translate_summary">گنجور کے خلاصے کا ترجمہ</string>
<string name="assistant_not_set_up">استعمال کے لیے ترتیبات میں معاون قائم کریں۔</string>
<string name="assistant_working">پوچھا جا رہا ہے…</string>
<string name="look_up">لغت میں تلاش</string>
<string name="share">شیئر</string>
<string name="share_poem">یہ کلام شیئر کریں</string>
<string name="selection_actions">آپ کیا کرنا چاہیں گے؟</string>
<string name="assistant_ask">اسسٹنٹ سے پوچھیں</string>
<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>
+1
View File
@@ -10,4 +10,5 @@
<item name="android:windowBackground">@color/window_background</item>
<item name="android:windowSplashScreenBackground">@color/window_background</item>
</style>
</resources>
+35
View File
@@ -77,4 +77,39 @@
<string name="oled_note">Applies to the dark themes; saves power on OLED screens</string>
<string name="play_recitation">Play recitation</string>
<string name="pause">Pause</string>
<string name="assistant">AI assistant</string>
<string name="assistant_intro">Optional. Everything in this app works without it. Nothing is sent anywhere unless you set this up yourself, and no account or key ships with the app.</string>
<string name="assistant_mode">How to reach a model</string>
<string name="assistant_off">Off</string>
<string name="assistant_off_note">No assistant. Ganjoor&#8217;s own Persian summaries are still shown if you enable them in reading settings.</string>
<string name="assistant_server">Your own server</string>
<string name="assistant_server_note">Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own.</string>
<string name="assistant_share">Send to an app</string>
<string name="assistant_share_note">Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one.</string>
<string name="assistant_language">Answer in</string>
<string name="assistant_presets">Presets</string>
<string name="assistant_base_url">Base URL</string>
<string name="assistant_base_url_note">Ollama: http://127.0.0.1:11434/v1 · LM Studio: http://127.0.0.1:1234/v1 · OpenAI: https://api.openai.com/v1 · DeepSeek: https://api.deepseek.com/v1 · Gemini: https://generativelanguage.googleapis.com/v1beta/openai · Claude: https://api.anthropic.com/v1</string>
<string name="assistant_model">Model</string>
<string name="assistant_api_key">API key (optional)</string>
<string name="assistant_api_key_note">A server on your own machine usually needs none. Stored unencrypted on this device.</string>
<string name="assistant_test">Test connection</string>
<string name="assistant_testing">Testing…</string>
<string name="assistant_privacy">When you ask for a translation or a summary, only the verse or poem you chose is sent to the endpoint you configured.</string>
<string name="assistant_translate">Translate</string>
<string name="assistant_summarise">Summarise</string>
<string name="assistant_explain">Explain this couplet</string>
<string name="assistant_translate_summary">Translate Ganjoor&#8217;s summary</string>
<string name="assistant_not_set_up">Set up an assistant in settings to use this.</string>
<string name="assistant_working">Asking…</string>
<string name="look_up">Look up</string>
<string name="share">Share</string>
<string name="share_poem">Share this poem</string>
<string name="selection_actions">What would you like to do?</string>
<string name="assistant_ask">Ask an assistant</string>
<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
View File
@@ -9,4 +9,5 @@
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/window_background</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A language model the reader runs themselves — Ollama, LM Studio, llama.cpp — listens on plain
HTTP, usually on their own machine over the local network. Android's config can name domains
but not address ranges, so there is no way to permit exactly "the reader's own server" and
nothing else; cleartext is allowed and the choice of endpoint is left to them.
Everything the app fetches on its own account — the poems, the poet portraits, the recitations
— is HTTPS regardless, and none of it goes through this.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,66 @@
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
import java.io.IOException
class AssistantTest {
@Test
fun `recognises anthropic by its host`() {
assertTrue(Assistant.isClaude("https://api.anthropic.com/v1"))
assertFalse(Assistant.isClaude("https://api.openai.com/v1"))
assertFalse(Assistant.isClaude("http://127.0.0.1:11434/v1"))
}
@Test
fun `reads an openai reply`() {
val body = """{"choices":[{"message":{"role":"assistant","content":" the rose "}}]}"""
assertEquals("the rose", Assistant.reply(body, claude = false))
}
/** Anthropic answers with content blocks, and the first is not always the text one. */
@Test
fun `reads a claude reply past a non-text block`() {
val body = """{"content":[{"type":"thinking","text":"x"},{"type":"text","text":" the rose "}]}"""
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) }
assertThrows(IOException::class.java) { Assistant.reply("""{"content":[]}""", true) }
}
}
@@ -0,0 +1,25 @@
package com.ganjoor.android
import com.ganjoor.android.ui.webUrl
import org.junit.Assert.assertEquals
import org.junit.Test
class ShareTest {
@Test
fun `builds a ganjoor link from a stored path`() {
assertEquals("https://ganjoor.net/hafez/ghazal/sh1", webUrl("/hafez/ghazal/sh1"))
}
/** Paths arrive with a leading slash and sometimes a trailing one; neither may double up. */
@Test
fun `trims slashes rather than doubling them`() {
assertEquals("https://ganjoor.net/saadi/golestan", webUrl("/saadi/golestan/"))
assertEquals("https://ganjoor.net/saadi/golestan", webUrl("saadi/golestan"))
}
@Test
fun `leaves an absolute url alone`() {
assertEquals("https://ganjoor.net/x", webUrl("https://ganjoor.net/x"))
}
}
@@ -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.