diff --git a/app/src/main/java/com/aryan/reader/AppUiModels.kt b/app/src/main/java/com/aryan/reader/AppUiModels.kt index d3e7741..a5155dc 100644 --- a/app/src/main/java/com/aryan/reader/AppUiModels.kt +++ b/app/src/main/java/com/aryan/reader/AppUiModels.kt @@ -124,4 +124,5 @@ data class ReaderScreenState( val customAppThemes: List = emptyList(), val allTags: List = emptyList(), val showTagSelectionDialogFor: Set = emptySet(), + val isScreenCaptureProtectionEnabled: Boolean = false, ) diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 5d431c4..dc9912b 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -359,6 +359,15 @@ fun HomeScreen( onExportLogsClick = { viewModel.exportLogsToFile(context) }, onToggleHideReaderAi = { saveHideReaderAiFeatures(context, !loadHideReaderAiFeatures(context)) + }, + onScreenCaptureProtectionChange = { enabled -> + viewModel.setScreenCaptureProtectionEnabled(enabled) + val messageRes = if (enabled) { + R.string.banner_screen_capture_protection_on + } else { + R.string.banner_screen_capture_protection_off + } + viewModel.showBanner(context.getString(messageRes)) } ) } else { @@ -1019,7 +1028,8 @@ fun DefaultTopAppBar( onTestSpeechBubbleDetectionClick: () -> Unit, onLanguageClick: () -> Unit, onExportLogsClick: () -> Unit, - onToggleHideReaderAi: () -> Unit + onToggleHideReaderAi: () -> Unit, + onScreenCaptureProtectionChange: (Boolean) -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) } @@ -1090,6 +1100,19 @@ fun DefaultTopAppBar( } }) + DropdownMenuItem( + text = { Text(stringResource(R.string.options_screen_capture_protection)) }, + onClick = { + onScreenCaptureProtectionChange(!uiState.isScreenCaptureProtectionEnabled) + showOptionsMenu = false + }, + trailingIcon = { + if (uiState.isScreenCaptureProtectionEnabled) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.content_desc_enabled)) + } + } + ) + DropdownMenuItem(text = { Text(stringResource(R.string.options_external_file_behavior)) }, onClick = { onExternalFileBehaviorClick() showOptionsMenu = false diff --git a/app/src/main/java/com/aryan/reader/MainActivity.kt b/app/src/main/java/com/aryan/reader/MainActivity.kt index 8df59dd..e746e7a 100644 --- a/app/src/main/java/com/aryan/reader/MainActivity.kt +++ b/app/src/main/java/com/aryan/reader/MainActivity.kt @@ -22,6 +22,7 @@ package com.aryan.reader import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.WindowManager import android.webkit.WebView import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent @@ -34,6 +35,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController @@ -87,6 +90,8 @@ class MainActivity : AppCompatActivity() { setContent { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + ScreenCaptureProtectionEffect(enabled = uiState.isScreenCaptureProtectionEnabled) + val darkTheme = when (uiState.appThemeMode) { AppThemeMode.LIGHT -> false AppThemeMode.DARK -> true @@ -131,4 +136,21 @@ class MainActivity : AppCompatActivity() { viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = true) } } -} \ No newline at end of file +} + +@Composable +private fun MainActivity.ScreenCaptureProtectionEffect(enabled: Boolean) { + DisposableEffect(enabled) { + if (enabled) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + onDispose { + if (enabled) { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } +} diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index d342f53..dd58c74 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -544,6 +544,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio activeTabBookId = prefs.getString(KEY_ACTIVE_TAB, null), externalFileBehavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK", useStrictFileFilter = prefs.getBoolean(KEY_USE_STRICT_FILE_FILTER, false), + isScreenCaptureProtectionEnabled = prefs.getBoolean(KEY_SCREEN_CAPTURE_PROTECTION, false), appThemeMode = try { AppThemeMode.valueOf(prefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) ?: AppThemeMode.SYSTEM.name) } catch (_: Exception) { AppThemeMode.SYSTEM }, @@ -5227,6 +5228,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(useStrictFileFilter = enabled) } } + fun setScreenCaptureProtectionEnabled(enabled: Boolean) { + prefs.edit { putBoolean(KEY_SCREEN_CAPTURE_PROTECTION, enabled) } + _internalState.update { it.copy(isScreenCaptureProtectionEnabled = enabled) } + } + private fun loadCustomAppThemes(prefs: SharedPreferences): List { val jsonString = prefs.getString(KEY_CUSTOM_APP_THEMES, "[]") ?: "[]" val themes = mutableListOf() @@ -5506,6 +5512,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type" private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior" private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter" + private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled" private const val KEY_APP_THEME_MODE = "app_theme_mode" private const val KEY_APP_CONTRAST_OPTION = "app_contrast_option" private const val KEY_APP_SEED_COLOR = "app_seed_color" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c603f45..af96739 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1304,4 +1304,7 @@ Exit Smart Zoom Smart Comic Zoom Toggle Smart Comic Zoom + Screen capture protection + Screen capture protection is on + Screen capture protection is off diff --git a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt index 312726a..a059d52 100644 --- a/app/src/test/java/com/aryan/reader/MainViewModelTest.kt +++ b/app/src/test/java/com/aryan/reader/MainViewModelTest.kt @@ -319,6 +319,25 @@ class MainViewModelTest { verify { mockEditor.putString("external_file_behavior", "KEEP") } } + @Test + fun `screen capture protection persists and updates state`() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.uiState.collect {} + } + + viewModel.setScreenCaptureProtectionEnabled(true) + + val enabledState = viewModel.uiState.first { it.isScreenCaptureProtectionEnabled } + assertTrue(enabledState.isScreenCaptureProtectionEnabled) + verify { mockEditor.putBoolean("screen_capture_protection_enabled", true) } + + viewModel.setScreenCaptureProtectionEnabled(false) + + val disabledState = viewModel.uiState.first { !it.isScreenCaptureProtectionEnabled } + assertFalse(disabledState.isScreenCaptureProtectionEnabled) + verify { mockEditor.putBoolean("screen_capture_protection_enabled", false) } + } + @Test fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest { backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {