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

@ -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,