Offline dictionary (#45)

* Added support for external dictionary apps and a dictionary settings sheet.

* Refactored external dictionary integration and improved package visibility

 - Updated `AndroidManifest.xml` with `<queries>` for common dictionary apps and intent actions (`PROCESS_TEXT`, `SEARCH`, `SEND`) to support Android 11+ package visibility.
 - Refactored `ExternalDictionaryHelper` to streamline dictionary discovery using `Intent.ACTION_PROCESS_TEXT` and `colordict.intent.action.SEARCH`.
 - Added a package blocklist in `ExternalDictionaryHelper` to filter out system services (e.g., Samsung Pass, GMS) from dictionary lists.
  - Simplified `launchDictionary` logic to prioritize `PROCESS_TEXT` intents.
 - Fixed a bug in `PdfViewerScreen` to only show the dictionary upsell dialog when `useOnlineDictionary` is enabled.

* Refactored dictionary settings into a dialog and updated dictionary selection logic.

- Renamed `DictionarySettingsSheet` to `DictionarySettingsDialog` and converted it from a bottom sheet to a formal dialog.
- Redesigned the dictionary settings UI with a clearer distinction between AI and external app modes.
- Added a "Search" option to the external dictionary list that triggers a web search.
- Updated `ExternalDictionaryHelper` to include a package blocklist and support for generic web search intents.
- Simplified `PdfHelper` by removing Pro user checks for the "Dictionary" action.
- Updated icons for dictionary settings across the EPUB and PDF readers.
This commit is contained in:
Aryan 2026-03-08 15:19:52 +05:30 committed by GitHub
parent dff236fa7f
commit 76780bff31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 759 additions and 171 deletions

View file

@ -11,6 +11,26 @@
</intent>
</queries>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="colordict.intent.action.SEARCH" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="text/plain" />
</intent>
<package android:name="it.t_arn.aard2" />
<package android:name="io.github.mvasilev.dictionary" />
<package android:name="com.github.tngande.ossdict" />
<package android:name="com.gaurav.lookup" />
<package android:name="com.randallbaltazar.lookup" />
</queries>
<application
android:name=".MyApplication"
android:allowBackup="true"

View file

@ -104,6 +104,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
@ -531,7 +532,8 @@ fun AiDefinitionPopup(
result: AiDefinitionResult?,
isLoading: Boolean,
onDismiss: () -> Unit,
isMainTtsActive: Boolean = false
isMainTtsActive: Boolean = false,
onOpenExternalDictionary: () -> Unit
) {
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
@ -642,6 +644,13 @@ fun AiDefinitionPopup(
contentDescription = "Copy"
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onOpenExternalDictionary) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Open in Dictionary App"
)
}
}
}

View file

