Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d320137063 | ||
|
|
90b527522b | ||
|
|
2f16b52c2c | ||
|
|
65bc9ed0fc | ||
|
|
576a07ae31 | ||
|
|
3d4f7aae3a | ||
|
|
9445f44759 |
No files matched your search
@@ -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,258 @@
|
||||
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()
|
||||
}
|
||||
|
||||
/** 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"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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.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
|
||||
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.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
|
||||
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
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
}) {
|
||||
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 })
|
||||
}
|
||||
|
||||
/** 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<Result<String>?>(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) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).padding(end = 4.dp),
|
||||
)
|
||||
Text(stringResource(R.string.share))
|
||||
}
|
||||
}
|
||||
|
||||
else -> Text(
|
||||
text = result.exceptionOrNull()?.message
|
||||
?: stringResource(R.string.assistant_not_set_up),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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.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 +41,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 +54,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 +99,7 @@ fun PoemScreen(
|
||||
},
|
||||
actions = {
|
||||
HomeAction(onHome)
|
||||
ShareAction(poem = poem, couplets = couplets)
|
||||
BookmarkAction(
|
||||
url = fullUrl,
|
||||
title = poem.title,
|
||||
@@ -109,10 +114,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 +216,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
|
||||
@@ -335,6 +361,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 +379,28 @@ 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))
|
||||
}
|
||||
AssistantAction(
|
||||
prompt = "explain",
|
||||
text = passage.excerpt.orEmpty(),
|
||||
label = R.string.assistant_explain,
|
||||
instruction = R.string.assistant_ask_prompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +419,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('/')
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -72,4 +72,37 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -10,4 +10,5 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -4,4 +4,5 @@
|
||||
<style name="Theme.Ganjoorappandroid" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -72,4 +72,37 @@
|
||||
<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>
|
||||
</resources>
|
||||
@@ -10,4 +10,5 @@
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/window_background</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -77,4 +77,37 @@
|
||||
<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’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’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>
|
||||
</resources>
|
||||
@@ -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,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) }
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user