diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt new file mode 100644 index 000000000..567213c13 --- /dev/null +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerEdgeToEdgeTest.kt @@ -0,0 +1,189 @@ +package app.grapheneos.pdfviewer.test + +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.grapheneos.pdfviewer.PdfViewer +import app.grapheneos.pdfviewer.R +import app.grapheneos.pdfviewer.util.PdfViewerLauncher +import app.grapheneos.pdfviewer.util.PdfViewerRobot +import app.grapheneos.pdfviewer.util.PdfViewerTestUtils +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +/** + * Edge-to-edge rendering and inset geometry checks. + */ +@RunWith(AndroidJUnit4::class) +class PdfViewerEdgeToEdgeTest { + + private val robot = PdfViewerRobot() + + private data class EdgeInsets( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float + ) + + @Test + fun edgeToEdgeBridge_reportsAndroidInsets() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { + "JS bridge insets should match Android layout/window insets " + + "(expected=${getExpectedInsets(scenario)}, " + + "actual=${getChannelInsets(scenario)})" + } + ) { + insetsMatch(getExpectedInsets(scenario), getChannelInsets(scenario)) + } + + val expected = getExpectedInsets(scenario) + val actual = getChannelInsets(scenario) + assertTrue("Expected visible app bar top inset > 0", expected.top > 0f) + assertInsetsMatch("JS bridge insets", expected, actual) + } + } + + @Test + fun edgeToEdgeCanvasPadding_matchesBridgeInsets() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { + "Canvas padding should match JS bridge insets " + + "(expected=${getChannelInsets(scenario)}, " + + "actual=${getCanvasPaddingInDevicePixels(scenario)})" + } + ) { + insetsMatch( + getChannelInsets(scenario), + getCanvasPaddingInDevicePixels(scenario) + ) + } + + assertInsetsMatch( + "Canvas padding", + getChannelInsets(scenario), + getCanvasPaddingInDevicePixels(scenario) + ) + } + } + + @Test + fun toolbarToggle_preservesTextLayerAlignment() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) + + robot.tapWebView() + PdfViewerTestUtils.waitForToolbarState(scenario, visible = false) + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) + + robot.tapWebView() + PdfViewerTestUtils.waitForToolbarState(scenario, visible = true) + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) + } + } + + private fun getExpectedInsets( + scenario: ActivityScenario + ): EdgeInsets { + var result: EdgeInsets? = null + scenario.onActivity { activity -> + val webView = activity.findViewById(R.id.webview) + val appBar = activity.findViewById(R.id.app_bar_layout) + val insetTypes = WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() + val insets = ViewCompat.getRootWindowInsets(webView)?.getInsets(insetTypes) + + result = EdgeInsets( + left = (insets?.left ?: 0).toFloat(), + top = appBar.height.toFloat(), + right = (insets?.right ?: 0).toFloat(), + bottom = (insets?.bottom ?: 0).toFloat() + ) + } + return result ?: EdgeInsets(0f, 0f, 0f, 0f) + } + + private fun getChannelInsets( + scenario: ActivityScenario + ) = EdgeInsets( + left = PdfViewerTestUtils.evaluateJs( + scenario, "channel.getInsetLeft()" + ).toFloat(), + top = PdfViewerTestUtils.evaluateJs( + scenario, "channel.getInsetTop()" + ).toFloat(), + right = PdfViewerTestUtils.evaluateJs( + scenario, "channel.getInsetRight()" + ).toFloat(), + bottom = PdfViewerTestUtils.evaluateJs( + scenario, "channel.getInsetBottom()" + ).toFloat() + ) + + private fun getCanvasPaddingInDevicePixels( + scenario: ActivityScenario + ) = EdgeInsets( + left = evaluateCanvasPaddingInDevicePixels(scenario, "paddingLeft"), + top = evaluateCanvasPaddingInDevicePixels(scenario, "paddingTop"), + right = evaluateCanvasPaddingInDevicePixels(scenario, "paddingRight"), + bottom = evaluateCanvasPaddingInDevicePixels(scenario, "paddingBottom") + ) + + private fun evaluateCanvasPaddingInDevicePixels( + scenario: ActivityScenario, + propertyName: String + ): Float { + return PdfViewerTestUtils.evaluateJs( + scenario, + """ + (function() { + const canvas = document.getElementById('content'); + const value = parseFloat(getComputedStyle(canvas)['$propertyName']) || 0; + return value * globalThis.devicePixelRatio; + })() + """.trimIndent() + ).toFloat() + } + + private fun assertInsetsMatch( + label: String, + expected: EdgeInsets, + actual: EdgeInsets + ) { + assertTrue( + "$label should match expected=$expected actual=$actual", + insetsMatch(expected, actual) + ) + } + + private fun insetsMatch(expected: EdgeInsets, actual: EdgeInsets): Boolean { + return abs(expected.left - actual.left) <= INSET_TOLERANCE_PX && + abs(expected.top - actual.top) <= INSET_TOLERANCE_PX && + abs(expected.right - actual.right) <= INSET_TOLERANCE_PX && + abs(expected.bottom - actual.bottom) <= INSET_TOLERANCE_PX + } + + private companion object { + const val INSET_TOLERANCE_PX = 1.5f + } +} 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 504e8fa27..5d539984e 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerNavigationTest.kt @@ -166,7 +166,7 @@ class PdfViewerNavigationTest { robot.clickRotateClockwise() PdfViewerTestUtils.waitForDocumentRotation(scenario, expected = 90) - robot.performPinchZoomIn() + robot.performPinchZoomIn(scenario) val zoomedRatio = robot.getZoomRatio(scenario) assertTrue( "Zoom should have increased (was $zoomedRatio)", 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 c3e033a72..dda226749 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/test/PdfViewerRenderTest.kt @@ -132,24 +132,24 @@ class PdfViewerRenderTest { PdfViewerTestUtils.waitForCanvasRendered(scenario) PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") - val initialWidth = robot.getCanvasWidth(scenario) - val initialHeight = robot.getCanvasHeight(scenario) + val initialWidth = robot.getCanvasCssWidth(scenario) + val initialHeight = robot.getCanvasCssHeight(scenario) - robot.performPinchZoomIn() + robot.performPinchZoomIn(scenario) - PdfViewerTestUtils.waitForCanvasDimensionsChanged( + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( scenario, initialWidth, initialHeight ) - val zoomedWidth = robot.getCanvasWidth(scenario) - val zoomedHeight = robot.getCanvasHeight(scenario) + val zoomedWidth = robot.getCanvasCssWidth(scenario) + val zoomedHeight = robot.getCanvasCssHeight(scenario) assertTrue( - "Canvas width should increase after zoom in " + + "Canvas CSS width should increase after zoom in " + "($initialWidth → $zoomedWidth)", zoomedWidth > initialWidth ) assertTrue( - "Canvas height should increase after zoom in " + + "Canvas CSS height should increase after zoom in " + "($initialHeight → $zoomedHeight)", zoomedHeight > initialHeight ) @@ -165,25 +165,44 @@ class PdfViewerRenderTest { PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) PdfViewerTestUtils.waitForCanvasRendered(scenario) - robot.performPinchZoomIn() - val initialWidth = robot.getCanvasWidth(scenario) - val initialHeight = robot.getCanvasHeight(scenario) + val defaultWidth = robot.getCanvasCssWidth(scenario) + val defaultHeight = robot.getCanvasCssHeight(scenario) + + robot.performPinchZoomIn(scenario) + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( + scenario, defaultWidth, defaultHeight + ) + PdfViewerTestUtils.assertTextLayerContent(scenario, "Test Text") + robot.assertTextLayerAligned(scenario) - robot.performPinchZoomOut() + val initialWidth = robot.getCanvasCssWidth(scenario) + val initialHeight = robot.getCanvasCssHeight(scenario) + val initialZoomRatio = robot.getZoomRatio(scenario) - PdfViewerTestUtils.waitForCanvasDimensionsChanged( + robot.performPinchZoomOut(scenario) + PdfViewerTestUtils.pollUntil( + timeout = 5_000, + description = { + "Zoom ratio should decrease after zoom out " + + "(initial=$initialZoomRatio, current=${robot.getZoomRatio(scenario)})" + } + ) { + robot.getZoomRatio(scenario) < initialZoomRatio + } + + PdfViewerTestUtils.waitForCanvasCssDimensionsChanged( scenario, initialWidth, initialHeight ) - val zoomedWidth = robot.getCanvasWidth(scenario) - val zoomedHeight = robot.getCanvasHeight(scenario) + val zoomedWidth = robot.getCanvasCssWidth(scenario) + val zoomedHeight = robot.getCanvasCssHeight(scenario) assertTrue( - "Canvas width should decrease after zoom out " + + "Canvas CSS width should decrease after zoom out " + "($initialWidth → $zoomedWidth)", zoomedWidth < initialWidth ) assertTrue( - "Canvas height should decrease after zoom out " + + "Canvas CSS height should decrease after zoom out " + "($initialHeight → $zoomedHeight)", zoomedHeight < initialHeight ) @@ -199,7 +218,7 @@ class PdfViewerRenderTest { PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) PdfViewerTestUtils.waitForCanvasRendered(scenario) - repeat(5) { robot.performPinchZoomOut() } + repeat(5) { robot.performPinchZoomOut(scenario, speed = 1500) } PdfViewerTestUtils.pollUntil( timeout = 5_000, @@ -213,6 +232,30 @@ class PdfViewerRenderTest { } } + @Test + fun setZoomRatio_clampsToMinimumZoomRatio() { + PdfViewerLauncher.launchWithTestAsset("test-simple.pdf").use { scenario -> + PdfViewerTestUtils.waitForDocumentFullyLoaded(scenario) + PdfViewerTestUtils.waitForCanvasRendered(scenario) + + val clampedZoom = PdfViewerTestUtils.evaluateJs( + scenario, + """ + (function() { + channel.setZoomRatio(-1); + return channel.getZoomRatio(); + })() + """.trimIndent() + ).toFloat() + + assertTrue( + "Zoom ratio did not clamp to MIN_ZOOM_RATIO " + + "(was $clampedZoom)", + abs(clampedZoom - MIN_ZOOM_RATIO) < 0.001f + ) + } + } + @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 23f615134..a81782b02 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerRobot.kt @@ -1,9 +1,12 @@ package app.grapheneos.pdfviewer.util +import android.graphics.Rect import android.view.View import android.widget.NumberPicker import androidx.annotation.IdRes import androidx.annotation.StringRes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider @@ -30,6 +33,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.Until import app.grapheneos.pdfviewer.PdfViewer import app.grapheneos.pdfviewer.R @@ -49,12 +53,25 @@ import org.hamcrest.Matchers.not import org.hamcrest.TypeSafeMatcher import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import kotlin.math.roundToInt /** * Encapsulates all Espresso view assertions and actions. */ class PdfViewerRobot { + private data class GestureMargins( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int + ) + + private companion object { + const val UIAUTOMATOR_DEFAULT_GESTURE_MARGIN_PERCENT = 0.1f + const val GESTURE_EDGE_MARGIN_DP = 32f + } + enum class AppMenuItem( @IdRes internal val id: Int, @StringRes internal val titleRes: Int @@ -471,30 +488,126 @@ class PdfViewerRobot { // Zoom - fun performPinchZoomIn(percent: Float = 0.75f, speed: Int = 500) { - val device = UiDevice.getInstance( - InstrumentationRegistry.getInstrumentation() - ) + fun performPinchZoomIn( + scenario: ActivityScenario, + percent: Float = 0.75f, + speed: Int = 500, + ) { + val webView = findWebViewObject() + applyContentGestureMargins(webView, scenario) + webView.pinchOpen(percent, speed) + } + + fun performPinchZoomOut( + scenario: ActivityScenario, + percent: Float = 0.75f, + speed: Int = 500, + ) { + val webView = findWebViewObject() + applyContentGestureMargins(webView, scenario) + webView.pinchClose(percent, speed) + } + + private fun findWebViewObject(): UiObject2 { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) // Using class name because UiAutomator cannot find WebView reliably because // android:importantForAccessibility is "no" - val webView = device.wait( + return device.wait( Until.findObject(By.clazz("android.webkit.WebView")), 10_000 ) ?: throw AssertionError( "Could not find WebView by class `android.webkit.WebView` within 10s" ) - webView.pinchOpen(percent, speed) } - fun performPinchZoomOut(percent: Float = 0.75f, speed: Int = 500) { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val webView = device.wait( - Until.findObject(By.clazz("android.webkit.WebView")), - 10_000 - ) ?: throw AssertionError( - "Could not find WebView by class `android.webkit.WebView` within 10s" + private fun applyContentGestureMargins( + webViewObject: UiObject2, + scenario: ActivityScenario? + ) { + if (scenario == null) { + return + } + + // UiObject2.pinchClose starts at the outer corners. Edge-to-edge keeps the + // WebView bounds behind the app bar, so constrain pinches to real content. + val margins = getContentGestureMargins(scenario) + webViewObject.setGestureMargins( + margins.left, + margins.top, + margins.right, + margins.bottom ) - webView.pinchClose(percent, speed) + } + + private fun getContentGestureMargins( + scenario: ActivityScenario + ): GestureMargins { + var margins: GestureMargins? = null + scenario.onActivity { activity -> + val webView = activity.findViewById(R.id.webview) + val webViewBounds = getBoundsInScreen(webView) + val appBarBounds = Rect() + val appBar = activity.findViewById(R.id.app_bar_layout) + val appBarBottom = if (appBar.getGlobalVisibleRect(appBarBounds)) { + appBarBounds.bottom + } else { + webViewBounds.top + } + val insetTypes = WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() + val insets = ViewCompat.getRootWindowInsets(webView)?.getInsets(insetTypes) + + val density = webView.resources.displayMetrics.density + val edgeMargin = (GESTURE_EDGE_MARGIN_DP * density).roundToInt() + val defaultHorizontal = + (webViewBounds.width() * UIAUTOMATOR_DEFAULT_GESTURE_MARGIN_PERCENT).roundToInt() + val defaultVertical = + (webViewBounds.height() * UIAUTOMATOR_DEFAULT_GESTURE_MARGIN_PERCENT).roundToInt() + val topObstruction = maxOf( + insets?.top ?: 0, + appBarBottom - webViewBounds.top + ).coerceAtLeast(0) + + val left = maxOf(defaultHorizontal, (insets?.left ?: 0) + edgeMargin) + val top = maxOf(defaultVertical, topObstruction + edgeMargin) + val right = maxOf(defaultHorizontal, (insets?.right ?: 0) + edgeMargin) + val bottom = maxOf(defaultVertical, (insets?.bottom ?: 0) + edgeMargin) + val fittedHorizontal = fitMargins(left, right, webViewBounds.width()) + val fittedVertical = fitMargins(top, bottom, webViewBounds.height()) + + margins = GestureMargins( + fittedHorizontal.first, + fittedVertical.first, + fittedHorizontal.second, + fittedVertical.second + ) + } + return margins ?: GestureMargins(0, 0, 0, 0) + } + + private fun getBoundsInScreen(view: View): Rect { + val location = IntArray(2) + view.getLocationOnScreen(location) + return Rect( + location[0], + location[1], + location[0] + view.width, + location[1] + view.height + ) + } + + private fun fitMargins(start: Int, end: Int, size: Int): Pair { + if (size <= 2) { + return 0 to 0 + } + val maxTotal = size - 2 + if (start + end <= maxTotal) { + return start to end + } + + val scale = maxTotal.toFloat() / (start + end).toFloat() + val fittedStart = (start * scale).roundToInt().coerceIn(0, maxTotal) + return fittedStart to maxTotal - fittedStart } fun getZoomRatio(scenario: ActivityScenario): Float { diff --git a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt index 473ecd66f..2dab50e5d 100644 --- a/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt +++ b/app/src/androidTest/kotlin/app/grapheneos/pdfviewer/util/PdfViewerTestUtils.kt @@ -269,6 +269,31 @@ object PdfViewerTestUtils { } } + fun waitForCanvasCssDimensionsChanged( + scenario: ActivityScenario, + previousWidth: Int, + previousHeight: Int, + timeout: Long = 15_000 + ) { + pollUntil( + timeout = timeout, + description = { + "Canvas CSS dimensions did not change from ${previousWidth}x${previousHeight} " + + "within ${timeout}ms" + } + ) { + val w = evaluateJs( + scenario, + "parseInt(document.getElementById('content').style.width) || 0" + ).toIntOrNull() + val h = evaluateJs( + scenario, + "parseInt(document.getElementById('content').style.height) || 0" + ).toIntOrNull() + w != null && h != null && (w != previousWidth || h != previousHeight) + } + } + fun waitForDocumentChanged( scenario: ActivityScenario, expectedPages: Int, diff --git a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java index 553dd8858..8288f32d7 100644 --- a/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java +++ b/app/src/main/java/app/grapheneos/pdfviewer/PdfViewer.java @@ -33,7 +33,11 @@ import androidx.annotation.VisibleForTesting; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.ViewModelProvider; @@ -141,6 +145,10 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private float mZoomFocusY = 0f; private int mSwipeThreshold; private int mSwipeVelocityThreshold; + private volatile float mInsetLeft = 0f; + private volatile float mInsetTop = 0f; + private volatile float mInsetRight = 0f; + private volatile float mInsetBottom = 0f; private int mDocumentOrientationDegrees; private int mDocumentState; private String mEncryptedDocumentPassword; @@ -157,6 +165,13 @@ public class PdfViewer extends AppCompatActivity implements LoaderManager.Loader private PasswordPromptFragment mPasswordPromptFragment; public PdfViewModel viewModel; + private final View.OnLayoutChangeListener appBarOnLayoutChangeListener = + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + if (binding.toolbar.getVisibility() == View.VISIBLE) { + mInsetTop = bottom - top; + } + }; + private final ActivityResultLauncher openDocumentLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result == null) return; @@ -234,6 +249,26 @@ public float getMaxZoomRatio() { return MAX_ZOOM_RATIO; } + @JavascriptInterface + public float getInsetLeft() { + return mInsetLeft; + } + + @JavascriptInterface + public float getInsetTop() { + return mInsetTop; + } + + @JavascriptInterface + public float getInsetRight() { + return mInsetRight; + } + + @JavascriptInterface + public float getInsetBottom() { + return mInsetBottom; + } + @JavascriptInterface public int getDocumentOrientationDegrees() { return mDocumentOrientationDegrees; @@ -305,7 +340,7 @@ private void showWebViewCrashed() { @SuppressLint({"SetJavaScriptEnabled"}) protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + WindowCompat.enableEdgeToEdge(getWindow()); binding = PdfviewerBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); @@ -334,6 +369,21 @@ protected void onCreate(Bundle savedInstanceState) { // Margins for the toolbar are needed, so that content of the toolbar // is not covered by a system button navigation bar when in landscape. KtUtilsKt.applySystemBarMargins(binding.toolbar, false); + ViewCompat.setOnApplyWindowInsetsListener( + binding.webview, new OnApplyWindowInsetsListener() { + @Override + public @NonNull WindowInsetsCompat onApplyWindowInsets( + @NonNull View v, @NonNull WindowInsetsCompat insets) { + Insets allInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() + | WindowInsetsCompat.Type.displayCutout()); + mInsetLeft = allInsets.left; + mInsetRight = allInsets.right; + // Only set the bottom inset. The top will use the height of the app bar layout + // which includes the status bar/display cutout. + mInsetBottom = allInsets.bottom; + return insets; + } + }); binding.webview.setBackgroundColor(Color.TRANSPARENT); @@ -577,6 +627,8 @@ public void onZoomEnd() { mEncryptedDocumentPassword = savedInstanceState.getString(STATE_ENCRYPTED_DOCUMENT_PASSWORD); } + binding.appBarLayout.addOnLayoutChangeListener(appBarOnLayoutChangeListener); + binding.webviewAlertReload.setOnClickListener(v -> { webViewCrashed = false; recreate(); @@ -609,6 +661,7 @@ private void purgeWebView() { @Override protected void onDestroy() { super.onDestroy(); + binding.appBarLayout.removeOnLayoutChangeListener(appBarOnLayoutChangeListener); purgeWebView(); maybeCloseInputStream(); } diff --git a/app/src/main/res/layout/pdfviewer.xml b/app/src/main/res/layout/pdfviewer.xml index dc73b98af..b1e2bfd3d 100644 --- a/app/src/main/res/layout/pdfviewer.xml +++ b/app/src/main/res/layout/pdfviewer.xml @@ -4,7 +4,13 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + - - document.body.clientHeight; + const isOverflownX = canvas.clientWidth > document.body.clientWidth; + // Translate the text layer to stay aligned with the rendered page including canvas insets and + // grid centering effects. const translate = { - X: Math.max(0, pageWidth - document.body.clientWidth) / 2, - Y: Math.max(0, pageHeight - document.body.clientHeight) / 2 + X: isOverflownX + ? insetLeft - (document.body.clientWidth - pageWidth) / 2 + : (insetLeft - insetRight) / 2, + Y: isOverflownY + ? insetTop - (document.body.clientHeight - pageHeight) / 2 + : (insetTop - insetBottom) / 2 }; layerDiv.style.translate = `${translate.X}px ${translate.Y}px`; } @@ -187,6 +201,13 @@ function renderPage(pageNumber, zoom, prerender, prerenderTrigger = 0) { const newContext = newCanvas.getContext("2d", { alpha: false }); newContext.scale(ratio, ratio); + // Add padding to the canvas to allow the page to be scrolled bellow/above any + // system/app ui that might be visible. + canvas.style.paddingLeft = (channel.getInsetLeft() / ratio) + "px"; + canvas.style.paddingTop = (channel.getInsetTop() / ratio) + "px"; + canvas.style.paddingRight = (channel.getInsetRight() / ratio) + "px"; + canvas.style.paddingBottom = (channel.getInsetBottom() / ratio) + "px"; + task = page.render({ canvasContext: newContext, viewport: newViewport