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

@ -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 {
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 })
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 ->
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!!, 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 = {
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
})
if (useOnlineDictionary) {
showDictionaryUpsellDialog = true
}
},
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,