diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c921208..e675966 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,26 @@ + + + + + + + + + + + + + + + + + + + + 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" + ) + } } } diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index 5ecba14..92eb347 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -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() - } + onWordSelectedForAiDefinition(textToDefine) } customMenuState = null } diff --git a/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt new file mode 100644 index 0000000..9974729 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epubreader/DictionarySettingsDialog.kt @@ -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>(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)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt index c04f1b7..4ad992f 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt @@ -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) } + } ) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt index 36da00b..bff4365 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt @@ -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 }) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index fd8f0d0..5ffb17e 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -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(null) } + var aiDefinitionResult by remember { mutableStateOf(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(null) } @@ -585,17 +666,9 @@ fun EpubReaderHost( var webViewRefForTts by remember { mutableStateOf(null) } - // Dictionary - var showAiDefinitionPopup by remember { mutableStateOf(false) } - var selectedTextForAi by remember { mutableStateOf(null) } - var aiDefinitionResult by remember { mutableStateOf(null) } - var isAiDefinitionLoading by remember { mutableStateOf(false) } - var showSummarizationPopup by remember { mutableStateOf(false) } var summarizationResult by remember { mutableStateOf(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, diff --git a/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt new file mode 100644 index 0000000..cf08a92 --- /dev/null +++ b/app/src/main/java/com/aryan/reader/epubreader/ExternalDictionaryHelper.kt @@ -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 { + val pm = context.packageManager + val apps = mutableListOf() + val addedPackages = mutableSetOf() + + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt index 0b3a032..cee987d 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/PaginatedReader.kt @@ -1467,15 +1467,9 @@ internal fun PaginatedReaderContent( if (isForDictionary) { if (!text.isNullOrBlank()) { - if (isProUser || countWords(text) <= 1) { - if (text.length <= 2000) { - onWordSelectedForAiDefinition(text) - } - } else { - onShowDictionaryUpsellDialog() - } + onWordSelectedForAiDefinition(text) } - } else if (isForHighlight) { + } else if (isForHighlight) { // Do not copy to real clipboard } else { realClipboard.setClipEntry(clipEntry) @@ -3022,28 +3016,26 @@ private fun PaginatedTextSelectionMenu( } // 5. Dictionary Option - if (!isOss) { - HorizontalDivider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onDictionary) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.dictionary), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - Text( - "Dictionary", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDictionary) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.dictionary), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + Text( + "Dictionary", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) } } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt index 854f2f8..63039a4 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt @@ -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() - } + onAiDefine(menuState.selectedText) }) { Text("Dictionary") } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt index f3dc497..4f6c1ea 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt @@ -4557,9 +4557,7 @@ private fun PdfPageRenderer( popupPositionProvider = popupPositionProvider, onCopy = onCopy, onAiDefine = onAiDefine, - onSelectAll = onSelectAll, - isProUser = isProUser, - onShowUpsellDialog = onShowUpsellDialog + onSelectAll = onSelectAll ) } } diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 461969e..5b52fd6 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -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,