@ -975,7 +975,7 @@ fun ChapterWebView(
}
// 3. Dictionary Option (Preserving Logic)
if (!isOss && state.selectedText.length <= 2000) {
if (state.selectedText.length <= 2000) {
HorizontalDivider()
Row(
modifier = Modifier
@ -983,12 +983,7 @@ fun ChapterWebView(
.clickable {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
val wordCount = countWords(textToDefine)
if (isProUser || wordCount <= 1) {
onWordSelectedForAiDefinition(textToDefine)
} else {
onShowDictionaryUpsellDialog()
}
}
customMenuState = null
}

View file

@ -0,0 +1,299 @@
// DictionarySettingsDialog.kt
package com.aryan.reader.epubreader
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.graphics.drawable.toBitmap
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
@Suppress("KotlinConstantConditions")
@Composable
fun DictionarySettingsDialog(
isVisible: Boolean,
onDismiss: () -> Unit,
isProUser: Boolean,
useOnlineDictionary: Boolean,
onToggleOnlineDictionary: (Boolean) -> Unit,
selectedPackageName: String?,
onSelectPackage: (String) -> Unit
) {
if (!isVisible) return
val context = LocalContext.current
var availableApps by remember { mutableStateOf<List<ExternalDictionaryApp>>(emptyList()) }
LaunchedEffect(Unit) {
availableApps = ExternalDictionaryHelper.getAvailableDictionaries(context)
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp)
) {
// Header
Text(
text = "Dictionary Settings",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
if (BuildConfig.FLAVOR != "oss") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(12.dp),
color = if (useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
border = BorderStroke(
1.dp,
if (useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleOnlineDictionary(true) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ai),
contentDescription = null,
tint = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "AI Smart Dictionary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
Text(
text = "Contextual definitions powered by AI.",
style = MaterialTheme.typography.bodySmall,
color = if (useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (useOnlineDictionary) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
// External App Option
Surface(
shape = RoundedCornerShape(12.dp),
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
border = BorderStroke(
1.dp,
if (!useOnlineDictionary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleOnlineDictionary(false) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "External App",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
Text(
text = "Launch an offline dictionary or search app.",
style = MaterialTheme.typography.bodySmall,
color = if (!useOnlineDictionary) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!useOnlineDictionary) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = if (useOnlineDictionary) "Fallback External App (Used when offline)" else "Select External App",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
} else {
// OSS FLAVOR UI (Dedicated to external apps)
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.padding(bottom = 16.dp)
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(12.dp))
Text(
text = "Choose an external app to define selected words.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
// App List
if (availableApps.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
"No supported dictionary apps found.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp) // Bound height so it doesn't take over screen
.background(MaterialTheme.colorScheme.surfaceContainerLowest, RoundedCornerShape(12.dp))
) {
items(availableApps) { app ->
val isSelected = app.packageName == selectedPackageName
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectPackage(app.packageName) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Dynamic Icon handling
if (app.packageName == ExternalDictionaryHelper.GOOGLE_SEARCH_PKG) {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(24.dp)
)
}
} else if (app.icon != null) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
} else {
Box(
modifier = Modifier
.size(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp))
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = app.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
}
}
}
}
}
}
}

View file

@ -227,21 +227,16 @@ suspend fun executeRecapLogic(
*/
@Composable
fun EpubReaderAiOverlays(
// Summarization State
showSummarizationPopup: Boolean,
summarizationResult: SummarizationResult?,
isSummarizationLoading: Boolean,
onDismissSummarization: () -> Unit,
showSummarizationUpsellDialog: Boolean,
onDismissSummarizationUpsell: () -> Unit,
// Recap State
showRecapPopup: Boolean,
recapResult: SummarizationResult?,
isRecapLoading: Boolean,
onDismissRecap: () -> Unit,
// Dictionary State
showAiDefinitionPopup: Boolean,
selectedTextForAi: String?,
aiDefinitionResult: AiDefinitionResult?,
@ -249,10 +244,9 @@ fun EpubReaderAiOverlays(
onDismissAiDefinition: () -> Unit,
showDictionaryUpsellDialog: Boolean,
onDismissDictionaryUpsell: () -> Unit,
// Navigation
onNavigateToPro: () -> Unit,
isTtsSessionActive: Boolean
isTtsSessionActive: Boolean,
onOpenExternalDictionary: (String) -> Unit
) {
if (showSummarizationPopup) {
SummarizationPopup(
@ -298,7 +292,11 @@ fun EpubReaderAiOverlays(
result = aiDefinitionResult,
isLoading = isAiDefinitionLoading,
onDismiss = onDismissAiDefinition,
isMainTtsActive = isTtsSessionActive
isMainTtsActive = isTtsSessionActive,
// Pass it down
onOpenExternalDictionary = {
selectedTextForAi?.let { text -> onOpenExternalDictionary(text) }
}
)
}

View file

@ -67,6 +67,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
@ -148,6 +149,7 @@ fun EpubReaderTopBar(
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenDeviceVoiceSettings: () -> Unit,
onOpenDictionarySettings: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier,
onToggleReflow: (() -> Unit)? = null,
@ -190,6 +192,12 @@ fun EpubReaderTopBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onOpenDictionarySettings) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings"
)
}
Box {
var showMoreMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMoreMenu = true }) {

View file

@ -32,6 +32,7 @@ import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -280,6 +281,30 @@ private fun saveTtsMode(context: Context, modeName: String) {
prefs.edit { putString(TTS_MODE_KEY, modeName) }
}
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private fun loadUseOnlineDict(context: Context): Boolean {
if (BuildConfig.FLAVOR == "oss") return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
private fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
}
private fun loadExternalDictPackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
}
private fun saveExternalDictPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
@ -334,6 +359,7 @@ fun EpubReaderScreen(
)
}
@Suppress("ControlFlowWithEmptyBody")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt")
@androidx.annotation.OptIn(UnstableApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@ -516,6 +542,61 @@ fun EpubReaderHost(
saveHighlightsToPrefs(context, epubBook.title, userHighlights)
}
// Dictionary
var showAiDefinitionPopup by remember { mutableStateOf(false) }
var selectedTextForAi by remember { mutableStateOf<String?>(null) }
var aiDefinitionResult by remember { mutableStateOf<AiDefinitionResult?>(null) }
var isAiDefinitionLoading by remember { mutableStateOf(false) }
var showDictionarySettingsSheet by remember { mutableStateOf(false) }
var useOnlineDictionary by remember {
mutableStateOf(loadUseOnlineDict(context))
}
var selectedDictPackage by remember {
mutableStateOf(loadExternalDictPackage(context))
}
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
val onDictionaryLookup = { word: String ->
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(word)
if (isProUser || wordCount <= 1) {
selectedTextForAi = word
showAiDefinitionPopup = true
scope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = word,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = { isAiDefinitionLoading = false }
)
}
} else {
showDictionaryUpsellDialog = true
}
} else {
if (selectedDictPackage != null) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word)
} else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
}
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf<SummarizationResult?>(null) }
@ -585,17 +666,9 @@ fun EpubReaderHost(
var webViewRefForTts by remember { mutableStateOf<WebView?>(null) }
// Dictionary
var showAiDefinitionPopup by remember { mutableStateOf(false) }
var selectedTextForAi by remember { mutableStateOf<String?>(null) }
var aiDefinitionResult by remember { mutableStateOf<AiDefinitionResult?>(null) }
var isAiDefinitionLoading by remember { mutableStateOf(false) }
var showSummarizationPopup by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf<SummarizationResult?>(null) }
var isSummarizationLoading by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
val epubSearcher = remember(epubBook) { createEpubSearcher(epubBook) }
@ -2101,32 +2174,7 @@ fun EpubReaderHost(
}
},
onWordSelectedForAiDefinition = { text ->
val wordCount = countWords(text)
if (isProUser || wordCount <= 1) {
Timber.d("Text selected for AI definition: $text"
)
selectedTextForAi = text
showAiDefinitionPopup = true
scope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = text,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = {
isAiDefinitionLoading = false
}
)
}
} else {
showDictionaryUpsellDialog = true
}
onDictionaryLookup(text)
},
onContentReadyForSummarization = { content ->
Timber.d("Content received for summarization")
@ -2461,32 +2509,7 @@ fun EpubReaderHost(
showDictionaryUpsellDialog = true
},
onWordSelectedForAiDefinition = { text ->
val wordCount = countWords(text)
if (isProUser || wordCount <= 1) {
Timber.d("Text selected for AI definition: $text"
)
selectedTextForAi = text
showAiDefinitionPopup = true
scope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = text,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
},
onFinish = {
isAiDefinitionLoading = false
}
)
}
} else {
showDictionaryUpsellDialog = true
}
onDictionaryLookup(text)
},
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
onHighlightCreated = { cfi, text, colorId ->
@ -2992,6 +3015,7 @@ fun EpubReaderHost(
searchFocusRequester = searchFocusRequester,
modifier = Modifier.align(Alignment.TopCenter),
onOpenTtsSettings = { showTtsSettingsSheet = true },
onOpenDictionarySettings = { showDictionarySettingsSheet = true },
onOpenDeviceVoiceSettings = { showDeviceVoiceSettingsSheet = true },
onToggleReflow = onToggleReflow,
)
@ -3322,9 +3346,16 @@ fun EpubReaderHost(
},
showDictionaryUpsellDialog = showDictionaryUpsellDialog,
onDismissDictionaryUpsell = { showDictionaryUpsellDialog = false },
onNavigateToPro = onNavigateToPro,
isTtsSessionActive = isTtsSessionActive
isTtsSessionActive = isTtsSessionActive,
onOpenExternalDictionary = { text ->
if (selectedDictPackage != null) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
)
if (isNavigatingToBookmark) {
@ -3483,6 +3514,24 @@ fun EpubReaderHost(
)
}
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,
onDismiss = { showDictionarySettingsSheet = false },
isProUser = isProUser,
useOnlineDictionary = useOnlineDictionary,
onToggleOnlineDictionary = { newState ->
useOnlineDictionary = newState
saveUseOnlineDict(context, newState)
},
selectedPackageName = selectedDictPackage,
onSelectPackage = { pkg ->
selectedDictPackage = pkg
saveExternalDictPackage(context, pkg)
}
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,

View file

@ -0,0 +1,154 @@
// ExternalDictionaryHelper.kt
package com.aryan.reader.epubreader
import android.app.SearchManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.os.Build
import android.widget.Toast
import timber.log.Timber
data class ExternalDictionaryApp(
val label: String,
val packageName: String,
val icon: Drawable?
)
object ExternalDictionaryHelper {
const val GOOGLE_SEARCH_PKG = "app.internal.google_search"
private val PACKAGE_BLOCKLIST = setOf(
"com.samsung.android.samsungpassautofill",
"com.samsung.android.samsungpass",
"com.samsung.android.app.pass",
"com.google.android.gms",
"com.truecaller",
"com.adobe.reader",
"com.reddit.frontpage"
)
fun getAvailableDictionaries(context: Context): List<ExternalDictionaryApp> {
val pm = context.packageManager
val apps = mutableListOf<ExternalDictionaryApp>()
val addedPackages = mutableSetOf<String>()
val processTextIntent = Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain")
val textResolvers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.queryIntentActivities(processTextIntent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION") pm.queryIntentActivities(processTextIntent, 0)
}
textResolvers.forEach { ri ->
val pkg = ri.activityInfo.packageName
if (!PACKAGE_BLOCKLIST.contains(pkg) && addedPackages.add(pkg)) {
apps.add(ExternalDictionaryApp(
label = ri.loadLabel(pm).toString(),
packageName = pkg,
icon = ri.loadIcon(pm)
))
}
}
val colorDictIntent = Intent("colordict.intent.action.SEARCH")
val colorResolvers = pm.queryIntentActivities(colorDictIntent, 0)
colorResolvers.forEach { ri ->
val pkg = ri.activityInfo.packageName
if (!PACKAGE_BLOCKLIST.contains(pkg) && addedPackages.add(pkg)) {
apps.add(ExternalDictionaryApp(
label = ri.loadLabel(pm).toString(),
packageName = pkg,
icon = ri.loadIcon(pm)
))
}
}
val sortedApps = apps.sortedBy { it.label }.toMutableList()
// Inject Google Search at the top
sortedApps.add(
0,
ExternalDictionaryApp(
label = "Search",
packageName = GOOGLE_SEARCH_PKG,
icon = null
)
)
return sortedApps
}
fun launchDictionary(context: Context, packageName: String, query: String) {
val pm = context.packageManager
try {
if (packageName == GOOGLE_SEARCH_PKG) {
val searchIntent = Intent(Intent.ACTION_WEB_SEARCH).apply {
putExtra(SearchManager.QUERY, query)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(searchIntent)
return
}
val processTextIntent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (processTextIntent.resolveActivity(pm) != null) {
context.startActivity(processTextIntent)
return
}
val dictIntent = Intent("colordict.intent.action.SEARCH").apply {
putExtra("EXTRA_QUERY", query)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (dictIntent.resolveActivity(pm) != null) {
context.startActivity(dictIntent)
return
}
if (packageName == "it.t_arn.aard2") {
val aardIntent = Intent("aard2.lookup").apply {
putExtra("query", query)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(aardIntent)
return
}
launchGenericSend(context, packageName, query)
} catch (e: Exception) {
Timber.e(e, "Failed to launch dictionary app: $packageName")
Toast.makeText(context, "Error opening dictionary", Toast.LENGTH_SHORT).show()
}
}
private fun launchGenericSend(context: Context, packageName: String, query: String) {
val sendIntent = Intent(Intent.ACTION_SEND)
sendIntent.type = "text/plain"
sendIntent.putExtra(Intent.EXTRA_TEXT, query)
sendIntent.setPackage(packageName)
sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(sendIntent)
} catch (e: Exception) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
context.startActivity(launchIntent)
} else {
throw e
}
}
}
}

View file

@ -1467,14 +1467,8 @@ internal fun PaginatedReaderContent(
if (isForDictionary) {
if (!text.isNullOrBlank()) {
if (isProUser || countWords(text) <= 1) {
if (text.length <= 2000) {
onWordSelectedForAiDefinition(text)
}
} else {
onShowDictionaryUpsellDialog()
}
}
} else if (isForHighlight) {
// Do not copy to real clipboard
} else {
@ -3022,7 +3016,6 @@ private fun PaginatedTextSelectionMenu(
}
// 5. Dictionary Option
if (!isOss) {
HorizontalDivider()
Row(
modifier = Modifier
@ -3047,7 +3040,6 @@ private fun PaginatedTextSelectionMenu(
}
}
}
}
@Composable
private fun RenderFlexChildBlock(

View file

@ -129,9 +129,7 @@ internal fun PdfSelectionMenuPopup(
popupPositionProvider: PopupPositionProvider,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
onSelectAll: () -> Unit,
isProUser: Boolean,
onShowUpsellDialog: () -> Unit,
onSelectAll: () -> Unit
) {
Popup(
popupPositionProvider = popupPositionProvider,
@ -157,11 +155,7 @@ internal fun PdfSelectionMenuPopup(
}
if (menuState.selectedText.length <= 2000) {
TextButton(onClick = {
if (isProUser || countWords(menuState.selectedText) <= 1) {
onAiDefine(menuState.selectedText)
} else {
onShowUpsellDialog()
}
}) {
Text("Dictionary")
}

View file

@ -4557,9 +4557,7 @@ private fun PdfPageRenderer(
popupPositionProvider = popupPositionProvider,
onCopy = onCopy,
onAiDefine = onAiDefine,
onSelectAll = onSelectAll,
isProUser = isProUser,
onShowUpsellDialog = onShowUpsellDialog
onSelectAll = onSelectAll
)
}
}

View file

@ -29,14 +29,12 @@ import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarResult
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import androidx.compose.foundation.border
import android.util.Base64
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@ -55,6 +53,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
@ -90,6 +89,7 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Brush
@ -133,8 +133,10 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
@ -236,7 +238,10 @@ import com.aryan.reader.SearchTopBar
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.countWords
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
@ -304,6 +309,29 @@ private const val PDF_AUTO_SCROLL_LOCAL_MAX_PREFIX = "pdf_as_local_max_"
private const val PDF_SCROLL_LOCKED_PREFIX = "pdf_sl_local_"
private const val PDF_FULL_SCREEN_PREFIX = "pdf_fs_local_"
private const val PDF_MUSICIAN_MODE_KEY = "pdf_musician_mode_enabled"
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss") return false
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
private fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
}
private fun loadExternalDictPackage(context: Context): String? {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
}
private fun saveExternalDictPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
private fun savePdfMusicianMode(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -736,6 +764,10 @@ fun PdfViewerScreen(
var currentTtsMode by remember { mutableStateOf(loadTtsMode(context)) }
var showTtsSettingsSheet by remember { mutableStateOf(false) }
var showDictionarySettingsSheet by remember { mutableStateOf(false) }
var useOnlineDictionary by remember { mutableStateOf(loadUseOnlineDict(context)) }
var selectedDictPackage by remember { mutableStateOf(loadExternalDictPackage(context)) }
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
fun triggerAutoScrollTempPause(durationMs: Long) {
@ -1840,24 +1872,54 @@ fun PdfViewerScreen(
{ isLoading: Boolean -> isHighlightingLoading = isLoading }
}
val onShowDictionaryUpsellDialogStable = remember { { showDictionaryUpsellDialog = true } }
val onShowDictionaryUpsellDialogStable = remember(useOnlineDictionary) {
{
if (useOnlineDictionary) {
showDictionaryUpsellDialog = true
}
}
}
val onWordSelectedForAiDefinitionStable = remember(isProUser, executeWithOcrCheck) {
val onDictionaryLookupStable = remember(isProUser, executeWithOcrCheck, useOnlineDictionary, selectedDictPackage) {
{ text: String ->
executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(text)
if (isProUser || wordCount <= 1) {
selectedTextForAi = text
showAiDefinitionPopup = true
coroutineScope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(text = text, onUpdate = { chunk ->
fetchAiDefinition(
text = text,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(
definition = currentDefinition + chunk
)
}, onError = { error ->
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(error = error)
}, onFinish = { isAiDefinitionLoading = false })
},
onFinish = {
isAiDefinitionLoading = false
}
)
}
} else {
showDictionaryUpsellDialog = true
}
} else {
if (selectedDictPackage != null) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
Toast.makeText(context, "Please select a dictionary app first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
}
}
@ -3458,33 +3520,11 @@ fun PdfViewerScreen(
onSingleTap = onSingleTapStable,
isProUser = isProUser,
onShowDictionaryUpsellDialog = {
if (useOnlineDictionary) {
showDictionaryUpsellDialog = true
},
onWordSelectedForAiDefinition = { text ->
selectedTextForAi = text
showAiDefinitionPopup = true
coroutineScope.launch {
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = text,
onUpdate = { chunk ->
val currentDefinition =
aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(
definition = currentDefinition + chunk
)
},
onError = { error ->
aiDefinitionResult = AiDefinitionResult(
error = error
)
},
onFinish = {
isAiDefinitionLoading = false
})
}
},
onWordSelectedForAiDefinition = onDictionaryLookupStable,
onOcrStateChange = onOcrStateChange,
onLinkClicked = { url -> clickedLinkUrl = url },
onInternalLinkClicked = onInternalLinkNav,
@ -3846,7 +3886,7 @@ fun PdfViewerScreen(
searchResultToHighlight = searchHighlightTarget,
isProUser = isProUser,
onShowDictionaryUpsellDialog = onShowDictionaryUpsellDialogStable,
onWordSelectedForAiDefinition = onWordSelectedForAiDefinitionStable,
onWordSelectedForAiDefinition = onDictionaryLookupStable,
ttsHighlightData = ttsHighlightData,
ttsReadingPage = ttsPageData?.pageIndex,
onLinkClicked = onLinkClickedStable,
@ -3940,7 +3980,7 @@ fun PdfViewerScreen(
}
if (isMusicianMode && isAutoScrollModeActive) {
val density = LocalDensity.current
@Suppress("UnusedVariable", "Unused") val density = LocalDensity.current
var leftPulseTrigger by remember { mutableLongStateOf(0L) }
var rightPulseTrigger by remember { mutableLongStateOf(0L) }
@ -4345,6 +4385,14 @@ fun PdfViewerScreen(
)
}
IconButton(onClick = { showDictionarySettingsSheet = true }) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = "Dictionary Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (BuildConfig.DEBUG) {
IconButton(onClick = { showPenPlayground = true }) {
Icon(
@ -4453,11 +4501,7 @@ fun PdfViewerScreen(
showMoreMenu = false
isAutoScrollModeActive = true
isAutoScrollPlaying = true
showBars = if (isMusicianMode) {
false
} else {
true
}
showBars = !isMusicianMode
}
)
@ -5741,7 +5785,17 @@ fun PdfViewerScreen(
selectedTextForAi = null
aiDefinitionResult = null
},
isMainTtsActive = isTtsSessionActive
isMainTtsActive = isTtsSessionActive,
onOpenExternalDictionary = {
selectedTextForAi?.let { text ->
if (selectedDictPackage != null) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, text)
} else {
Toast.makeText(context, "Select an offline dictionary first.", Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
}
)
}
if (showDictionaryUpsellDialog) {
@ -5873,6 +5927,24 @@ fun PdfViewerScreen(
)
}
if (showDictionarySettingsSheet) {
DictionarySettingsDialog(
isVisible = true,
onDismiss = { showDictionarySettingsSheet = false },
isProUser = isProUser,
useOnlineDictionary = useOnlineDictionary,
onToggleOnlineDictionary = { newState ->
useOnlineDictionary = newState
saveUseOnlineDict(context, newState)
},
selectedPackageName = selectedDictPackage,
onSelectPackage = { pkg ->
selectedDictPackage = pkg
saveExternalDictPackage(context, pkg)
}
)
}
if (showDeviceVoiceSettingsSheet) {
DeviceVoiceSettingsSheet(
isVisible = true,