diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt index 84f1465ae..01895ef45 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/PdfViewerTestAccessors.kt @@ -44,3 +44,9 @@ var PdfViewer.outlineStatus: PdfViewModel.OutlineStatus set(value) { viewModel.setOutlineForTest(value) } + +var PdfViewer.zoomRatio: Float + get() = viewModel.zoomRatio.value + set(value) { + viewModel.setZoomRatio(value) + } diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt index 46bf913c3..d37f6d200 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt @@ -3,6 +3,7 @@ package app.grapheneos.pdfviewer.test import android.app.Activity import android.app.Instrumentation import android.content.Intent +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.test.core.app.ActivityScenario @@ -10,17 +11,20 @@ import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.PdfJsChannel.Companion.MAX_ZOOM_RATIO +import app.grapheneos.pdfviewer.PdfJsChannel.Companion.MIN_ZOOM_RATIO import app.grapheneos.pdfviewer.PdfViewer -import app.grapheneos.pdfviewer.testrules.RetryRules import app.grapheneos.pdfviewer.RetryableComposeRule import app.grapheneos.pdfviewer.TestTags import app.grapheneos.pdfviewer.currentPage import app.grapheneos.pdfviewer.documentName import app.grapheneos.pdfviewer.testrules.OrientationRules +import app.grapheneos.pdfviewer.testrules.RetryRules import app.grapheneos.pdfviewer.totalPages import app.grapheneos.pdfviewer.util.PdfViewerLauncher import app.grapheneos.pdfviewer.util.PdfViewerRobot import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import app.grapheneos.pdfviewer.zoomRatio import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -28,9 +32,11 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain import org.junit.runner.RunWith +import kotlin.math.abs +import kotlin.math.roundToInt /** - * Page navigation and JumpToPage dialog. + * Page navigation, JumpToPage dialog and CustomZoomDialog. */ @RunWith(AndroidJUnit4::class) class PdfViewerNavigationTest { @@ -320,4 +326,140 @@ class PdfViewerNavigationTest { robot.assertMenuItemVisible(PdfViewerRobot.AppMenuItem.Outline, expected = true) } } + + @Test + fun zoomButtons_menuStaysOpenAfterClick() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + robot.clickMenuZoomIn() + robot.assertZoomRowVisible() + } + } + + @Test + fun zoomInButton_disabledAtMaxZoom() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + scenario.onActivity { + it.zoomRatio = MAX_ZOOM_RATIO + } + composeRule.waitForIdle() + + robot.assertZoomInEnabled(false) + robot.assertZoomOutEnabled(true) + } + } + + @Test + fun zoomOutButton_disabledAtMinZoom() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + scenario.onActivity { + it.zoomRatio = MIN_ZOOM_RATIO + } + composeRule.waitForIdle() + + robot.assertZoomOutEnabled(false) + robot.assertZoomInEnabled(true) + } + } + + @Test + fun customZoomDialog_opensWithCurrentZoom() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val currentZoom = robot.getZoomRatio(scenario) + val expectedPercent = (currentZoom * 100).roundToInt() + + robot.clickZoomPercentage() + robot.assertCustomZoomDialogText(expectedPercent) + } + } + + @Test + fun customZoomDialog_okAppliesZoom() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + robot.clickZoomPercentage() + robot.setCustomZoomValue(200) + robot.clickDialogOk() + + PdfViewerTestUtils.pollUntil( + description = { + "Zoom ratio should be 2.0 after custom zoom " + + "(was ${robot.getZoomRatio(scenario)})" + } + ) { + abs(robot.getZoomRatio(scenario) - 2.0f) < 0.01f + } + } + } + + @Test + fun customZoomDialog_cancelDoesNotChangeZoom() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val initialZoom = robot.getZoomRatio(scenario) + + robot.clickZoomPercentage() + robot.setCustomZoomValue(200) + robot.clickDialogCancel() + composeRule.waitForIdle() + + val afterZoom = robot.getZoomRatio(scenario) + assertEquals( + "Zoom should not change after dialog cancel", + initialZoom, afterZoom, 0.001f + ) + } + } + + @Test + fun customZoomDialog_prefilledValueIsSelected_typingReplaces() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + robot.clickZoomPercentage() + robot.typeCustomZoom(200) + + composeRule.onNodeWithTag(TestTags.CUSTOM_ZOOM_FIELD) + .assertTextContains("200") + + robot.clickDialogOk() + + PdfViewerTestUtils.pollUntil( + description = { + "Zoom ratio should be 2.0 after typing replacement " + + "(was ${robot.getZoomRatio(scenario)})" + } + ) { + abs(robot.getZoomRatio(scenario) - 2.0f) < 0.01f + } + } + } + + @Test + fun customZoomDialog_rejectsInvalidInput() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + robot.clickZoomPercentage() + robot.setCustomZoomValue(0) + robot.assertDialogOkEnabled(false) + } + } } diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt index 709c0478b..4cf937401 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt @@ -2,11 +2,11 @@ package app.grapheneos.pdfviewer.test import androidx.test.ext.junit.runners.AndroidJUnit4 import app.grapheneos.pdfviewer.PdfJsChannel.Companion.MIN_ZOOM_RATIO -import app.grapheneos.pdfviewer.testrules.RetryRules import app.grapheneos.pdfviewer.RetryableComposeRule import app.grapheneos.pdfviewer.currentPage import app.grapheneos.pdfviewer.documentProperties import app.grapheneos.pdfviewer.testrules.OrientationRules +import app.grapheneos.pdfviewer.testrules.RetryRules import app.grapheneos.pdfviewer.totalPages import app.grapheneos.pdfviewer.util.PdfViewerLauncher import app.grapheneos.pdfviewer.util.PdfViewerRobot @@ -275,6 +275,79 @@ class PdfViewerRenderTest { } } + @Test + fun buttonZoomIn_increasesDimensionsAndPreservesTextLayer() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val initialWidth = robot.getCanvasCssWidth(scenario) + val initialHeight = robot.getCanvasCssHeight(scenario) + + robot.clickMenuZoomIn() + + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( + scenario, initialWidth, initialHeight + ) + + val zoomedWidth = robot.getCanvasCssWidth(scenario) + val zoomedHeight = robot.getCanvasCssHeight(scenario) + assertTrue( + "Canvas CSS width should increase after menu button zoom in " + + "($initialWidth → $zoomedWidth)", + zoomedWidth > initialWidth + ) + assertTrue( + "Canvas CSS height should increase after menu button zoom in " + + "($initialHeight → $zoomedHeight)", + zoomedHeight > initialHeight + ) + + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) + } + } + + @Test + fun buttonZoomOut_decreasesDimensionsAndPreservesTextLayer() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val defaultWidth = robot.getCanvasCssWidth(scenario) + val defaultHeight = robot.getCanvasCssHeight(scenario) + + robot.clickMenuZoomIn() + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( + scenario, defaultWidth, defaultHeight + ) + + val zoomedInWidth = robot.getCanvasCssWidth(scenario) + val zoomedInHeight = robot.getCanvasCssHeight(scenario) + + robot.clickMenuZoomOut() + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( + scenario, zoomedInWidth, zoomedInHeight + ) + + val zoomedOutWidth = robot.getCanvasCssWidth(scenario) + val zoomedOutHeight = robot.getCanvasCssHeight(scenario) + assertTrue( + "Canvas CSS width should decrease after menu button zoom out " + + "($zoomedInWidth → $zoomedOutWidth)", + zoomedOutWidth < zoomedInWidth + ) + assertTrue( + "Canvas CSS height should decrease after menu button zoom out " + + "($zoomedInHeight → $zoomedOutHeight)", + zoomedOutHeight < zoomedInHeight + ) + + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) + } + } + @Test fun documentPropertiesDialog_showsExpectedRows() { PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt index f357bf01d..2fc2c4dbb 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag @@ -559,6 +560,75 @@ class PdfViewerRobot(private val composeRule: ComposeTestRule) { return result.toFloat() } + private fun ensureOverflowMenuOpen() { + val zoomInDesc = getTargetContext().getString(R.string.zoom_in) + val nodes = composeRule.onAllNodesWithContentDescription(zoomInDesc) + .fetchSemanticsNodes() + if (nodes.isEmpty()) { + composeRule.onNodeWithContentDescription(TestTags.OVERFLOW_MENU).performClick() + composeRule.waitForIdle() + } + } + + fun clickMenuZoomIn() { + ensureOverflowMenuOpen() + val desc = getTargetContext().getString(R.string.zoom_in) + composeRule.onNodeWithContentDescription(desc).performClick() + composeRule.waitForIdle() + } + + fun clickMenuZoomOut() { + ensureOverflowMenuOpen() + val desc = getTargetContext().getString(R.string.zoom_out) + composeRule.onNodeWithContentDescription(desc).performClick() + composeRule.waitForIdle() + } + + fun clickZoomPercentage() { + ensureOverflowMenuOpen() + composeRule.onNodeWithTag(TestTags.ZOOM_PERCENTAGE).performClick() + composeRule.waitForIdle() + } + + fun assertZoomInEnabled(enabled: Boolean) { + ensureOverflowMenuOpen() + val desc = getTargetContext().getString(R.string.zoom_in) + val node = composeRule.onNodeWithContentDescription(desc) + if (enabled) node.assertIsEnabled() else node.assertIsNotEnabled() + } + + fun assertZoomOutEnabled(enabled: Boolean) { + ensureOverflowMenuOpen() + val desc = getTargetContext().getString(R.string.zoom_out) + val node = composeRule.onNodeWithContentDescription(desc) + if (enabled) node.assertIsEnabled() else node.assertIsNotEnabled() + } + + fun assertZoomRowVisible() { + val desc = getTargetContext().getString(R.string.zoom_in) + composeRule.onNodeWithContentDescription(desc).assertIsDisplayed() + } + + fun assertCustomZoomDialogText(currentPercent: Int) { + composeRule.onNodeWithTag(TestTags.CUSTOM_ZOOM_FIELD) + .assertTextContains(currentPercent.toString()) + } + + fun setCustomZoomValue(value: Int) { + composeRule.onNodeWithTag(TestTags.CUSTOM_ZOOM_FIELD).performTextClearance() + composeRule.onNodeWithTag(TestTags.CUSTOM_ZOOM_FIELD).performTextInput(value.toString()) + } + + fun typeCustomZoom(value: Int) { + composeRule.onNodeWithTag(TestTags.CUSTOM_ZOOM_FIELD).performTextInput(value.toString()) + } + + fun assertDialogOkEnabled(enabled: Boolean) { + val text = getTargetContext().getString(android.R.string.ok) + val node = composeRule.onNodeWithText(text) + if (enabled) node.assertIsEnabled() else node.assertIsNotEnabled() + } + // Text layer alignment fun assertTextLayerAligned(scenario: ActivityScenario) { diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt index b192837e2..7537e4198 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfJsChannel.kt @@ -25,11 +25,11 @@ class PdfJsChannel(private val viewModel: PdfViewModel) { fun getPage(): Int = viewModel.page.value @JavascriptInterface - fun getZoomRatio(): Float = viewModel.zoomRatio + fun getZoomRatio(): Float = viewModel.zoomRatio.value @JavascriptInterface fun setZoomRatio(ratio: Float) { - viewModel.zoomRatio = ratio.coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) + viewModel.setZoomRatio(ratio.coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO)) } @JavascriptInterface diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt index b96c5ceed..a2cfb2230 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewerScreen.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent +import android.icu.text.DecimalFormatSymbols +import android.icu.text.NumberFormat import android.util.Log import android.view.MotionEvent import android.view.ViewConfiguration @@ -24,8 +26,10 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background +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.WindowInsets import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize @@ -57,12 +61,14 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -77,6 +83,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -84,6 +91,9 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString @@ -97,6 +107,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.DialogProperties +import androidx.core.os.ConfigurationCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -113,14 +124,28 @@ import app.grapheneos.pdfviewer.viewModel.PdfViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream +import java.util.Locale import kotlin.math.abs +import kotlin.math.roundToInt private const val TAG = "PdfViewerScreen" private const val MIN_WEBVIEW_RELEASE = 133 +private val ZOOM_PRESETS = intArrayOf(25, 50, 75, 100, 125, 150, 200, 300, 500, 750, 1000) + +private fun nextZoomPreset(ratio: Float): Float? { + val currentPercent = (ratio * 100).roundToInt() + return ZOOM_PRESETS.firstOrNull { it > currentPercent }?.let { it / 100f } +} + +private fun previousZoomPreset(ratio: Float): Float? { + val currentPercent = (ratio * 100).roundToInt() + return ZOOM_PRESETS.lastOrNull { it < currentPercent }?.let { it / 100f } +} private const val CONTENT_SECURITY_POLICY = "default-src 'none'; " + @@ -227,6 +252,7 @@ fun PdfViewerScreen( var showOutline by rememberSaveable { mutableStateOf(false) } var showJumpToPage by rememberSaveable { mutableStateOf(false) } var showDocProperties by rememberSaveable { mutableStateOf(false) } + var showCustomZoom by rememberSaveable { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) } var webViewRelease by remember { mutableIntStateOf(getWebViewRelease()) } val webViewOk = webViewRelease >= MIN_WEBVIEW_RELEASE @@ -349,8 +375,10 @@ fun PdfViewerScreen( } override fun onZoom(scaleFactor: Float, focusX: Float, focusY: Float) { - viewModel.zoomRatio = (viewModel.zoomRatio * scaleFactor) - .coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) + viewModel.setZoomRatio( + (viewModel.zoomRatio.value * scaleFactor) + .coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO) + ) viewModel.zoomFocusX = focusX viewModel.zoomFocusY = focusY wv.evaluateJavascript("onRenderPage(2)", null) @@ -497,6 +525,18 @@ fun PdfViewerScreen( onJumpToPage = { showJumpToPage = true }, onRotateClockwise = { rotateDocument(viewModel, webView, 90) }, onRotateCounterClockwise = { rotateDocument(viewModel, webView, -90) }, + zoomRatioFlow = viewModel.zoomRatio, + onZoomIn = { + nextZoomPreset(viewModel.zoomRatio.value)?.let { preset -> + zoomDocument(viewModel, webView, preset) + } + }, + onZoomOut = { + previousZoomPreset(viewModel.zoomRatio.value)?.let { preset -> + zoomDocument(viewModel, webView, preset) + } + }, + onCustomZoom = { showCustomZoom = true }, onOutline = { showOutline = true }, onShare = { shareDocument(context, viewModel) }, onSaveAs = { @@ -568,6 +608,18 @@ fun PdfViewerScreen( } } + if (showCustomZoom) { + val currentZoom = remember { viewModel.zoomRatio.value } + CustomZoomDialog( + currentZoomRatio = currentZoom, + onZoom = { newRatio -> + showCustomZoom = false + zoomDocument(viewModel, webView, newRatio) + }, + onDismiss = { showCustomZoom = false } + ) + } + if (showJumpToPage && hasPages) { JumpToPageDialog( currentPage = page, @@ -730,6 +782,12 @@ private fun rotateDocument(viewModel: PdfViewModel, webView: WebView?, offset: I webView.evaluateJavascript("onRenderPage(0)", null) } +private fun zoomDocument(viewModel: PdfViewModel, webView: WebView?, ratio: Float) { + webView ?: return + viewModel.setZoomRatio(ratio.coerceIn(MIN_ZOOM_RATIO, MAX_ZOOM_RATIO)) + webView.evaluateJavascript("onRenderPage(1)", null) +} + private fun shareDocument(context: Context, viewModel: PdfViewModel) { val uri = viewModel.uri.value ?: return val shareIntent = Intent(Intent.ACTION_SEND).apply { @@ -772,6 +830,10 @@ private fun PdfTopAppBar( onJumpToPage: () -> Unit, onRotateClockwise: () -> Unit, onRotateCounterClockwise: () -> Unit, + zoomRatioFlow: StateFlow, + onZoomIn: () -> Unit, + onZoomOut: () -> Unit, + onCustomZoom: () -> Unit, onOutline: () -> Unit, onShare: () -> Unit, onSaveAs: () -> Unit, @@ -802,7 +864,7 @@ private fun PdfTopAppBar( IconButton(onClick = onOpen, enabled = !webViewCrashed && webViewOk) { Icon( - painterResource(R.drawable.ic_insert_drive_file_24dp), + painterResource(R.drawable.ic_open_file_24dp), contentDescription = stringResource(R.string.action_open) ) } @@ -875,6 +937,13 @@ private fun PdfTopAppBar( ) } ) + ZoomRow( + zoomRatioFlow = zoomRatioFlow, + onZoomIn = onZoomIn, + onZoomOut = onZoomOut, + onCustomZoom = { onMenuToggle(false); onCustomZoom() }, + enabled = enabled + ) if (hasOutline) { DropdownMenuItem( text = { Text(stringResource(R.string.action_outline)) }, @@ -946,39 +1015,184 @@ private fun WebViewAlertScreen( onReload: () -> Unit, modifier: Modifier = Modifier ) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) + Surface(modifier = modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_error_24dp), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + if (showReload) { + Button( + onClick = onReload, + modifier = Modifier + .padding(top = 8.dp) + .testTag(TestTags.RELOAD_BUTTON) + ) { + Text(stringResource(R.string.reload)) + } + } + } + } + } +} + +@Composable +private fun ZoomRow( + zoomRatioFlow: StateFlow, + onZoomIn: () -> Unit, + onZoomOut: () -> Unit, + onCustomZoom: () -> Unit, + enabled: Boolean +) { + val zoomRatio by zoomRatioFlow.collectAsStateWithLifecycle() + if (zoomRatio <= 0f) return + + val locale = currentLocale() + val zoomFormat = remember(locale) { NumberFormat.getPercentInstance(locale) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + onClick = onZoomOut, + enabled = enabled && previousZoomPreset(zoomRatio) != null ) { Icon( - painter = painterResource(R.drawable.ic_error_outline_24dp), - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary + painterResource(R.drawable.ic_remove_24dp), + contentDescription = stringResource(R.string.zoom_out) ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(top = 8.dp) + } + + TextButton( + onClick = onCustomZoom, + enabled = enabled, + modifier = Modifier.testTag(TestTags.ZOOM_PERCENTAGE) + .semantics { liveRegion = LiveRegionMode.Polite } + ) { + Text(zoomFormat.format(zoomRatio)) + } + + IconButton( + onClick = onZoomIn, + enabled = enabled && nextZoomPreset(zoomRatio) != null + ) { + Icon( + painterResource(R.drawable.ic_add_24dp), + contentDescription = stringResource(R.string.zoom_in) ) - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp) + } + } +} + +@Composable +private fun CustomZoomDialog( + currentZoomRatio: Float, + onZoom: (Float) -> Unit, + onDismiss: () -> Unit +) { + val minPercent = (MIN_ZOOM_RATIO * 100).roundToInt() + val maxPercent = (MAX_ZOOM_RATIO * 100).roundToInt() + val maxLength = maxPercent.toString().length + + val locale = currentLocale() + val percentSymbol = remember(locale) { + DecimalFormatSymbols.getInstance(locale).percent.toString() + } + val percentIsPrefix = remember(locale) { + val formatted = NumberFormat.getPercentInstance(locale).format(1) + val digitIndex = formatted.indexOfFirst { it.isDigit() } + val symbolIndex = formatted.indexOfFirst { !it.isDigit() && !it.isWhitespace() } + symbolIndex in 0 until digitIndex + } + + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + val initial = (currentZoomRatio * 100).roundToInt().toString() + mutableStateOf( + TextFieldValue( + text = initial, + selection = TextRange(0, initial.length) ) - if (showReload) { - Button( - onClick = onReload, + ) + } + val parsed = textFieldValue.text.toIntOrNull() + val isValid = parsed != null && parsed in minPercent..maxPercent + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { parsed?.let { onZoom(it / 100f) } }, + enabled = isValid + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { Text(stringResource(R.string.zoom)) }, + text = { + Column { + Text(stringResource(R.string.zoom_range, minPercent, maxPercent)) + OutlinedTextField( + value = textFieldValue, + onValueChange = { newValue -> + val filtered = newValue.text.filter { it.isDigit() }.take(maxLength) + textFieldValue = if (filtered == newValue.text) { + newValue + } else { + newValue.copy( + text = filtered, + selection = TextRange( + minOf(newValue.selection.end, filtered.length) + ) + ) + } + }, + singleLine = true, + isError = textFieldValue.text.isNotEmpty() && !isValid, + prefix = if (percentIsPrefix) ({ Text(percentSymbol) }) else null, + suffix = if (percentIsPrefix) null else ({ Text(percentSymbol) }), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { if (isValid) onZoom(parsed / 100f) } + ), modifier = Modifier + .fillMaxWidth() .padding(top = 8.dp) - .testTag(TestTags.RELOAD_BUTTON) - ) { - Text(stringResource(R.string.reload)) - } + .focusRequester(focusRequester) + .testTag(TestTags.CUSTOM_ZOOM_FIELD) + ) } } - } + ) } @Composable @@ -1211,3 +1425,10 @@ private fun PasswordPromptDialog( } ) } + +@Composable +@ReadOnlyComposable +@SuppressLint("NonObservableLocale") +private fun currentLocale(): Locale = + ConfigurationCompat.getLocales(LocalConfiguration.current)[0] + ?: Locale.getDefault() diff --git a/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt b/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt index 84b635801..19d1ea396 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/TestTags.kt @@ -8,4 +8,6 @@ object TestTags { const val OUTLINE_ITEM = "outline_item" const val OVERFLOW_MENU = "overflow_menu" const val JUMP_TO_PAGE_FIELD = "jump_to_page_field" + const val CUSTOM_ZOOM_FIELD = "custom_zoom_field" + const val ZOOM_PERCENTAGE = "zoom_percentage" } diff --git a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt index 64d30d8c2..39baf6758 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt +++ b/app/src/main/java/app/grapheneos/pdfviewer/viewModel/PdfViewModel.kt @@ -189,7 +189,10 @@ class PdfViewModel( } } - @Volatile var zoomRatio: Float = 0f + private val _zoomRatio = MutableStateFlow(0f) + val zoomRatio: StateFlow = _zoomRatio.asStateFlow() + fun setZoomRatio(value: Float) { _zoomRatio.value = value } + @Volatile var encryptedDocumentPassword: String = "" @Volatile var zoomFocusX = 0f @Volatile var zoomFocusY = 0f @@ -283,7 +286,7 @@ class PdfViewModel( fun resetDocumentState() { setPage(1) _numPages.value = 0 - zoomRatio = 0f + _zoomRatio.value = 0f setDocumentOrientationDegrees(0) encryptedDocumentPassword = "" clearOutline() @@ -294,7 +297,7 @@ class PdfViewModel( fun prepareForLoad() { documentPropertiesLoaded.set(false) _documentLoaded.value = false - zoomRatio = 0f + _zoomRatio.value = 0f } fun handleLoadError() { diff --git a/app/src/main/res/drawable/ic_add_24dp.xml b/app/src/main/res/drawable/ic_add_24dp.xml new file mode 100644 index 000000000..d6fd3d379 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_24dp.xml b/app/src/main/res/drawable/ic_error_24dp.xml new file mode 100644 index 000000000..8d8153d21 --- /dev/null +++ b/app/src/main/res/drawable/ic_error_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_error_outline_24dp.xml b/app/src/main/res/drawable/ic_error_outline_24dp.xml deleted file mode 100644 index 9b8c530d5..000000000 --- a/app/src/main/res/drawable/ic_error_outline_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_first_page_24dp.xml b/app/src/main/res/drawable/ic_first_page_24dp.xml index 59d6aa5d6..96866b8b2 100644 --- a/app/src/main/res/drawable/ic_first_page_24dp.xml +++ b/app/src/main/res/drawable/ic_first_page_24dp.xml @@ -1,9 +1,10 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + diff --git a/app/src/main/res/drawable/ic_insert_drive_file_24dp.xml b/app/src/main/res/drawable/ic_insert_drive_file_24dp.xml deleted file mode 100644 index d35c045dd..000000000 --- a/app/src/main/res/drawable/ic_insert_drive_file_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_last_page_24dp.xml b/app/src/main/res/drawable/ic_last_page_24dp.xml index 4807df630..59f6a194a 100644 --- a/app/src/main/res/drawable/ic_last_page_24dp.xml +++ b/app/src/main/res/drawable/ic_last_page_24dp.xml @@ -1,9 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_navigate_before_24dp.xml b/app/src/main/res/drawable/ic_navigate_before_24dp.xml index e2334073e..7c486f849 100644 --- a/app/src/main/res/drawable/ic_navigate_before_24dp.xml +++ b/app/src/main/res/drawable/ic_navigate_before_24dp.xml @@ -1,9 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_navigate_next_24dp.xml b/app/src/main/res/drawable/ic_navigate_next_24dp.xml index f7f29d69d..3d036ecb1 100644 --- a/app/src/main/res/drawable/ic_navigate_next_24dp.xml +++ b/app/src/main/res/drawable/ic_navigate_next_24dp.xml @@ -1,9 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_open_file_24dp.xml b/app/src/main/res/drawable/ic_open_file_24dp.xml new file mode 100644 index 000000000..c94ebed36 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_file_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_bulletlist_24dp.xml b/app/src/main/res/drawable/ic_outline_bulletlist_24dp.xml index 98fba8cee..4889f4fbe 100644 --- a/app/src/main/res/drawable/ic_outline_bulletlist_24dp.xml +++ b/app/src/main/res/drawable/ic_outline_bulletlist_24dp.xml @@ -1,10 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_pageview_24dp.xml b/app/src/main/res/drawable/ic_pageview_24dp.xml index 8a3ce2ddb..c4f8a34a1 100644 --- a/app/src/main/res/drawable/ic_pageview_24dp.xml +++ b/app/src/main/res/drawable/ic_pageview_24dp.xml @@ -1,9 +1,10 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + diff --git a/app/src/main/res/drawable/ic_remove_24dp.xml b/app/src/main/res/drawable/ic_remove_24dp.xml new file mode 100644 index 000000000..46c12d354 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_rotate_left_24dp.xml b/app/src/main/res/drawable/ic_rotate_left_24dp.xml index 17773435f..b357ca8c8 100644 --- a/app/src/main/res/drawable/ic_rotate_left_24dp.xml +++ b/app/src/main/res/drawable/ic_rotate_left_24dp.xml @@ -1,9 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_rotate_right_24dp.xml b/app/src/main/res/drawable/ic_rotate_right_24dp.xml index 5b80f8ea8..796b0a344 100644 --- a/app/src/main/res/drawable/ic_rotate_right_24dp.xml +++ b/app/src/main/res/drawable/ic_rotate_right_24dp.xml @@ -1,9 +1,11 @@ - + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal" + android:autoMirrored="true"> + diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml index 1ce584327..ee8539d0d 100644 --- a/app/src/main/res/drawable/ic_save_24dp.xml +++ b/app/src/main/res/drawable/ic_save_24dp.xml @@ -1,10 +1,10 @@ + android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280L840,492Q821,484 800.5,481.5Q780,479 760,482L760,313L647,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L440,760L440,764L440,840L200,840ZM200,200L200,313L200,482Q200,485 200,494.5Q200,504 200,519L200,760L200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200ZM520,920L520,797L741,577Q750,568 761,564Q772,560 783,560Q795,560 806,564.5Q817,569 826,578L863,615Q871,624 875.5,635Q880,646 880,657Q880,668 876,679.5Q872,691 863,700L643,920L520,920ZM820,657L820,657L783,620L783,620L820,657ZM580,860L618,860L739,738L721,719L702,701L580,822L580,860ZM721,719L702,701L702,701L739,738L739,738L721,719ZM240,400L600,400L600,240L240,240L240,400ZM480,720Q481,720 482,720Q483,720 484,720L600,605Q600,603 600,602.5Q600,602 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720Z"/> diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml index 2f13bb3e2..9224dbec8 100644 --- a/app/src/main/res/drawable/ic_share_24dp.xml +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -1,10 +1,10 @@ + android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a680b81f1..a4827dabc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,5 +57,10 @@ PDF version Pages + Zoom + Zoom in + Zoom out + Percentage (%1$d–%2$d%%) + Unknown diff --git a/viewer/js/index.js b/viewer/js/index.js index 545f6fbaa..9578abd29 100644 --- a/viewer/js/index.js +++ b/viewer/js/index.js @@ -112,7 +112,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { orientationDegrees = channel.getDocumentOrientationDegrees(); console.log("page: " + pageNumber + ", zoom: " + newZoomRatio + ", orientationDegrees: " + orientationDegrees + ", prerender: " + prerender); - for (let i = 0; i < cache.length; i++) { + for (let i = 0; zoom !== 2 && i < cache.length; i++) { const cached = cache[i]; if (cached.pageNumber === pageNumber && cached.zoomRatio === newZoomRatio && cached.orientationDegrees === orientationDegrees) { @@ -121,6 +121,7 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { cache.push(cached); display(cached.canvas, zoom); + zoomRatio = newZoomRatio; textLayerDiv.replaceWith(cached.textLayerDiv); textLayerDiv = cached.textLayerDiv;