From 3d4f7aae3a4a5b127d878a4988f1653183e3da71 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 18:45:13 +0200 Subject: [PATCH 1/6] Share verses and poems, and reach the dictionary from any app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/src/main/AndroidManifest.xml | 53 ++++- .../ganjoor/android/ProcessTextActivity.kt | 181 +++++++++++++++ .../com/ganjoor/android/data/Assistant.kt | 207 +++++++++++++++++ .../android/ui/AssistantResultSheet.kt | 108 +++++++++ .../com/ganjoor/android/ui/AssistantScreen.kt | 213 ++++++++++++++++++ .../java/com/ganjoor/android/ui/PoemScreen.kt | 43 +++- .../main/java/com/ganjoor/android/ui/Share.kt | 30 +++ .../java/com/ganjoor/android/ui/WordSheet.kt | 18 +- app/src/main/res/values-fa/strings.xml | 31 +++ app/src/main/res/values-night-v31/themes.xml | 1 + app/src/main/res/values-night/themes.xml | 1 + app/src/main/res/values-ur/strings.xml | 31 +++ app/src/main/res/values-v31/themes.xml | 1 + app/src/main/res/values/strings.xml | 31 +++ app/src/main/res/values/themes.xml | 1 + .../main/res/xml/network_security_config.xml | 17 ++ 16 files changed, 957 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt create mode 100644 app/src/main/java/com/ganjoor/android/data/Assistant.kt create mode 100644 app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt create mode 100644 app/src/main/java/com/ganjoor/android/ui/AssistantScreen.kt create mode 100644 app/src/main/java/com/ganjoor/android/ui/Share.kt create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc946e8..91e2eee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt new file mode 100644 index 0000000..4b678ee --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt @@ -0,0 +1,181 @@ +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.Search +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.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.AssistantAnswer +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 action = when (intent.component?.className?.substringAfterLast('.')) { + "AskAssistantText" -> SelectionAction.Ask + "ShareText" -> SelectionAction.Share + else -> SelectionAction.LookUp + } + + 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, action, onClose = ::finish) + } + } + } + } +} + +/** Which verb the reader picked in the selection menu. */ +enum class SelectionAction { LookUp, Ask, Share } + +/** + * Each alias goes straight to its action. Share finishes immediately — the system chooser is the + * whole interface — so this screen is only ever seen for a lookup or an answer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionScreen(selected: String, action: SelectionAction, onClose: () -> Unit) { + val context = LocalContext.current + val assistant = LocalAssistant.current + val prefs = LocalSettings.current.value + + // Without a server configured there is nothing to ask, so the question goes to whichever + // assistant the reader has installed. That hand-off is also what keeps this F-Droid-clean: + // no vendor SDK, no API key, nothing but an intent the reader confirms. + val askLocally = action == SelectionAction.Ask && assistant.serverReady + val prompt = stringResource(R.string.assistant_ask_prompt) + + LaunchedEffect(action) { + when { + action == SelectionAction.Share -> { + context.shareText(selected) + onClose() + } + + action == SelectionAction.Ask && !askLocally -> { + context.shareText("$prompt\n\n$selected") + onClose() + } + } + } + + if (action == SelectionAction.Share || (action == SelectionAction.Ask && !askLocally)) 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. + IconButton(onClick = { context.shareText("$prompt\n\n$selected") }) { + Icon(Icons.Default.Search, 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)) { + if (askLocally) AssistantAnswer(selected) else WordLookup(selected) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/data/Assistant.kt b/app/src/main/java/com/ganjoor/android/data/Assistant.kt new file mode 100644 index 0000000..08f301a --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/data/Assistant.kt @@ -0,0 +1,207 @@ +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.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 { error("No AssistantSettings") } + +@Serializable +private data class ChatRequest( + val model: String, + val messages: List, + 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 = emptyList()) + +@Serializable +private data class Choice(val message: ChatMessage? = null) + +/** + * 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() + } + + suspend fun ask( + settings: AssistantSettings, + system: String, + user: String, + ): Result = withContext(Dispatchers.IO) { + if (!settings.serverReady) { + return@withContext Result.failure(IllegalStateException("not configured")) + } + val url = settings.baseUrl.trimEnd('/') + "/chat/completions" + val body = json.encodeToString( + ChatRequest( + model = settings.model, + messages = listOf( + ChatMessage("system", system), + ChatMessage("user", user), + ), + ) + ).toRequestBody(media) + + val request = Request.Builder() + .url(url) + .post(body) + .apply { if (settings.apiKey.isNotBlank()) 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)}") + json.decodeFromString(text) + .choices.firstOrNull()?.message?.content?.trim() + .orEmpty() + .ifEmpty { throw IOException("empty reply") } + } + } + } +} + +/** 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" +} diff --git a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt new file mode 100644 index 0000000..c967190 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt @@ -0,0 +1,108 @@ +package com.ganjoor.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +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.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 + +/** + * Asks the reader's own assistant and shows 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) + } +} + +/** The same answer as a plain screen, for the selection-menu activity. */ +@Composable +fun AssistantAnswer(text: String, prompt: String = "translate") { + val settings = LocalAssistant.current + val context = LocalContext.current + val language = settings.language + + val answer by produceState?>(null, prompt, text, language) { + value = 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 + }, + ) + } + + 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), + ) + 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) }) { + Text(stringResource(R.string.share)) + } + } + + else -> Text( + text = result.exceptionOrNull()?.message + ?: stringResource(R.string.assistant_not_set_up), + color = MaterialTheme.colorScheme.error, + ) + } + } +} diff --git a/app/src/main/java/com/ganjoor/android/ui/AssistantScreen.kt b/app/src/main/java/com/ganjoor/android/ui/AssistantScreen.kt new file mode 100644 index 0000000..dd10a1c --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/AssistantScreen.kt @@ -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(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), + ) +} diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 66415dc..8ce7131 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -15,6 +15,7 @@ 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.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 +40,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 @@ -51,6 +53,7 @@ import com.ganjoor.android.data.breadcrumbs import com.ganjoor.android.data.wordAt import com.ganjoor.android.data.Ganjoor 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 @@ -95,6 +98,7 @@ fun PoemScreen( }, actions = { HomeAction(onHome) + ShareAction(poem = poem, couplets = couplets) BookmarkAction( url = fullUrl, title = poem.title, @@ -109,10 +113,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(), @@ -211,6 +215,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>) { + 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 @@ -335,6 +360,7 @@ internal fun wordTappedAt( private 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 +378,15 @@ private fun PassageActions(passage: Bookmark) { }) { Text(stringResource(R.string.copy)) } + TextButton(onClick = { + context.shareText( + text = passage.excerpt.orEmpty(), + url = passage.url, + subject = passage.title, + ) + }) { + Text(stringResource(R.string.share)) + } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/Share.kt b/app/src/main/java/com/ganjoor/android/ui/Share.kt new file mode 100644 index 0000000..62f7449 --- /dev/null +++ b/app/src/main/java/com/ganjoor/android/ui/Share.kt @@ -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('/') diff --git a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt index cdc3678..4a5919f 100644 --- a/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/WordSheet.kt @@ -43,7 +43,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 +62,20 @@ 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun WordSheet(word: String, onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { WordLookup(word) } +} + +/** + * 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) { 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 +89,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()) @@ -183,5 +192,4 @@ fun WordSheet(word: String, onDismiss: () -> Unit) { } } } - } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 877c051..88e5f92 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -72,4 +72,35 @@ روی پوسته‌های تاریک اعمال می‌شود و در نمایشگر OLED باتری کمتری می‌برد پخش خوانش توقف + دستیار هوش مصنوعی + اختیاری است. همهٔ بخش‌های برنامه بدون آن کار می‌کنند. تا خودتان تنظیم نکنید چیزی به جایی فرستاده نمی‌شود و هیچ حساب یا کلیدی همراه برنامه نیست. + شیوهٔ دسترسی به مدل + خاموش + بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند. + سرور خودتان + با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI یا سرویسی پولی اگر ترجیح می‌دهید. + ارسال به یک برنامه + متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود. + پاسخ به زبان + آماده‌ها + نشانی پایه + برای نمونه http://127.0.0.1:11434/v1 برای Ollama روی همین دستگاه + مدل + کلید API (اختیاری) + سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود. + آزمایش اتصال + در حال آزمایش… + وقتی ترجمه یا خلاصه می‌خواهید، تنها همان بیت یا شعری که انتخاب کرده‌اید به نشانی‌ای که تنظیم کرده‌اید فرستاده می‌شود. + ترجمه + خلاصه + شرح این بیت + ترجمهٔ خلاصهٔ گنجور + برای استفاده، دستیار را در تنظیمات راه‌اندازی کنید. + در حال پرسش… + جست‌وجو در لغت‌نامه + هم‌رسانی + هم‌رسانی این شعر + چه کاری انجام شود؟ + پرسش از دستیار + این شعر فارسی را ترجمه کن و معنایش را توضیح بده: diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml index b0717b3..a6c7a23 100644 --- a/app/src/main/res/values-night-v31/themes.xml +++ b/app/src/main/res/values-night-v31/themes.xml @@ -10,4 +10,5 @@ @color/window_background @color/window_background + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 31f7796..eb53b2e 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -4,4 +4,5 @@ + diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index a748790..3fe35ad 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -72,4 +72,35 @@ تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے قرات سنیں وقفہ + اے آئی معاون + اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔ + ماڈل تک رسائی کا طریقہ + بند + کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔ + آپ کا اپنا سرور + ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI، یا کوئی ادائیگی والی سروس اگر آپ چاہیں۔ + کسی ایپ کو بھیجیں + متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔ + جواب کی زبان + تیار ترتیبات + بنیادی یو آر ایل + مثلاً اسی آلے پر Ollama کے لیے http://127.0.0.1:11434/v1 + ماڈل + API کلید (اختیاری) + آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔ + کنکشن جانچیں + جانچ جاری… + جب آپ ترجمہ یا خلاصہ مانگتے ہیں تو صرف وہی شعر یا کلام بھیجا جاتا ہے جو آپ نے منتخب کیا۔ + ترجمہ + خلاصہ + اس شعر کی تشریح + گنجور کے خلاصے کا ترجمہ + استعمال کے لیے ترتیبات میں معاون قائم کریں۔ + پوچھا جا رہا ہے… + لغت میں تلاش + شیئر + یہ کلام شیئر کریں + آپ کیا کرنا چاہیں گے؟ + اسسٹنٹ سے پوچھیں + اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں: diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index 129f192..6e96b44 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -10,4 +10,5 @@ @color/window_background @color/window_background + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afc5b66..e433525 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,4 +77,35 @@ Applies to the dark themes; saves power on OLED screens Play recitation Pause + AI assistant + 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. + How to reach a model + Off + No assistant. Ganjoor’s own Persian summaries are still shown if you enable them in reading settings. + Your own server + Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI, or a paid service if you prefer one. + Send to an app + Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one. + Answer in + Presets + Base URL + For example http://127.0.0.1:11434/v1 for Ollama on this device + Model + API key (optional) + A server on your own machine usually needs none. Stored unencrypted on this device. + Test connection + Testing… + When you ask for a translation or a summary, only the verse or poem you chose is sent to the endpoint you configured. + Translate + Summarise + Explain this couplet + Translate Ganjoor’s summary + Set up an assistant in settings to use this. + Asking… + Look up + Share + Share this poem + What would you like to do? + Ask an assistant + Translate this Persian poetry and explain what it means: diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f93eb02..2531832 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,4 +9,5 @@ + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..e42b1d9 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + From 576a07ae3174f5bec41bbe954cde9a226fe77f4e Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 18:49:37 +0200 Subject: [PATCH 2/6] Wire up the optional AI assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../java/com/ganjoor/android/MainActivity.kt | 4 +++ .../android/ui/AssistantResultSheet.kt | 22 ++++++++++++++++ .../java/com/ganjoor/android/ui/GanjoorApp.kt | 10 ++++++++ .../java/com/ganjoor/android/ui/PoemScreen.kt | 12 +++++++++ .../com/ganjoor/android/ui/ReadingSettings.kt | 11 +++++--- app/src/main/res/values-fa/strings.xml | 2 ++ app/src/main/res/values-ur/strings.xml | 4 ++- app/src/main/res/values/strings.xml | 2 ++ .../java/com/ganjoor/android/ShareTest.kt | 25 +++++++++++++++++++ 9 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/ganjoor/android/ShareTest.kt diff --git a/app/src/main/java/com/ganjoor/android/MainActivity.kt b/app/src/main/java/com/ganjoor/android/MainActivity.kt index ea205dc..45add11 100644 --- a/app/src/main/java/com/ganjoor/android/MainActivity.kt +++ b/app/src/main/java/com/ganjoor/android/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt index c967190..d912b29 100644 --- a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt @@ -16,7 +16,9 @@ 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.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -46,6 +48,26 @@ fun AssistantResultSheet(prompt: String, text: String, onDismiss: () -> Unit) { } } +/** + * 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. + */ +@Composable +fun AssistantAction(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) } + + TextButton(onClick = { + if (assistant.serverReady) open = true else context.shareText("$ask\n\n$text") + }) { + Text(stringResource(label)) + } + if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false }) +} + /** The same answer as a plain screen, for the selection-menu activity. */ @Composable fun AssistantAnswer(text: String, prompt: String = "translate") { diff --git a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt index 6adc2c2..b5a5ebf 100644 --- a/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt +++ b/app/src/main/java/com/ganjoor/android/ui/GanjoorApp.kt @@ -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 { DownloadsScreen(onUp = { nav.goHome() }) } + composable { + AssistantScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() }) + } composable { AboutScreen(onUp = { nav.navigateUp() }, onHome = { nav.goHome() }) } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 8ce7131..187e0d0 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -387,6 +387,12 @@ 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, + ) } } @@ -405,6 +411,12 @@ private fun PoemSummary(summary: String) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + AssistantAction( + prompt = "summary", + text = summary, + label = R.string.assistant_translate, + instruction = R.string.assistant_translate_prompt, + ) } } diff --git a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt index c5c99a4..396d614 100644 --- a/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt +++ b/app/src/main/java/com/ganjoor/android/ui/ReadingSettings.kt @@ -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)) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 88e5f92..e8b7de5 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -103,4 +103,6 @@ چه کاری انجام شود؟ پرسش از دستیار این شعر فارسی را ترجمه کن و معنایش را توضیح بده: + هوش مصنوعی + این متن را ترجمه کن: diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 3fe35ad..fa8700a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -72,7 +72,7 @@ تاریک تھیمز پر لاگو ہوتا ہے؛ OLED اسکرین پر بیٹری بچاتا ہے قرات سنیں وقفہ - اے آئی معاون + مصنوعی ذہانت کا معاون اختیاری ہے۔ ایپ کا ہر حصہ اس کے بغیر کام کرتا ہے۔ جب تک آپ خود ترتیب نہ دیں کچھ کہیں نہیں بھیجا جاتا، اور ایپ کے ساتھ کوئی اکاؤنٹ یا کلید نہیں آتی۔ ماڈل تک رسائی کا طریقہ بند @@ -103,4 +103,6 @@ آپ کیا کرنا چاہیں گے؟ اسسٹنٹ سے پوچھیں اس فارسی کلام کا ترجمہ کریں اور اس کا مطلب بیان کریں: + مصنوعی ذہانت + اس کا ترجمہ کریں: diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e433525..501e3c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,4 +108,6 @@ What would you like to do? Ask an assistant Translate this Persian poetry and explain what it means: + AI assistant + Translate this: diff --git a/app/src/test/java/com/ganjoor/android/ShareTest.kt b/app/src/test/java/com/ganjoor/android/ShareTest.kt new file mode 100644 index 0000000..008ae11 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/ShareTest.kt @@ -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")) + } +} From 65bc9ed0fc29edefedbfad78a4c6c75cd8f65661 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 18:54:19 +0200 Subject: [PATCH 3/6] Talk to Claude as well as the OpenAI-shaped services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/ganjoor/android/data/Assistant.kt | 81 +++++++++++++++---- app/src/main/res/values-fa/strings.xml | 4 +- app/src/main/res/values-ur/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../java/com/ganjoor/android/AssistantTest.kt | 38 +++++++++ 5 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/com/ganjoor/android/AssistantTest.kt diff --git a/app/src/main/java/com/ganjoor/android/data/Assistant.kt b/app/src/main/java/com/ganjoor/android/data/Assistant.kt index 08f301a..a69c3a5 100644 --- a/app/src/main/java/com/ganjoor/android/data/Assistant.kt +++ b/app/src/main/java/com/ganjoor/android/data/Assistant.kt @@ -8,6 +8,7 @@ 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 @@ -129,6 +130,25 @@ private data class ChatResponse(val choices: List = 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, + @SerialName("max_tokens") val maxTokens: Int = 2048, +) + +@Serializable +private data class ClaudeResponse(val content: List = 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 @@ -146,6 +166,21 @@ object Assistant { .build() } + /** 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(body) + .content.firstOrNull { it.type == "text" }?.text + } else { + json.decodeFromString(body) + .choices.firstOrNull()?.message?.content + } + return text?.trim().orEmpty().ifEmpty { throw IOException("empty reply") } + } + suspend fun ask( settings: AssistantSettings, system: String, @@ -154,31 +189,47 @@ object Assistant { if (!settings.serverReady) { return@withContext Result.failure(IllegalStateException("not configured")) } - val url = settings.baseUrl.trimEnd('/') + "/chat/completions" - val body = json.encodeToString( - ChatRequest( - model = settings.model, - messages = listOf( - ChatMessage("system", system), - ChatMessage("user", user), - ), + 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)), + ) ) - ).toRequestBody(media) + } else { + json.encodeToString( + ChatRequest( + model = settings.model, + messages = listOf( + ChatMessage("system", system), + ChatMessage("user", user), + ), + ) + ) + }.toRequestBody(media) val request = Request.Builder() - .url(url) + .url(if (claude) "$base/messages" else "$base/chat/completions") .post(body) - .apply { if (settings.apiKey.isNotBlank()) header("Authorization", "Bearer ${settings.apiKey}") } + .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)}") - json.decodeFromString(text) - .choices.firstOrNull()?.message?.content?.trim() - .orEmpty() - .ifEmpty { throw IOException("empty reply") } + reply(text, claude) } } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index e8b7de5..78de97b 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -78,13 +78,13 @@ خاموش بدون دستیار. خلاصه‌های فارسی خود گنجور، اگر در تنظیمات خواندن روشن باشند، نمایش داده می‌شوند. سرور خودتان - با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI یا سرویسی پولی اگر ترجیح می‌دهید. + با هر چیزی که API گفت‌وگوی OpenAI را بفهمد کار می‌کند — Ollama، LM Studio، llama.cpp، LocalAI — و با کلاد که API خودش را دارد. ارسال به یک برنامه متن را به هر دستیاری که روی گوشی دارید می‌سپارد. تا خودتان یکی را انتخاب نکنید چیزی از این برنامه بیرون نمی‌رود. پاسخ به زبان آماده‌ها نشانی پایه - برای نمونه http://127.0.0.1:11434/v1 برای Ollama روی همین دستگاه + 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 مدل کلید API (اختیاری) سرور روی دستگاه خودتان معمولاً کلید نمی‌خواهد. بدون رمزگذاری روی همین دستگاه ذخیره می‌شود. diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index fa8700a..eefe67c 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -78,13 +78,13 @@ بند کوئی معاون نہیں۔ گنجور کے اپنے فارسی خلاصے، اگر مطالعے کی ترتیبات میں آن ہوں، پھر بھی دکھائے جائیں گے۔ آپ کا اپنا سرور - ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI، یا کوئی ادائیگی والی سروس اگر آپ چاہیں۔ + ہر اس چیز سے بات کرتا ہے جو OpenAI چیٹ API سمجھتی ہو — Ollama، LM Studio، llama.cpp، LocalAI — اور Claude سے بھی، جس کا اپنا API ہے۔ کسی ایپ کو بھیجیں متن آپ کے فون پر موجود کسی بھی معاون کو دے دیتا ہے۔ جب تک آپ خود انتخاب نہ کریں کچھ باہر نہیں جاتا۔ جواب کی زبان تیار ترتیبات بنیادی یو آر ایل - مثلاً اسی آلے پر Ollama کے لیے http://127.0.0.1:11434/v1 + 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 ماڈل API کلید (اختیاری) آپ کی اپنی مشین کے سرور کو عموماً کلید کی ضرورت نہیں۔ اسی آلے پر بغیر خفیہ کاری محفوظ ہوتی ہے۔ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 501e3c3..b6287d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,13 +83,13 @@ Off No assistant. Ganjoor’s own Persian summaries are still shown if you enable them in reading settings. Your own server - Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI, or a paid service if you prefer one. + Talks to anything speaking the OpenAI chat API — Ollama, LM Studio, llama.cpp, LocalAI — and to Claude, which uses its own. Send to an app Hands the text to any assistant installed on your phone. Nothing leaves this app until you pick one. Answer in Presets Base URL - For example http://127.0.0.1:11434/v1 for Ollama on this device + 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 Model API key (optional) A server on your own machine usually needs none. Stored unencrypted on this device. diff --git a/app/src/test/java/com/ganjoor/android/AssistantTest.kt b/app/src/test/java/com/ganjoor/android/AssistantTest.kt new file mode 100644 index 0000000..8212628 --- /dev/null +++ b/app/src/test/java/com/ganjoor/android/AssistantTest.kt @@ -0,0 +1,38 @@ +package com.ganjoor.android + +import com.ganjoor.android.data.Assistant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +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 `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) } + } +} From 2f16b52c2cbe92473e4549199faad2cc7bb3a61d Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 19:00:09 +0200 Subject: [PATCH 4/6] 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) --- app/src/main/AndroidManifest.xml | 3 +++ .../java/com/ganjoor/android/ProcessTextActivity.kt | 4 ++-- .../com/ganjoor/android/ui/AssistantResultSheet.kt | 9 +++++++++ .../main/java/com/ganjoor/android/ui/PoemScreen.kt | 8 ++++++++ app/src/main/res/drawable/ic_ask.xml | 12 ++++++++++++ app/src/main/res/drawable/ic_lookup.xml | 11 +++++++++++ app/src/main/res/drawable/ic_share.xml | 11 +++++++++++ 7 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/ic_ask.xml create mode 100644 app/src/main/res/drawable/ic_lookup.xml create mode 100644 app/src/main/res/drawable/ic_share.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91e2eee..007c21c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ @@ -56,6 +57,7 @@ @@ -68,6 +70,7 @@ diff --git a/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt index 4b678ee..a06a54e 100644 --- a/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt +++ b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt @@ -12,7 +12,7 @@ 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.Search +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -165,7 +165,7 @@ private fun SelectionScreen(selected: String, action: SelectionAction, onClose: actions = { // The dictionary knows single words; a whole line is better asked about. IconButton(onClick = { context.shareText("$prompt\n\n$selected") }) { - Icon(Icons.Default.Search, stringResource(R.string.assistant_ask)) + Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.assistant_ask)) } IconButton(onClick = { context.shareText(selected) }) { Icon(Icons.Default.Share, stringResource(R.string.share)) diff --git a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt index d912b29..c1479f5 100644 --- a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt @@ -6,7 +6,11 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding 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.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -116,6 +120,11 @@ fun AssistantAnswer(text: String, prompt: String = "translate") { 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)) } } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 187e0d0..b6fd91f 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -11,6 +11,7 @@ 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 @@ -385,6 +386,13 @@ private fun PassageActions(passage: Bookmark) { 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)) } AssistantAction( diff --git a/app/src/main/res/drawable/ic_ask.xml b/app/src/main/res/drawable/ic_ask.xml new file mode 100644 index 0000000..fb7c1c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_ask.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_lookup.xml b/app/src/main/res/drawable/ic_lookup.xml new file mode 100644 index 0000000..5997537 --- /dev/null +++ b/app/src/main/res/drawable/ic_lookup.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..0069e35 --- /dev/null +++ b/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,11 @@ + + + + From 90b527522bfc918635880ee330ebb261a16411fe Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 19:02:27 +0200 Subject: [PATCH 5/6] Use the AI sparkle for the assistant action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../main/java/com/ganjoor/android/ProcessTextActivity.kt | 4 ++-- .../java/com/ganjoor/android/ui/AssistantResultSheet.kt | 6 ++++++ app/src/main/res/drawable/ic_ask.xml | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt index a06a54e..83fd400 100644 --- a/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt +++ b/app/src/main/java/com/ganjoor/android/ProcessTextActivity.kt @@ -12,7 +12,6 @@ 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.automirrored.filled.Send import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -30,6 +29,7 @@ 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 @@ -165,7 +165,7 @@ private fun SelectionScreen(selected: String, action: SelectionAction, onClose: actions = { // The dictionary knows single words; a whole line is better asked about. IconButton(onClick = { context.shareText("$prompt\n\n$selected") }) { - Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.assistant_ask)) + Icon(painterResource(R.drawable.ic_ask), stringResource(R.string.assistant_ask)) } IconButton(onClick = { context.shareText(selected) }) { Icon(Icons.Default.Share, stringResource(R.string.share)) diff --git a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt index c1479f5..27a8448 100644 --- a/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt +++ b/app/src/main/java/com/ganjoor/android/ui/AssistantResultSheet.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.remember 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 @@ -67,6 +68,11 @@ fun AssistantAction(prompt: String, text: String, label: Int, instruction: Int) 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)) } if (open) AssistantResultSheet(prompt = prompt, text = text, onDismiss = { open = false }) diff --git a/app/src/main/res/drawable/ic_ask.xml b/app/src/main/res/drawable/ic_ask.xml index fb7c1c9..8dbcf35 100644 --- a/app/src/main/res/drawable/ic_ask.xml +++ b/app/src/main/res/drawable/ic_ask.xml @@ -1,12 +1,15 @@ - + + 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" /> From d320137063b0bbc2c0300c3b58ea312312a57217 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Sun, 4 Oct 2026 19:14:22 +0200 Subject: [PATCH 6/6] 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) --- app/src/main/AndroidManifest.xml | 19 +---- .../ganjoor/android/ProcessTextActivity.kt | 71 +++++++++---------- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 007c21c..571a4f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,9 +33,9 @@ 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. - "Ask an assistant" cannot be an entry per assistant — only Claude or Gemini can put their - own name in this menu. It opens the share chooser instead, which lists whichever of them - is installed, with the question already written. + 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. --> - - - - - - - - SelectionAction.Ask - "ShareText" -> SelectionAction.Share - else -> SelectionAction.LookUp - } + val sharing = intent.component?.className?.endsWith("ShareText") == true val settings = Settings(applicationContext) val systemInDark = resources.configuration.uiMode and @@ -110,48 +106,34 @@ class ProcessTextActivity : ComponentActivity() { LocalLayoutDirection provides LayoutDirection.Rtl, ) { GanjoorTheme(settings.value.theme, settings.value.language, settings.value.oled) { - SelectionScreen(selected, action, onClose = ::finish) + SelectionScreen(selected, sharing, onClose = ::finish) } } } } } -/** Which verb the reader picked in the selection menu. */ -enum class SelectionAction { LookUp, Ask, Share } - /** - * Each alias goes straight to its action. Share finishes immediately — the system chooser is the - * whole interface — so this screen is only ever seen for a lookup or an answer. + * 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, action: SelectionAction, onClose: () -> Unit) { +private fun SelectionScreen(selected: String, sharing: Boolean, onClose: () -> Unit) { val context = LocalContext.current val assistant = LocalAssistant.current - val prefs = LocalSettings.current.value - - // Without a server configured there is nothing to ask, so the question goes to whichever - // assistant the reader has installed. That hand-off is also what keeps this F-Droid-clean: - // no vendor SDK, no API key, nothing but an intent the reader confirms. - val askLocally = action == SelectionAction.Ask && assistant.serverReady val prompt = stringResource(R.string.assistant_ask_prompt) + var asking by remember { mutableStateOf(false) } - LaunchedEffect(action) { - when { - action == SelectionAction.Share -> { - context.shareText(selected) - onClose() - } - - action == SelectionAction.Ask && !askLocally -> { - context.shareText("$prompt\n\n$selected") - onClose() - } + LaunchedEffect(sharing) { + if (sharing) { + context.shareText(selected) + onClose() } } - - if (action == SelectionAction.Share || (action == SelectionAction.Ask && !askLocally)) return + if (sharing) return Scaffold( topBar = { @@ -163,9 +145,18 @@ private fun SelectionScreen(selected: String, action: SelectionAction, onClose: } }, actions = { - // The dictionary knows single words; a whole line is better asked about. - IconButton(onClick = { context.shareText("$prompt\n\n$selected") }) { - Icon(painterResource(R.drawable.ic_ask), stringResource(R.string.assistant_ask)) + // 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)) @@ -175,7 +166,15 @@ private fun SelectionScreen(selected: String, action: SelectionAction, onClose: }, ) { insets -> Column(modifier = Modifier.fillMaxSize().padding(insets)) { - if (askLocally) AssistantAnswer(selected) else WordLookup(selected) + WordLookup(selected) } } + + if (asking) { + AssistantResultSheet( + prompt = "explain", + text = selected, + onDismiss = { asking = false }, + ) + } }