From 3bcb92f2df838325c995f3caec06c136770368c4 Mon Sep 17 00:00:00 2001 From: chethan616 Date: Thu, 15 Oct 2026 00:06:27 +0530 Subject: [PATCH 1/3] Fix crashes on Android 9/10, organize save, links, text drag; trim margins, book mode, save a copy - #80: glass buttons crashed on Android 12 and older. The press glow's lazy RuntimeShader property cast to a class that only exists on Android 13+. RuntimeShader now lives in an API 33 object. The press transform and tab bar also skip zero sizes, because the NaN scale they produced crashed Android 9's View layers. - #89: organize, merge and extract saved after closing the source document. importPage shares the source's fonts and images, so any PDF with them failed ("COSStream has been closed"). Sources now stay open until the save, and a failed organize removes its empty output. - #88: dragging a placed text box no longer pans the page. A one-finger pan waits for touch slop and leaves alone a drag that a page child consumed. - #83: PDF links work. Web and mail links open in their app, and jumps inside the document scroll to the target page and position. They are read with the form fields in one PdfBox pass. - #85: Settings > Appearance > Trim page margins. One content rect is measured across the document, and pages are laid out wider and clipped, so overlays keep their coordinates. - #84: Settings > Appearance > Book mode. Each page fits the screen. A fling or a sideways swipe turns exactly one page. - #76: Share has a Save a copy action. It writes the chosen format wherever the user picks, and keeps the file's own name when nothing was edited. - The bar ink sampler reads the real page frame, so book mode's letterbox no longer gets dark icons. --- DESIGN.md | 9 +- .../clearpdf/data/repository/Preferences.kt | 8 + .../ui/components/LiquidBottomTabs.kt | 3 +- .../clearpdf/ui/screen/PdfContinuousPage.kt | 47 ++- .../clearpdf/ui/screen/PdfViewerDialogs.kt | 15 +- .../clearpdf/ui/screen/PdfViewerScreen.kt | 341 +++++++++++++----- .../clearpdf/ui/screen/SettingsScreen.kt | 30 ++ .../clearpdf/ui/utils/InteractiveHighlight.kt | 73 ++-- .../ui/viewmodel/PageOrganizerViewModel.kt | 5 + .../ui/viewmodel/PdfViewerViewModel.kt | 92 ++++- app/src/main/res/values-ar/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 7 + app/src/main/res/values-pt/strings.xml | 7 + app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../com/kyant/pdfcore/editor/PdfEditor.kt | 10 +- .../java/com/kyant/pdfcore/link/PdfLinks.kt | 103 ++++++ .../com/kyant/pdfcore/merger/PdfMerger.kt | 18 +- .../com/kyant/pdfcore/splitter/PdfSplitter.kt | 7 +- 21 files changed, 671 insertions(+), 139 deletions(-) create mode 100644 pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt diff --git a/DESIGN.md b/DESIGN.md index d9d40c5..78fac67 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -180,7 +180,14 @@ by hand must do the same. `settle`, `fade` and layout springs never bounce and i is separate from the app theme. A light app can show dark pages and a dark app white ones. - Inside a viewer, **chrome follows the document, never the app theme**: - PDF viewer: each bar samples the luminance behind it (`bandLuminance`, inverted in dark reader) - and picks `chipGlass(onLight)` / `chipInk(onLight)`. + and picks `chipGlass(onLight)` / `chipInk(onLight)`. It reads where the page really is + (`pageFrame`), so book mode's letterbox and trimmed margins count as dark desk, not paper. +- Reading layouts are settings, not new screens: **Trim page margins** shows one document-wide + content rect (`PdfViewerViewModel.contentCrop`), laid out with `trimToContent` so markups, links, + form fields and selection keep their page coordinates; **Book mode** fits one page per screen, + a fling or sideways swipe turns exactly one page. Both stay on the vertical list. +- Gestures on a page (moving a markup, a list box) consume their pointer; the viewer's pan skips + a one-finger drag a page child has claimed. - Word editor and spreadsheet: the screen provides `LocalIsDarkMode = reader mode` to everything inside it and draws its own desk (light grey `#E3E4E9`, near-black `#0D0D0F`) behind the pages, so every glass control, dropdown menu, dock, dialog and the status-bar icons follow the paper. diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt index 099edba..9f555e2 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/Preferences.kt @@ -188,6 +188,14 @@ object AppSettingsManager { fun setInvertShareMorph(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("invert_share_morph", enabled).apply() fun setAutoDarkPages(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean(KEY_AUTO_DARK_PAGES, enabled).apply() + /** The viewer trims the empty border around pages, so the text fills the screen (#85). */ + fun getTrimMargins(context: Context): Boolean = prefs(context).getBoolean("trim_page_margins", false) + fun setTrimMargins(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("trim_page_margins", enabled).apply() + + /** Book mode: one page per screen, a sideways swipe turns the page (#84). */ + fun getBookMode(context: Context): Boolean = prefs(context).getBoolean("book_mode", false) + fun setBookMode(context: Context, enabled: Boolean) = prefs(context).edit().putBoolean("book_mode", enabled).apply() + fun getReduceGlassMotion(context: Context): Boolean = prefs(context).getBoolean(KEY_REDUCE_GLASS_MOTION, false) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt index 52b44d7..99a7dc4 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/LiquidBottomTabs.kt @@ -184,8 +184,9 @@ fun LiquidBottomTabs( }, highlight = { glassHighlight(GlassSettings.style) }, layerBlock = { + // No width yet: 16 dp / 0 would be NaN, a crash on Android 9 (#80). val progress = dampedDragAnimation.pressProgress - val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) + val scale = if (size.width <= 0f) 1f else lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) scaleX = scale scaleY = scale }, diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt index 8ed9fc4..d1ffb8a 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfContinuousPage.kt @@ -77,6 +77,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.layout +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions @@ -125,6 +128,9 @@ internal fun PdfContinuousPage( page: Int, backdrop: LayerBackdrop, bitmap: Bitmap?, + modifier: Modifier = Modifier, + /** Trim page margins (#85): the 0..1 part of the page to show; the rest is laid out but clipped. */ + crop: android.graphics.RectF? = null, darkPageAppearance: Boolean = false, /** Dark reader: photographs on this page (normalised rects), drawn as printed over the night page. */ nightPhotos: List = emptyList(), @@ -145,6 +151,9 @@ internal fun PdfContinuousPage( onTapFormWidget: (com.kyant.pdfcore.form.PdfFormWidget, Rect) -> Unit = { _, _ -> }, onFormTextChange: (com.kyant.pdfcore.form.PdfFormWidget, String) -> Unit = { _, _ -> }, onFormImeNext: (com.kyant.pdfcore.form.PdfFormWidget) -> Unit = {}, + /** The PDF's own links on this page; a tap on one opens it (#83). */ + links: List = emptyList(), + onOpenLink: (com.kyant.pdfcore.link.PdfLink) -> Unit = {}, ocrBlocks: List, findMatches: List, currentMatchIndex: Int, @@ -193,6 +202,8 @@ internal fun PdfContinuousPage( val latestFormValues by rememberUpdatedState(formValues) val latestOnTapFormWidget by rememberUpdatedState(onTapFormWidget) val latestOnFormValue by rememberUpdatedState(onFormTextChange) + val latestLinks by rememberUpdatedState(links) + val latestOnOpenLink by rememberUpdatedState(onOpenLink) // List boxes scroll inside their own box; offsets in rows, per field box. val formListScroll = remember { androidx.compose.runtime.mutableStateMapOf() } // This page's coordinates, to place a dropdown's menu under the field in window space. @@ -233,12 +244,14 @@ internal fun PdfContinuousPage( } Box( - Modifier + modifier // While an image is dragged across a page boundary, this page draws above its // neighbours so the image isn't hidden behind the next page. .zIndex(if (imageDragging) 1f else 0f) .fillMaxWidth() .padding(vertical = 6.dp) + // The placeholder is trimmed too, so the page keeps its height when the bitmap lands. + .then(if (crop != null) Modifier.trimToContent(crop) else Modifier) .then(if (bitmap == null) Modifier.aspectRatio(placeholderAspect ?: (1f / 1.414f)) else Modifier) .background(Color(0xFF15181E)) // Page-local coordinates for the text selection's screen mapping (see PdfViewportTransform). @@ -660,6 +673,20 @@ internal fun PdfContinuousPage( onShowControls() onInteraction() } + } else if (currentSelected < 0 && currentGroup.isEmpty() && latestLinks.isNotEmpty()) { + // A link printed on the page: a tap (not a drag, not a long-press) follows it. + val nx = down.position.x / size.width.toFloat() + val ny = down.position.y / size.height.toFloat() + val slop = 4f / size.width.toFloat() + val link = latestLinks.lastOrNull { l -> + nx >= l.left - slop && nx <= l.right + slop && ny >= l.top - slop && ny <= l.bottom + slop + } ?: return@awaitEachGesture + val up = waitForUpOrCancellation() ?: return@awaitEachGesture + // A zoomed pan doesn't consume, so tell a pan that ended on the link apart from a tap. + if ((up.position - down.position).getDistance() > viewConfiguration.touchSlop) return@awaitEachGesture + up.consume() + latestOnOpenLink(link) + onInteraction() } else { // Missed everything → clear any selection (tap propagates to container). if (currentSelected >= 0) onSelectMarkup(-1) @@ -1246,3 +1273,21 @@ private fun MarkupBarItem( if (!iconOnly) BasicText(label, style = TextStyle(color, 14.sp, FontWeight.SemiBold), maxLines = 1) } } + +/** + * Trim page margins (#85): the page is laid out larger, so [crop] spans the available width, and + * only that part is shown. Everything inside keeps its own page coordinates, so markups, links, + * form fields and the text selection stay where they are printed. + */ +private fun Modifier.trimToContent(crop: android.graphics.RectF): Modifier = clipToBounds().layout { measurable, constraints -> + if (!constraints.hasBoundedWidth || crop.width() <= 0f || crop.height() <= 0f) { + val p = measurable.measure(constraints) + return@layout layout(p.width, p.height) { p.place(0, 0) } + } + val width = constraints.maxWidth + val inner = (width / crop.width()).roundToInt() + val p = measurable.measure(Constraints(minWidth = inner, maxWidth = inner)) + layout(width, (p.height * crop.height()).roundToInt()) { + p.place(-(crop.left * inner).roundToInt(), -(crop.top * p.height).roundToInt()) + } +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt index b458fd5..7f5b954 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerDialogs.kt @@ -384,7 +384,9 @@ internal fun ExportShareDialog( onDismiss: () -> Unit, onShare: (format: ShareFormat, encrypt: Boolean, password: String) -> Unit, /** Prints the document exactly as it is, through Android's print screen (after Gander). */ - onPrint: (() -> Unit)? = null + onPrint: (() -> Unit)? = null, + /** Saves the chosen format to a place the user picks, edited or not (#76). */ + onSaveCopy: ((format: ShareFormat, encrypt: Boolean, password: String) -> Unit)? = null ) { val pdfOnly = originalExt == null // Keyed on `visible` so every open starts fresh. @@ -474,6 +476,17 @@ internal fun ExportShareDialog( } } } + if (onSaveCopy != null) { + // Page images are a set of files; everything else saves as one. + val canSave = canShare && format != ShareFormat.IMAGES + Row(Modifier.fillMaxWidth().padding(top = 4.dp)) { + GlassDialogAction( + stringResource(R.string.viewer_save_copy), + { if (canSave) onSaveCopy(format, encrypt && pdfSelected, password) }, + enabled = canSave + ) + } + } } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt index be31501..d311dc5 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.derivedStateOf import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.width import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring @@ -247,6 +249,13 @@ fun PdfViewerScreen( // Only the moon button's choice is saved; until it is used the pages follow the theme, so a // first frame drawn before the theme setting applies can't freeze the wrong colours. var darkPageOverride by rememberSaveable { mutableStateOf(null) } + // Settings > Appearance: trim page margins (#85) and book mode (#84), read once per document. + val trimMargins = remember { com.chethan616.clearpdf.data.repository.AppSettingsManager.getTrimMargins(context) } + val bookMode = remember { com.chethan616.clearpdf.data.repository.AppSettingsManager.getBookMode(context) } + var contentCrop by remember { mutableStateOf(null) } + LaunchedEffect(state.document?.uri, state.pageCount) { + contentCrop = if (trimMargins && state.document != null) viewModel.contentCrop() else null + } val darkPageAppearance = darkPageOverride ?: autoDarkPages var bookmarkedPages by remember { mutableStateOf>(emptyList()) } // Annotation (text box / sticky note) editing @@ -432,6 +441,14 @@ fun PdfViewerScreen( // glides longer and settles smoothly (vs the stiffer platform spline), so even 2–3 page // docs feel continuous instead of stopping abruptly. Only the fling curve changes — the // scroll mechanics and the default stretch overscroll (rubber-band at the edges) are intact. + // Book mode (#84): a fling turns exactly one page, however hard, like a page of a book. + val bookSnap = remember(listState) { + val base = androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider(listState) + object : androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider by base { + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f + } + } + val bookFling = androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior(bookSnap) val iosPageDecay = remember { androidx.compose.animation.core.exponentialDecay(frictionMultiplier = 0.55f) } val pageFling = remember(iosPageDecay) { object : androidx.compose.foundation.gestures.FlingBehavior { @@ -845,6 +862,27 @@ fun PdfViewerScreen( val offset = if (canvas != null) (w.top * canvas.height - viewport * 0.22f).toInt() else 0 runCatching { listState.animateScrollToItem(w.pageIndex, offset.coerceAtLeast(0)) } } + // A link on the page (#83): a jump inside the document scrolls there; a web or mail address + // goes to the app that handles it. Other schemes (javascript:, file:, intent:) are ignored. + val onOpenLink: (com.kyant.pdfcore.link.PdfLink) -> Unit = { link -> + val target = link.targetPage + if (target != null) { + val canvas = pageCanvasSizes[target] + val top = link.targetTop + val offset = if (canvas != null && top != null) (top * canvas.height).toInt() else 0 + viewerScope.launch { + runCatching { listState.animateScrollToItem(target.coerceIn(0, safePageCount - 1), offset.coerceAtLeast(0)) } + } + } else link.uri?.let { raw -> + val withScheme = if (Regex("^[a-zA-Z][a-zA-Z0-9+.-]*:").containsMatchIn(raw)) raw else "https://$raw" + val parsed = android.net.Uri.parse(withScheme) + if (parsed.scheme?.lowercase() in setOf("http", "https", "mailto", "tel")) { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, parsed).addCategory(Intent.CATEGORY_BROWSABLE)) + } + } + } + } // A dropdown field's open menu, and its field's box in window space. var formMenu by remember { mutableStateOf(null) } var formMenuAnchor by remember { mutableStateOf(androidx.compose.ui.geometry.Rect.Zero) } @@ -1002,7 +1040,9 @@ fun PdfViewerScreen( .fillMaxSize() .background(if (isLight) Color(0xFF0A0E14).copy(0.85f) else Color(0xFF020305).copy(0.88f)) ) { - val renderWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) + // Trimmed margins lay the page out wider than the screen, so it renders wider too. + val renderWidthPx = (with(LocalDensity.current) { maxWidth.roundToPx() }.coerceAtLeast(720) / + (contentCrop?.width() ?: 1f)).roundToInt().coerceAtMost(4096) // Re-render the focused page after zoom settles. Scaling a screen-sized bitmap makes PDF // text and fine diagrams soft; a bounded higher-resolution page keeps pinch zoom readable @@ -1071,6 +1111,19 @@ fun PdfViewerScreen( } } + // Book mode / trimmed margins: where each page really sits, for the ink sampling below. + val pageFrameOf: (Int) -> FloatArray? = { p -> + val c = contentCrop + val o = textSelection.pageOrigin(p) + val sz = textSelection.pageSize(p) + when { + !bookMode && c == null -> null + // Not placed yet: a book-mode page sits inside letterbox, so count nothing for now. + o == null || sz == null || sz.height <= 0f -> if (bookMode) floatArrayOf(0f, 1f, 0f, 0f) else null + else -> floatArrayOf(o.y, sz.height, o.y + (c?.top ?: 0f) * sz.height, o.y + (c?.bottom ?: 1f) * sz.height) + } + } + // ── Adaptive control ink, sampled per bar (Apple-style) ────────────────────────────── // Each bar reads the luminance of the content *directly behind it*, so on a page that is a // dark image up top and a white canvas below, the top bar goes white while the bottom toolbar @@ -1089,7 +1142,7 @@ fun PdfViewerScreen( LaunchedEffect(containerHeightPx, darkPageAppearance) { var firstSample = true snapshotFlow { - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, 0f, topBandBottomPx, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, 0f, topBandBottomPx, darkPageAppearance, pageFrame = pageFrameOf) }.collect { lum -> if (firstSample) { topBarLight = lum > 0.60f @@ -1102,7 +1155,7 @@ fun PdfViewerScreen( snapshotFlow { val h = containerHeightPx.toFloat() if (h <= 0f) 0f - else bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, h - bottomBandDepthPx, h, darkPageAppearance) + else bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, h - bottomBandDepthPx, h, darkPageAppearance, pageFrame = pageFrameOf) }.collect { lum -> if (firstSample) { bottomBarLight = lum > 0.60f @@ -1136,7 +1189,7 @@ fun PdfViewerScreen( if (h <= 0f) 0f else { val bottom = (h - imeInset.getBottom(density)).coerceAtLeast(bottomBandDepthPx) - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, bottom - bottomBandDepthPx, bottom, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, bottom - bottomBandDepthPx, bottom, darkPageAppearance, pageFrame = pageFrameOf) } }.collect { lum -> if (lum > 0.62f) findBarLight = true else if (lum < 0.58f) findBarLight = false @@ -1159,6 +1212,29 @@ fun PdfViewerScreen( Modifier .fillMaxSize() .onSizeChanged { containerHeightPx = it.height; containerWidthPx = it.width } + // Book mode (#84): a sideways swipe turns the page. Outside the pan handler, so a + // zoomed-in pan still sees the finger first; a markup drag consumes it first. + .then( + if (!bookMode || activeTool != PdfEditTool.None) Modifier + else Modifier.pointerInput(state.pageCount) { + var dx = 0f + detectHorizontalDragGestures( + onDragStart = { dx = 0f }, + onHorizontalDrag = { _, amount -> dx += amount }, + onDragEnd = { + if (scale <= 1.01f && !textSelection.gestureActive && kotlin.math.abs(dx) > 48.dp.toPx()) { + val info = listState.layoutInfo + val shown = info.visibleItemsInfo + .minByOrNull { kotlin.math.abs(it.offset + it.size / 2 - info.viewportEndOffset / 2) } + ?.index ?: listState.firstVisibleItemIndex + val target = (shown + if (dx < 0f) 1 else -1).coerceIn(0, safePageCount - 1) + viewerScope.launch { listState.animateScrollToItem(target) } + lastInteractionAtMs = System.currentTimeMillis() + } + } + ) + } + ) // Long-press → select word. First in the chain so, once it fires, it can consume // in the Initial pass ahead of the zoom/tap handlers and the page list. .pdfTextSelectionGestures( @@ -1176,18 +1252,32 @@ fun PdfViewerScreen( awaitEachGesture { awaitFirstDown(requireUnconsumed = false) var accumulatedPan = Offset.Zero + // A one-finger pan only starts past touch slop, and a drag a page + // child claimed first (moving or resizing a markup, an image) keeps + // the finger for the whole gesture: the page stays put under it (#88). + var childOwnsDrag = false + var panStarted = false do { val event = awaitPointerEvent() // A selection drag owns the finger: no one-finger pan underneath it. if (textSelection.gestureActive) continue + val oneFinger = event.changes.count { it.pressed } <= 1 + if (oneFinger && event.changes.any { it.isConsumed }) childOwnsDrag = true + if (childOwnsDrag && oneFinger) continue val zoomChange = event.calculateZoom() - val panChange = event.calculatePan() + var panChange = event.calculatePan() accumulatedPan += panChange // A tap may hide the chrome, but must not stop reading. Stop only // after a real drag or pinch takes ownership of the page. if (autoScroll && (zoomChange != 1f || accumulatedPan.getDistance() > viewConfiguration.touchSlop)) { autoScroll = false } + if (!panStarted) { + if (oneFinger && zoomChange == 1f && accumulatedPan.getDistance() <= viewConfiguration.touchSlop) continue + // Catch up on the movement held back while waiting for slop. + panStarted = true + panChange = accumulatedPan + } val zoomed = scale > 1.001f if (zoomChange != 1f || zoomed) { val cw = size.width.toFloat() @@ -1273,7 +1363,7 @@ fun PdfViewerScreen( ) { LazyColumn( state = listState, - flingBehavior = pageFling, + flingBehavior = if (bookMode) bookFling else pageFling, userScrollEnabled = activeTool == PdfEditTool.None && scale <= 1.01f, modifier = Modifier.fillMaxSize(), // Center the block when it's shorter than the viewport (e.g. a @@ -1281,9 +1371,22 @@ fun PdfViewerScreen( // the top with a dark blank half below. No effect once content // overflows (multi-page / zoomed), where it scrolls normally. verticalArrangement = Arrangement.Center, - contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 4.dp, bottom = 4.dp + extraBottomPadding) + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = if (bookMode) 0.dp else 4.dp, + bottom = (if (bookMode) 0.dp else 4.dp) + extraBottomPadding + ) ) { items(count = safePageCount, key = { it }, contentType = { "pdfPage" }) { page -> + // Book mode (#84): each page fits the screen, centred in a screen-tall slot. + val bookSlot = if (bookMode) Modifier.fillParentMaxSize() else null + val bookPage = if (!bookMode) Modifier else { + val sz = pageBitmapSizes[page] ?: pageBitmapSizes.values.firstOrNull { it.width > 0f && it.height > 0f } + val aspect = sz?.takeIf { it.height > 0f } + ?.let { (it.width * (contentCrop?.width() ?: 1f)) / (it.height * (contentCrop?.height() ?: 1f)) } + val gap = with(density) { 12.dp.toPx() } + if (aspect == null || containerHeightPx <= 0 || containerWidthPx <= 0) Modifier + else Modifier.width(with(density) { minOf(containerWidthPx.toFloat(), (containerHeightPx - gap) * aspect).toDp() }) + } // Re-request whenever this page has no bitmap (covers cache eviction // while the item stays composed in the lazy list). val needsRender = state.pageBitmaps.getOrNull(page) == null @@ -1294,10 +1397,13 @@ fun PdfViewerScreen( LaunchedEffect(page, darkPageAppearance, needsRender) { if (darkPageAppearance && !needsRender) viewModel.ensureNightPhotos(context, page) } + OptionalSlot(bookSlot) { PdfContinuousPage( page = page, backdrop = backdrop, bitmap = state.pageBitmaps.getOrNull(page), + modifier = bookPage, + crop = contentCrop, darkPageAppearance = darkPageAppearance, nightPhotos = if (darkPageAppearance) viewModel.nightPhotos[page].orEmpty() else emptyList(), marks = getPageMarks(page), @@ -1318,6 +1424,8 @@ fun PdfViewerScreen( val i = formOrder.indexOfFirst { it.key == w.key } focusedForm = formOrder.getOrNull(i + 1) }, + links = state.linksByPage[page].orEmpty(), + onOpenLink = onOpenLink, ocrBlocks = state.ocrBlocksByPage[page].orEmpty(), findMatches = state.findMatches, currentMatchIndex = state.currentMatchIndex, @@ -1467,6 +1575,7 @@ fun PdfViewerScreen( } } ) + } } } } @@ -2032,7 +2141,7 @@ fun PdfViewerScreen( // Floating contextual chrome, in the title-chip glass over the live page, with ink sampled from // the document behind each control (same band sampling as the bars). val pageLuminanceAt: (Float, Float) -> Float = { top, bottom -> - bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, top, bottom, darkPageAppearance) + bandLuminance(listState.layoutInfo, state.pageBitmaps, scale, top, bottom, darkPageAppearance, pageFrame = pageFrameOf) } PdfMarkupBarLayer( host = markupBarHost, @@ -2282,6 +2391,104 @@ fun PdfViewerScreen( // Original vs PDF (with optional encryption). `originalExt` is null for a plain PDF, which // collapses the dialog to just the encrypt toggle. val shareExt = state.fileName.substringAfterLast('.', "").uppercase() + // The file a Share or a Save a copy hands over, built off the main thread: copy/encrypt into + // cacheDir/shared, which the app FileProvider serves (file_paths.xml cache-path "/"). + fun buildSharePayload(format: ShareFormat, encrypt: Boolean, password: String): Pair? = + runCatching { + fun wrap(u: android.net.Uri): android.net.Uri = + if (u.scheme == "file") + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", java.io.File(u.path!!) + ) + else u + val shareDir = java.io.File(context.cacheDir, "shared").apply { mkdirs() } + // Unsaved edits travel with the share: build an edited copy first and + // make every PDF-derived format (PDF, images, text) from it. + val edited: android.net.Uri? = if (hasUnsavedEdits && format != ShareFormat.ORIGINAL) { + val overlays = buildExportOverlays(annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes) + val name = state.fileName.substringBeforeLast('.').ifBlank { "document" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + viewModel.writeEditedCopy(context, overlays, java.io.File(shareDir, "${name}_edited.pdf")) + } else null + val sourcePdf = edited ?: state.document?.uri + when (format) { + ShareFormat.IMAGES -> sourcePdf?.let { pdf -> + val stem = state.fileName.substringBeforeLast('.').ifBlank { "page" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val count = state.pageCount.coerceAtLeast(1) + val uris = ArrayList() + for (p in 0 until count) { + val bmp = com.kyant.pdfcore.raster.PdfRasterizer.rasterizePageBitmap(context, pdf, p) ?: continue + val f = java.io.File(shareDir, "${stem}_${p + 1}.png") + f.outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + bmp.recycle() + uris += androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) + } + shareMultiple = uris + uris.firstOrNull()?.let { it to "image/png" } + } + ShareFormat.TEXT -> sourcePdf?.let { pdf -> + val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val f = java.io.File(shareDir, "$stem.txt") + f.writeText(viewModel.extractAllText(context, pdf)) + androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) to "text/plain" + } + ShareFormat.ORIGINAL -> state.originalUri?.let { orig -> + // Mirror the original into our own storage so the target app can + // actually read it — a SAF uri from another provider can't be + // re-granted to a third app. + val safeName = state.fileName.ifBlank { "document.$shareExt" } + .replace(Regex("[^A-Za-z0-9._-]"), "_") + val out = java.io.File(shareDir, "${System.currentTimeMillis()}_$safeName") + context.contentResolver.openInputStream(orig)?.use { input -> + out.outputStream().use { input.copyTo(it) } + } ?: return@runCatching null + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) to (context.contentResolver.getType(orig) ?: "application/octet-stream") + } + ShareFormat.PDF -> sourcePdf?.let { pdf -> + if (encrypt && password.isNotBlank()) { + val out = java.io.File(shareDir, "protected_${System.currentTimeMillis()}.pdf") + val outUri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", out + ) + com.kyant.pdfcore.security.PdfSecurityService.encryptToUri(context, pdf, outUri, password) + outUri to "application/pdf" + } else { + wrap(pdf) to "application/pdf" + } + } + } + }.getOrNull() + // Save a copy (#76): the file the user picked, as it is (or as the chosen format), to a + // place they choose. The format etc. wait here while the system save screen is up. + var pendingSaveCopy by remember { mutableStateOf?>(null) } + val saveCopyLauncher = rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult() + ) { res -> + val target = res.data?.data + val req = pendingSaveCopy + pendingSaveCopy = null + if (res.resultCode != android.app.Activity.RESULT_OK || target == null || req == null) return@rememberLauncherForActivityResult + viewerScope.launch { + val ok = withContext(Dispatchers.IO) { + runCatching { + val (src, _) = buildSharePayload(req.first, req.second, req.third) ?: return@runCatching false + shareMultiple = emptyList() + context.contentResolver.openInputStream(src)?.use { input -> + context.contentResolver.openOutputStream(target, "w")?.use { input.copyTo(it) } + } != null + }.getOrDefault(false) + } + android.widget.Toast.makeText( + context, + if (ok) R.string.viewer_save_copy_done else R.string.viewer_save_copy_failed, + android.widget.Toast.LENGTH_SHORT + ).show() + } + } ExportShareDialog( visible = showShareDialog, originalExt = if (shareExt.isNotBlank() && shareExt != "PDF") shareExt else null, @@ -2294,82 +2501,30 @@ fun PdfViewerScreen( android.widget.Toast.makeText(context, R.string.docx_print_failed, android.widget.Toast.LENGTH_SHORT).show() } }, + onSaveCopy = { format, encrypt, password -> + showShareDialog = false + // Unedited, a copy keeps the file's own name (no "_Edited"). + val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } + val (name, mime) = when (format) { + ShareFormat.ORIGINAL -> state.fileName.ifBlank { "document.${shareExt.lowercase()}" } to + (state.originalUri?.let { context.contentResolver.getType(it) } ?: "application/octet-stream") + ShareFormat.TEXT -> "$stem.txt" to "text/plain" + else -> (if (hasUnsavedEdits) "${stem}_Edited.pdf" else "$stem.pdf") to "application/pdf" + } + pendingSaveCopy = Triple(format, encrypt, password) + runCatching { + saveCopyLauncher.launch( + Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mime) + .putExtra(Intent.EXTRA_TITLE, name) + ) + }.onFailure { pendingSaveCopy = null } + }, onShare = { format, encrypt, password -> showShareDialog = false viewerScope.launch { - // All file work off the main thread: copy/encrypt, then hand a FileProvider uri to - // the chooser. Everything lands in cacheDir/shared, which the app FileProvider - // serves (file_paths.xml cache-path "/"). - val payload = withContext(Dispatchers.IO) { - runCatching { - fun wrap(u: android.net.Uri): android.net.Uri = - if (u.scheme == "file") - androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", java.io.File(u.path!!) - ) - else u - val shareDir = java.io.File(context.cacheDir, "shared").apply { mkdirs() } - // Unsaved edits travel with the share: build an edited copy first and - // make every PDF-derived format (PDF, images, text) from it. - val edited: android.net.Uri? = if (hasUnsavedEdits && format != ShareFormat.ORIGINAL) { - val overlays = buildExportOverlays(annotationsByPage, state.ocrBlocksByPage, pageCanvasSizes, pageBitmapSizes) - val name = state.fileName.substringBeforeLast('.').ifBlank { "document" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - viewModel.writeEditedCopy(context, overlays, java.io.File(shareDir, "${name}_edited.pdf")) - } else null - val sourcePdf = edited ?: state.document?.uri - when (format) { - ShareFormat.IMAGES -> sourcePdf?.let { pdf -> - val stem = state.fileName.substringBeforeLast('.').ifBlank { "page" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val count = state.pageCount.coerceAtLeast(1) - val uris = ArrayList() - for (p in 0 until count) { - val bmp = com.kyant.pdfcore.raster.PdfRasterizer.rasterizePageBitmap(context, pdf, p) ?: continue - val f = java.io.File(shareDir, "${stem}_${p + 1}.png") - f.outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } - bmp.recycle() - uris += androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) - } - shareMultiple = uris - uris.firstOrNull()?.let { it to "image/png" } - } - ShareFormat.TEXT -> sourcePdf?.let { pdf -> - val stem = state.fileName.substringBeforeLast('.').ifBlank { "document" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val f = java.io.File(shareDir, "$stem.txt") - f.writeText(viewModel.extractAllText(context, pdf)) - androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.provider", f) to "text/plain" - } - ShareFormat.ORIGINAL -> state.originalUri?.let { orig -> - // Mirror the original into our own storage so the target app can - // actually read it — a SAF uri from another provider can't be - // re-granted to a third app. - val safeName = state.fileName.ifBlank { "document.$shareExt" } - .replace(Regex("[^A-Za-z0-9._-]"), "_") - val out = java.io.File(shareDir, "${System.currentTimeMillis()}_$safeName") - context.contentResolver.openInputStream(orig)?.use { input -> - out.outputStream().use { input.copyTo(it) } - } ?: return@runCatching null - androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", out - ) to (context.contentResolver.getType(orig) ?: "application/octet-stream") - } - ShareFormat.PDF -> sourcePdf?.let { pdf -> - if (encrypt && password.isNotBlank()) { - val out = java.io.File(shareDir, "protected_${System.currentTimeMillis()}.pdf") - val outUri = androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.provider", out - ) - com.kyant.pdfcore.security.PdfSecurityService.encryptToUri(context, pdf, outUri, password) - outUri to "application/pdf" - } else { - wrap(pdf) to "application/pdf" - } - } - } - }.getOrNull() - } + val payload = withContext(Dispatchers.IO) { buildSharePayload(format, encrypt, password) } if (payload != null) { val (shareUri, mime) = payload val many = shareMultiple @@ -2539,7 +2694,11 @@ private fun bandLuminance( // Dark page appearance draws every page through an RGB-inverting colour matrix, so the pixels // on screen are 1 - (bitmap). Sampling the bitmap as-is read a white page behind a black one and // picked dark ink — the "icons invisible in dark mode" bug. Mirror the matrix here. - inverted: Boolean = false + inverted: Boolean = false, + // Where a page really is, when it doesn't fill its list item: (bitmap frame top, frame height, + // visible top, visible bottom) in the same list-local px. Book mode letterboxes the page inside + // a screen-tall item, and trimmed margins show only part of a larger frame (#84, #85). + pageFrame: (Int) -> FloatArray? = { null } ): Float { val s = scale.coerceAtLeast(0.01f) val lTop = screenTopPx / s @@ -2548,15 +2707,19 @@ private fun bandLuminance( var weight = 0.0 for (item in layoutInfo.visibleItemsInfo) { if (item.size <= 0) continue - val io = item.offset.toFloat() - val ib = io + item.size.toFloat() + val f = pageFrame(item.index) + val frameTop = f?.get(0) ?: item.offset.toFloat() + val frameH = f?.get(1) ?: item.size.toFloat() + if (frameH <= 0f) continue + val io = f?.get(2) ?: frameTop + val ib = f?.get(3) ?: (frameTop + frameH) val interTop = maxOf(io, lTop) val interBottom = minOf(ib, lBottom) val cover = interBottom - interTop if (cover <= 0f) continue val bmp = pageBitmaps.getOrNull(item.index) ?: continue - val fTop = (interTop - io) / item.size - val fBottom = (interBottom - io) / item.size + val fTop = (interTop - frameTop) / frameH + val fBottom = (interBottom - frameTop) / frameH val l = regionLuminance(bmp, fTop, fBottom) lum += (if (inverted) 1f - l else l).toDouble() * cover weight += cover.toDouble() @@ -2598,3 +2761,9 @@ internal fun OcrTextRange.snappedToWords(text: String): OcrTextRange { while (b < text.length && inWord(text[b]) && inWord(text[b - 1])) b++ return copy(start = a, end = b) } + +/** [content] centred in a box with [slot] when there is one, otherwise placed as it is. */ +@Composable +private fun OptionalSlot(slot: Modifier?, content: @Composable () -> Unit) { + if (slot != null) Box(slot, contentAlignment = Alignment.Center) { content() } else content() +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt index 1b552ab..a993423 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SettingsScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Compress import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.Crop +import androidx.compose.material.icons.rounded.AutoStories import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.FileCopy import androidx.compose.material.icons.rounded.FolderOpen @@ -347,6 +349,34 @@ fun SettingsScreen( backdrop = backdrop ) SettingsDivider(isLight) + // #85: page margins trimmed in the viewer, so the text fills the screen. + var trimMargins by remember { mutableStateOf(com.chethan616.clearpdf.data.repository.AppSettingsManager.getTrimMargins(context)) } + SettingsToggleRow( + icon = Icons.Rounded.Crop, + title = stringResource(R.string.settings_trim_margins), + desc = stringResource(R.string.settings_trim_margins_desc), + checked = trimMargins, + onCheckedChange = { + trimMargins = it + com.chethan616.clearpdf.data.repository.AppSettingsManager.setTrimMargins(context, it) + }, + backdrop = backdrop + ) + SettingsDivider(isLight) + // #84: one page per screen, turned with a sideways swipe. + var bookMode by remember { mutableStateOf(com.chethan616.clearpdf.data.repository.AppSettingsManager.getBookMode(context)) } + SettingsToggleRow( + icon = Icons.Rounded.AutoStories, + title = stringResource(R.string.settings_book_mode), + desc = stringResource(R.string.settings_book_mode_desc), + checked = bookMode, + onCheckedChange = { + bookMode = it + com.chethan616.clearpdf.data.repository.AppSettingsManager.setBookMode(context, it) + }, + backdrop = backdrop + ) + SettingsDivider(isLight) SettingsToggleRow( icon = Icons.Rounded.Vibration, title = stringResource(R.string.settings_reduce_glass_motion), diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt index f40a213..8b5e3c0 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/utils/InteractiveHighlight.kt @@ -2,7 +2,9 @@ package com.chethan616.clearpdf.ui.utils import com.chethan616.clearpdf.ui.components.MotionSettings import android.graphics.RuntimeShader +import android.graphics.Shader import android.os.Build +import androidx.annotation.RequiresApi import android.os.SystemClock import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.VectorConverter @@ -90,24 +92,10 @@ class InteractiveHighlight( // owns one of these, and `RuntimeShader(...)` compiles its AGSL on the calling thread — so a // screen of buttons used to pay one shader compile per button during composition (and a list // paid it again for every row it scrolled in), for a highlight most buttons never show. - private val shader: RuntimeShader? by lazy(LazyThreadSafetyMode.NONE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ -uniform float2 size; -layout(color) uniform half4 color; -uniform float radius; -uniform float2 position; - -half4 main(float2 coord) { - float dist = distance(coord, position); - float intensity = smoothstep(radius, radius * 0.5, dist); - return color * intensity; -}""" - ) - } else { - null - } + // Typed Shader, not RuntimeShader: reading a RuntimeShader property casts to that class, which + // doesn't exist before Android 13, so every press crashed there (#80). Only PressGlow touches it. + private val shader: Shader? by lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) PressGlow.create() else null } val modifier: Modifier = @@ -119,17 +107,14 @@ half4 main(float2 coord) { val shader = this@InteractiveHighlight.shader if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && shader != null) { drawRect(Color.White.copy(0.08f * progress + 0.05f * bloom), blendMode = BlendMode.Plus) - shader.apply { - val position = position(size, positionAnimation.value) - setFloatUniform("size", size.width, size.height) - setColorUniform("color", Color.White.copy(0.15f * progress + 0.10f * bloom).toArgb()) - setFloatUniform("radius", size.minDimension * (1.5f + 0.5f * bloom)) - setFloatUniform( - "position", - position.x.fastCoerceIn(0f, size.width), - position.y.fastCoerceIn(0f, size.height) - ) - } + val position = position(size, positionAnimation.value) + PressGlow.update( + shader, + size, + Color.White.copy(0.15f * progress + 0.10f * bloom).toArgb(), + size.minDimension * (1.5f + 0.5f * bloom), + Offset(position.x.fastCoerceIn(0f, size.width), position.y.fastCoerceIn(0f, size.height)) + ) drawRect(ShaderBrush(shader), blendMode = BlendMode.Plus) } else { drawRect(Color.White.copy(0.25f * progress), blendMode = BlendMode.Plus) @@ -138,6 +123,33 @@ half4 main(float2 coord) { drawContent() } + /** Everything that names RuntimeShader, kept out of code that runs on older Android. */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private object PressGlow { + fun create(): Shader = RuntimeShader( + """ +uniform float2 size; +layout(color) uniform half4 color; +uniform float radius; +uniform float2 position; + +half4 main(float2 coord) { + float dist = distance(coord, position); + float intensity = smoothstep(radius, radius * 0.5, dist); + return color * intensity; +}""" + ) + + fun update(shader: Shader, size: Size, color: Int, radius: Float, position: Offset) { + (shader as RuntimeShader).apply { + setFloatUniform("size", size.width, size.height) + setColorUniform("color", color) + setFloatUniform("radius", radius) + setFloatUniform("position", position.x, position.y) + } + } + } + private fun release() { positionUpdateJob?.cancel() longPressJob?.cancel() @@ -215,6 +227,9 @@ half4 main(float2 coord) { fun GraphicsLayerScope.liquidPressTransform(h: InteractiveHighlight) { val width = size.width val height = size.height + // A control not laid out yet (or collapsed to nothing) has no size: dividing by it gives NaN, + // which Android 9's View-backed layers reject with a crash (#80). + if (!(width > 0f && height > 0f)) return val motion = MotionSettings.style val progress = h.pressProgress diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt index 057165a..af91649 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PageOrganizerViewModel.kt @@ -165,14 +165,17 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { if (pages.isEmpty()) return _uiState.value = _uiState.value.copy(isSaving = true, errorMessage = null, resultMessage = null, lastOutputUri = null) viewModelScope.launch { + var created: Uri? = null try { val target = if (fileName.endsWith(".pdf", true)) fileName else "$fileName.pdf" val saveLabel = SaveLocationManager.getSavePathDisplay(context) val outUri = withContext(Dispatchers.IO) { createOutputUri(context, target, overrideUri) } + created = outUri val source = PdfDocument(uri = src, name = _uiState.value.sourceFileName) val order = pages.map { it.originalIndex } val rotations = pages.associate { it.originalIndex to it.rotation }.filterValues { it != 0 } withContext(Dispatchers.IO) { editor.applyPageEdits(context, source, order, rotations, outUri) } + created = null withContext(Dispatchers.IO) { RecentFilesManager.addRecent(context, RecentFile( @@ -193,6 +196,8 @@ class PageOrganizerViewModel(private val editor: PdfEditor) : ViewModel() { val shouldPrompt = withContext(Dispatchers.IO) { GitHubStarPromptManager.recordSuccess(context) } if (shouldPrompt) StarPromptEventBus.requestPrompt() } catch (e: Exception) { + // Don't leave an empty PDF behind in Downloads. + created?.let { uri -> withContext(Dispatchers.IO) { runCatching { context.contentResolver.delete(uri, null, null) } } } GitHubStarPromptManager.recordFailure(context) _uiState.value = _uiState.value.copy(isSaving = false, errorMessage = context.getString(R.string.organize_save_failed)) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt index d67d03a..f62cf00 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/viewmodel/PdfViewerViewModel.kt @@ -184,6 +184,8 @@ data class PdfViewerUiState( val ocrBlocksByPage: Map> = emptyMap(), /** The PDF's fillable form boxes, by page — filled in place in Edit mode (#68). */ val formWidgetsByPage: Map> = emptyMap(), + /** The PDF's own links (web addresses, jumps inside the document), by page (#83). */ + val linksByPage: Map> = emptyMap(), val ocrPagesInProgress: Set = emptySet(), val isExporting: Boolean = false, val exportMessage: String? = null, @@ -382,6 +384,7 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel pageBitmaps = List(doc.pageCount) { if (it == resumePage) firstPaint else null }, ocrBlocksByPage = emptyMap(), formWidgetsByPage = emptyMap(), + linksByPage = emptyMap(), ocrPagesInProgress = emptySet(), isExporting = false, exportMessage = null, @@ -390,14 +393,24 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel showOfficeEngineHint = com.chethan616.clearpdf.office.OfficeEngine.shouldOfferHint(context, displayName), renderedByOfficeEngine = renderedUri.path?.contains("/office-pdf/") == true ) - // Fillable fields, read off the main thread; most PDFs have none and stay empty. + // Fillable fields and links, read off the main thread in one pass; most PDFs have + // no form and stay empty. val formDocUri = doc.uri viewModelScope.launch { - val widgets = withContext(Dispatchers.IO) { - runCatching { com.kyant.pdfcore.form.PdfFormWidgets.read(context, formDocUri) }.getOrDefault(emptyMap()) + val (widgets, links) = withContext(Dispatchers.IO) { + runCatching { + com.kyant.pdfcore.internal.PdfBox.ensureInitialized(context) + context.contentResolver.openInputStream(formDocUri)?.use { input -> + com.tom_roush.pdfbox.pdmodel.PDDocument.load(input).use { pd -> + val w = runCatching { com.kyant.pdfcore.form.PdfFormWidgets.read(pd) }.getOrDefault(emptyMap()) + val l = runCatching { com.kyant.pdfcore.link.PdfLinks.read(pd) }.getOrDefault(emptyMap()) + w to l + } + } + }.getOrNull() ?: (emptyMap>() to emptyMap()) } - if (widgets.isNotEmpty() && _uiState.value.document?.uri == formDocUri) { - _uiState.value = _uiState.value.copy(formWidgetsByPage = widgets) + if ((widgets.isNotEmpty() || links.isNotEmpty()) && _uiState.value.document?.uri == formDocUri) { + _uiState.value = _uiState.value.copy(formWidgetsByPage = widgets, linksByPage = links) } } // The ORIGINAL uri, deliberately — not `openedUri`. @@ -512,6 +525,42 @@ class PdfViewerViewModel(private val openPdfUseCase: OpenPdfUseCase) : ViewModel }?.takeIf { _uiState.value.document?.uri == doc.uri } } + /** + * The part of the page that holds content, as a 0..1 rect shared by the whole document (#85). + * A few pages spread through the document are measured at thumbnail size and each side takes + * the generous end of what they print on, so a cover picture or a short chapter page doesn't + * decide it, and every page is trimmed the same (the text keeps one size). Null when the + * margins are too thin to be worth trimming. + */ + suspend fun contentCrop(): android.graphics.RectF? { + val doc = _uiState.value.document ?: return null + val count = _uiState.value.pageCount + if (count <= 0) return null + val samples = minOf(count, 8) + val pages = (0 until samples).map { if (samples == 1) 0 else (it * (count - 1).toFloat() / (samples - 1)).toInt() }.distinct() + val found = ArrayList() + for (p in pages) { + val bmp = withContext(AppDispatchers.pdf) { runCatching { openPdfUseCase.renderPage(doc, p, 240) }.getOrNull() } ?: continue + val r = withContext(Dispatchers.Default) { pageContentBounds(bmp) } + bmp.recycle() + if (_uiState.value.document?.uri != doc.uri) return null + if (r != null) found += r + } + if (found.isEmpty()) return null + fun low(v: List) = v.sorted()[(v.size - 1) / 4] + fun high(v: List) = v.sorted()[v.size - 1 - (v.size - 1) / 4] + val pad = 0.02f + val crop = android.graphics.RectF( + (low(found.map { it.left }) - pad).coerceAtLeast(0f), + (low(found.map { it.top }) - pad).coerceAtLeast(0f), + (high(found.map { it.right }) + pad).coerceAtMost(1f), + (high(found.map { it.bottom }) + pad).coerceAtMost(1f) + ) + if (crop.width() < 0.25f || crop.height() < 0.25f) return null + if (crop.width() > 0.94f && crop.height() > 0.94f) return null + return crop + } + fun renderPage(context: Context, pageIndex: Int, targetWidthPx: Int = DEFAULT_RENDER_WIDTH) { val state = _uiState.value val doc = state.document ?: return @@ -1166,3 +1215,36 @@ private fun OcrTextBlock.findMatches(lower: String, page: Int): List } return out } + +/** + * Where a rendered page prints anything, as a 0..1 rect, or null for a blank page: every pixel that + * differs clearly from the paper (the commonest corner colour), ignoring rows and columns with a + * single stray speck. + */ +internal fun pageContentBounds(page: Bitmap): android.graphics.RectF? = runCatching { + if (page.isRecycled || page.width < 8 || page.height < 8) return null + val w = minOf(page.width, 200) + val h = (page.height * w / page.width.toFloat()).toInt().coerceAtLeast(8) + val small = if (w == page.width) page else Bitmap.createScaledBitmap(page, w, h, true) + val px = IntArray(small.width * small.height) + small.getPixels(px, 0, small.width, 0, 0, small.width, small.height) + val sw = small.width + val sh = small.height + if (small !== page) small.recycle() + val corners = listOf(px[0], px[sw - 1], px[(sh - 1) * sw], px[sh * sw - 1]) + val paper = corners.groupingBy { it }.eachCount().maxByOrNull { it.value }!!.key + val pr = (paper shr 16) and 0xFF; val pg = (paper shr 8) and 0xFF; val pb = paper and 0xFF + val rowHits = IntArray(sh) + val colHits = IntArray(sw) + for (y in 0 until sh) for (x in 0 until sw) { + val c = px[y * sw + x] + val d = kotlin.math.abs(((c shr 16) and 0xFF) - pr) + kotlin.math.abs(((c shr 8) and 0xFF) - pg) + kotlin.math.abs((c and 0xFF) - pb) + if (d > 60) { rowHits[y]++; colHits[x]++ } + } + val top = rowHits.indexOfFirst { it >= 2 } + val bottom = rowHits.indexOfLast { it >= 2 } + val left = colHits.indexOfFirst { it >= 2 } + val right = colHits.indexOfLast { it >= 2 } + if (top < 0 || left < 0 || bottom <= top || right <= left) return null + android.graphics.RectF(left / sw.toFloat(), top / sh.toFloat(), (right + 1) / sw.toFloat(), (bottom + 1) / sh.toFloat()) +}.getOrNull() diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 9a7f655..4bdd863 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -270,6 +270,9 @@ التشفير بكلمة مرور كلمة المرور مشاركة + حفظ نسخة + تم حفظ النسخة + تعذّر حفظ النسخة PDF صور (PNG) نص (.txt) @@ -320,6 +323,10 @@ استعادة صفحات داكنة في الوضع الداكن تُفتح المستندات بصفحات داكنة كلما كان التطبيق داكنًا + قص هوامش الصفحة + يقص الحواف الفارغة حول الصفحات ليملأ النص الشاشة + وضع الكتاب + صفحة واحدة في كل مرة، اسحب جانبيًا لتقليب الصفحة الحقل السابق الحقل التالي املأ النموذج على الصفحة diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2a426c6..bc02357 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -251,6 +251,9 @@ Cifrar con contraseña Contraseña Compartir + Guardar una copia + Copia guardada + No se pudo guardar la copia PDF Normal Cifrado @@ -604,6 +607,10 @@ Restaurar Páginas oscuras en modo oscuro Los documentos se abren con páginas oscuras cuando la app está en modo oscuro + Recortar márgenes + Recorta el borde vacío de las páginas para que el texto llene la pantalla + Modo libro + Una página a la vez, desliza hacia un lado para pasar la página Campo anterior Campo siguiente Rellena el formulario en la página diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 839dd3a..901c860 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -252,6 +252,9 @@ Cripta con password Password Condividi + Salva una copia + Copia salvata + Impossibile salvare la copia PDF Normale Criptato @@ -755,6 +758,10 @@ Ripristina Pagine scure in modalità scura I documenti si aprono con pagine scure quando l\'app è in modalità scura + Rifila i margini + Taglia il bordo vuoto delle pagine così il testo riempie lo schermo + Modalità libro + Una pagina alla volta, scorri di lato per girare pagina Campo precedente Campo successivo Compila il modulo sulla pagina diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 960d2a0..52add6c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -329,6 +329,9 @@ Criptografar com senha Senha Compartilhar + Salvar uma cópia + Cópia salva + Não foi possível salvar a cópia PDF Normal Criptografado @@ -600,6 +603,10 @@ Restaurar Páginas escuras no modo escuro Os documentos abrem com páginas escuras sempre que o app estiver escuro + Cortar margens + Corta a borda vazia das páginas para o texto preencher a tela + Modo livro + Uma página por vez, deslize para o lado para virar a página Campo anterior Próximo campo Preencha o formulário na página diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9f4bc02..bba5005 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -329,6 +329,9 @@ Criptografar com senha Senha Compartilhar + Guardar uma cópia + Cópia guardada + Não foi possível guardar a cópia PDF Normal Criptografado @@ -600,6 +603,10 @@ Restaurar Páginas escuras no modo escuro Os documentos abrem com páginas escuras sempre que o app estiver escuro + Cortar margens + Corta a margem vazia das páginas para o texto preencher o ecrã + Modo livro + Uma página de cada vez, deslize para o lado para virar a página Campo anterior Próximo campo Preencha o formulário na página diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eace042..30a6f8f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -271,6 +271,9 @@ Зашифровать паролем Пароль Поделиться + Сохранить копию + Копия сохранена + Не удалось сохранить копию PDF Обычный Зашифрованный @@ -766,6 +769,10 @@ Восстановить Тёмные страницы в тёмной теме Документы открываются с тёмными страницами, когда приложение в тёмной теме + Обрезать поля + Обрезает пустые края страниц, чтобы текст заполнял экран + Режим книги + По одной странице, листайте вбок, чтобы перевернуть Предыдущее поле Следующее поле Заполните форму на странице diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7475542..740b526 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,6 +285,9 @@ Encrypt with password Password Share + Save a copy + Copy saved + Couldn\'t save the copy PDF Images (PNG) Text (.txt) @@ -341,6 +344,10 @@ Restore Dark pages in dark mode Documents open with dark pages whenever the app is dark + Trim page margins + Crops the empty border around pages so the text fills the screen + Book mode + One page at a time, swipe sideways to turn the page Previous field Next field Fill the form on the page diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt b/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt index cbe9c52..e771b97 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/editor/PdfEditor.kt @@ -46,6 +46,8 @@ class PdfEditorImpl : PdfEditor { val input = context.contentResolver.openInputStream(source.uri) ?: throw IllegalArgumentException("Cannot open source") input.use { stream -> + // importPage shares the source's fonts and images, so the source has to stay open + // until the output is saved ("COSStream has been closed" otherwise). PDDocument.load(stream).use { doc -> for (orig in order) { if (orig !in 0 until doc.numberOfPages) continue @@ -57,12 +59,12 @@ class PdfEditorImpl : PdfEditor { } written++ } + if (written == 0) throw IllegalArgumentException("Result would have no pages") + val out = context.contentResolver.openOutputStream(outputUri) + ?: throw IllegalStateException("Cannot write edited PDF output") + out.use { output.save(it) } } } - if (written == 0) throw IllegalArgumentException("Result would have no pages") - val out = context.contentResolver.openOutputStream(outputUri) - ?: throw IllegalStateException("Cannot write edited PDF output") - out.use { output.save(it) } } finally { output.close() } diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt b/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt new file mode 100644 index 0000000..39e17d2 --- /dev/null +++ b/pdf-core/src/main/java/com/kyant/pdfcore/link/PdfLinks.kt @@ -0,0 +1,103 @@ +package com.kyant.pdfcore.link + +import com.tom_roush.pdfbox.pdmodel.PDDocument +import com.tom_roush.pdfbox.pdmodel.common.PDRectangle +import com.tom_roush.pdfbox.pdmodel.interactive.action.PDActionGoTo +import com.tom_roush.pdfbox.pdmodel.interactive.action.PDActionURI +import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDNamedDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination +import com.tom_roush.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageXYZDestination + +/** + * A tappable link on a page: a web / mail address ([uri]) or a place in the same document + * ([targetPage], and [targetTop] as a 0..1 fraction of that page's height when the link names + * one). Geometry is normalized (0..1) in display space, like [com.kyant.pdfcore.form.PdfFormWidget]. + */ +data class PdfLink( + val pageIndex: Int, + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val uri: String? = null, + val targetPage: Int? = null, + val targetTop: Float? = null +) + +object PdfLinks { + + /** Every link in [doc], by page (empty when it has none). */ + fun read(doc: PDDocument): Map> { + val out = HashMap>() + for (i in 0 until doc.numberOfPages) { + val page = runCatching { doc.getPage(i) }.getOrNull() ?: continue + val annotations = runCatching { page.annotations }.getOrNull() ?: continue + val box = page.cropBox ?: page.mediaBox ?: continue + val rotation = ((page.rotation % 360) + 360) % 360 + for (a in annotations) { + val link = a as? PDAnnotationLink ?: continue + runCatching { + val rect = normalize(link.rectangle ?: return@runCatching, box, rotation) ?: return@runCatching + var uri: String? = null + var dest: PDDestination? = null + when (val action = link.action) { + is PDActionURI -> uri = action.uri + is PDActionGoTo -> dest = action.destination + null -> dest = link.destination + else -> Unit + } + val target = dest?.let { pageDestination(doc, it) } + val targetPage = target?.retrievePageNumber()?.takeIf { it in 0 until doc.numberOfPages } + if (uri.isNullOrBlank() && targetPage == null) return@runCatching + out.getOrPut(i) { ArrayList() } += PdfLink( + pageIndex = i, + left = rect[0], top = rect[1], right = rect[2], bottom = rect[3], + uri = uri?.trim()?.takeIf { it.isNotEmpty() }, + targetPage = targetPage, + targetTop = targetPage?.let { topOf(doc, it, target) } + ) + } + } + } + return out + } + + private fun pageDestination(doc: PDDocument, dest: PDDestination): PDPageDestination? = when (dest) { + is PDPageDestination -> dest + is PDNamedDestination -> runCatching { doc.documentCatalog.findNamedDestinationPage(dest) }.getOrNull() + else -> null + } + + /** Where an "XYZ" destination puts the top of the view, as a fraction of the page (unrotated pages). */ + private fun topOf(doc: PDDocument, pageIndex: Int, dest: PDPageDestination?): Float? { + val xyz = dest as? PDPageXYZDestination ?: return null + val y = xyz.top.takeIf { it >= 0 }?.toFloat() ?: return null + val page = runCatching { doc.getPage(pageIndex) }.getOrNull() ?: return null + if (((page.rotation % 360) + 360) % 360 != 0) return null + val box = page.cropBox ?: page.mediaBox ?: return null + if (box.height <= 0f) return null + return ((box.upperRightY - y) / box.height).coerceIn(0f, 1f) + } + + /** PDF user space → normalized display rect (left, top, right, bottom). */ + private fun normalize(r: PDRectangle, box: PDRectangle, rotation: Int): FloatArray? { + val w = box.width + val h = box.height + if (w <= 0f || h <= 0f) return null + fun map(x: Float, y: Float): Pair = when (rotation) { + 90 -> (y - box.lowerLeftY) / h to (x - box.lowerLeftX) / w + 180 -> (box.upperRightX - x) / w to (y - box.lowerLeftY) / h + 270 -> (box.upperRightY - y) / h to (box.upperRightX - x) / w + else -> (x - box.lowerLeftX) / w to (box.upperRightY - y) / h + } + val a = map(r.lowerLeftX, r.lowerLeftY) + val b = map(r.upperRightX, r.upperRightY) + val l = minOf(a.first, b.first).coerceIn(0f, 1f) + val t = minOf(a.second, b.second).coerceIn(0f, 1f) + val rr = maxOf(a.first, b.first).coerceIn(0f, 1f) + val bb = maxOf(a.second, b.second).coerceIn(0f, 1f) + return if (rr - l <= 0f || bb - t <= 0f) null else floatArrayOf(l, t, rr, bb) + } +} diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt b/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt index 763ae2c..4b45767 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/merger/PdfMerger.kt @@ -57,19 +57,20 @@ class PdfMergerImpl : PdfMerger { PdfBox.ensureInitialized(context) val output = PDDocument() + // importPage shares each source's fonts and images, so every source stays open until the + // output is saved ("COSStream has been closed" otherwise). + val opened = mutableListOf() var written = 0 try { for ((src, pages) in sources) { val input = context.contentResolver.openInputStream(src.uri) ?: throw IllegalStateException("Cannot read ${src.name}") - input.use { stream -> - PDDocument.load(stream).use { doc -> - for (i in pages) { - if (i in 0 until doc.numberOfPages) { - output.importPage(doc.getPage(i)) - written++ - } - } + val doc = input.use { PDDocument.load(it) } + opened += doc + for (i in pages) { + if (i in 0 until doc.numberOfPages) { + output.importPage(doc.getPage(i)) + written++ } } } @@ -78,6 +79,7 @@ class PdfMergerImpl : PdfMerger { out.use { output.save(it) } } finally { output.close() + opened.forEach { runCatching { it.close() } } } return PdfDocument(uri = outputUri, name = "Merged.pdf", pageCount = written) diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt b/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt index dcbaa53..7d83956 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/splitter/PdfSplitter.kt @@ -58,6 +58,7 @@ class PdfSplitterImpl : PdfSplitter { val input = context.contentResolver.openInputStream(source.uri) ?: throw IllegalArgumentException("Cannot open source") input.use { stream -> + // The source stays open until the output is saved: importPage shares its resources. PDDocument.load(stream).use { doc -> for (i in pages) { if (i in 0 until doc.numberOfPages) { @@ -65,11 +66,11 @@ class PdfSplitterImpl : PdfSplitter { written++ } } + val out = context.contentResolver.openOutputStream(outputUri) + ?: throw IllegalStateException("Cannot write split PDF output") + out.use { output.save(it) } } } - val out = context.contentResolver.openOutputStream(outputUri) - ?: throw IllegalStateException("Cannot write split PDF output") - out.use { output.save(it) } } finally { output.close() } From aa77fc6488d839e84f1a72b4bc83513e7d365616 Mon Sep 17 00:00:00 2001 From: chethan616 Date: Thu, 15 Oct 2026 12:27:53 +0530 Subject: [PATCH 2/3] Every viewer icon picks its own ink from what is behind it Book mode put the reader's icons over the dark letterbox while the bar's ink still came from the page, so they stayed black on black. One ink per bar also meant a circle over a photo or a page edge followed its neighbours, not its own background. - ProvideScreenInk (Android 8+) copies the window, scaled down, a few times a second, and only after the window has drawn something new. - Each control reads that copy through rememberAdaptiveInk: the corners of its own box (outside the glass) plus the sides level with its glyph, with hysteresis. The bar's old ink is the fallback, which Android 7 and older still use. - This covers the PDF/PPT top bar and reader row, the Word editor header and reading controls, the spreadsheet header and share button, and the shared find bar. --- DESIGN.md | 4 + .../com/chethan616/clearpdf/ui/DocsApp.kt | 3 + .../clearpdf/ui/components/AdaptiveInk.kt | 209 ++++++++++++++++++ .../clearpdf/ui/screen/DocxEditorScreen.kt | 43 ++-- .../clearpdf/ui/screen/PdfSearchBar.kt | 26 ++- .../ui/screen/PdfViewerBottomToolbar.kt | 21 +- .../clearpdf/ui/screen/PdfViewerScreen.kt | 23 +- .../ui/screen/SpreadsheetViewerScreen.kt | 29 ++- 8 files changed, 313 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt diff --git a/DESIGN.md b/DESIGN.md index 78fac67..8629eaa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -75,6 +75,10 @@ chromatic aberration, vibrancy, brightness, tint, highlight, corners, plus prese - Over documents use `chipGlass(onLight)` and `chipInk(onLight)`, picking `onLight` from the content behind the control (the viewer's `bandLuminance`). **In dark reader mode, invert the luminance** (`1 - l`). +- **Every viewer control picks its own ink** (PDF, PPT, Word, spreadsheet, find bar): wrap it with + `rememberAdaptiveInk(barInk)` and put `probe.modifier` first on the control. It reads the real + pixels at the control's corners and beside its glyph (`ProvideScreenInk`, a scaled window copy, + Android 8+), so two circles in one bar can differ. The bar's ink is only the fallback. - Vivid actions use the per-screen accent (`ToolAccents`, `LiquidGlassColors`) via `tint`. --- diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt index cefb489..2998acb 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -315,6 +315,8 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu LocalGlassOverlayHost provides overlayHost, androidx.compose.ui.platform.LocalHapticFeedback provides haptics ) { + // Per-icon ink in the document viewers (each control samples what's behind it). + com.chethan616.clearpdf.ui.components.ProvideScreenInk { Box(Modifier.fillMaxSize()) { // Captured layer = wallpaper + the live screen. The floating tab bar // (below) samples THIS, so it reflects real content scrolling under it @@ -562,6 +564,7 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu // Last child: above the tab bar and dialogs, outside the layer it samples. GlassOverlayLayer(overlayHost, contentBackdrop) } + } } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt b/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt new file mode 100644 index 0000000..8d3b1c5 --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/ui/components/AdaptiveInk.kt @@ -0,0 +1,209 @@ +package com.chethan616.clearpdf.ui.components + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import androidx.annotation.RequiresApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume + +/** + * Per-control ink for the document viewers. Each icon reads what is really on screen around it + * and picks dark or white ink for itself, so two buttons in the same bar can differ (one over a + * white page, one over the dark desk beside it, a photo, book mode's letterbox). + * + * How: while at least one control asks, the window is copied, scaled down, a few times a second, + * and only after it actually drew something new. A control samples the corners of its own box, + * which sit outside its round or capsule glass, so its own ink never feeds back into the reading. + * Before Android 8 (no window PixelCopy) every control keeps the ink its screen gives it. + */ +@Stable +class ScreenInkSampler internal constructor() { + internal var frame by mutableStateOf(null) + internal var users by mutableIntStateOf(0) + + @RequiresApi(Build.VERSION_CODES.O) + internal suspend fun run(window: Window, view: View) { + val dirty = AtomicBoolean(true) + val listener = ViewTreeObserver.OnDrawListener { dirty.set(true) } + view.viewTreeObserver.addOnDrawListener(listener) + val handler = Handler(Looper.getMainLooper()) + var bitmap: Bitmap? = null + try { + while (true) { + if (users == 0) { + snapshotFlow { users > 0 }.first { it } + dirty.set(true) + } + val decor = window.decorView + val w = decor.width + val h = decor.height + if (w > 0 && h > 0 && dirty.getAndSet(false)) { + val bw = (w / SCALE_DOWN).coerceAtLeast(1) + val bh = (h / SCALE_DOWN).coerceAtLeast(1) + val target = bitmap?.takeIf { it.width == bw && it.height == bh && !it.isRecycled } + ?: Bitmap.createBitmap(bw, bh, Bitmap.Config.ARGB_8888).also { bitmap = it } + val ok = runCatching { + suspendCancellableCoroutine { cont -> + PixelCopy.request(window, target, { result -> if (cont.isActive) cont.resume(result) }, handler) + } == PixelCopy.SUCCESS + }.getOrDefault(false) + if (ok) { + val px = IntArray(bw * bh) + target.getPixels(px, 0, bw, 0, 0, bw, bh) + frame = InkFrame(px, bw, bh, bw / w.toFloat(), bh / h.toFloat()) + } + } + delay(SAMPLE_INTERVAL_MS) + } + } finally { + if (view.viewTreeObserver.isAlive) view.viewTreeObserver.removeOnDrawListener(listener) + bitmap?.recycle() + } + } + + private companion object { + const val SCALE_DOWN = 6 + const val SAMPLE_INTERVAL_MS = 140L + } +} + +/** A scaled-down copy of the window; coordinates in and out are window pixels. */ +internal class InkFrame( + private val pixels: IntArray, + private val width: Int, + private val height: Int, + private val scaleX: Float, + private val scaleY: Float +) { + private fun luminanceAt(x: Float, y: Float): Double { + val px = (x * scaleX).toInt().coerceIn(0, width - 1) + val py = (y * scaleY).toInt().coerceIn(0, height - 1) + val c = pixels[py * width + px] + return (0.299 * ((c shr 16) and 0xFF) + 0.587 * ((c shr 8) and 0xFF) + 0.114 * (c and 0xFF)) / 255.0 + } + + /** + * Luminance around [box]: just inside its four corners (outside the rounded glass), plus, at + * double weight, just outside its left and right edges level with its centre, where the icon + * itself sits. A control straddling a page edge so follows what is beside its glyph. + */ + fun cornerLuminance(box: Rect, inset: Float): Float { + val d = inset.coerceAtMost(minOf(box.width, box.height) * 0.14f) + val corners = luminanceAt(box.left + d, box.top + d) + luminanceAt(box.right - d, box.top + d) + + luminanceAt(box.left + d, box.bottom - d) + luminanceAt(box.right - d, box.bottom - d) + val cy = box.center.y + val sides = luminanceAt(box.left - d * 0.6f, cy) + luminanceAt(box.right + d * 0.6f, cy) + return ((corners + 2 * sides) / 8.0).toFloat() + } +} + +val LocalScreenInk = staticCompositionLocalOf { null } + +/** Runs one [ScreenInkSampler] for this window (Android 8+); the viewers' controls read from it. */ +@Composable +fun ProvideScreenInk(content: @Composable () -> Unit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + content() + return + } + val view = LocalView.current + val window = remember(view) { view.context.findActivity()?.window } + val sampler = remember { ScreenInkSampler() } + if (window != null) LaunchedEffect(window, view) { sampler.run(window, view) } + CompositionLocalProvider(LocalScreenInk provides if (window != null) sampler else null, content = content) +} + +/** One control's ink: put [modifier] on the control (first, so it measures the resting box). */ +class InkProbe internal constructor(val modifier: Modifier, val color: Color) { + val soft: Color get() = color.copy(alpha = 0.62f) +} + +private val InkOnLight = Color(0xFF15171C) + +/** + * Ink for one control, from the pixels around it. [fallback] is the ink the screen would use for + * the whole bar: it applies until the first reading and on Android 7 and older, and when the + * reading agrees with it, it is kept as is (so a screen's own dark ink stays its exact colour). + */ +@Composable +fun rememberAdaptiveInk(fallback: Color): InkProbe { + val sampler = LocalScreenInk.current + val fallbackOnLight = fallback.luminance() < 0.5f + var onLight by remember { mutableStateOf(fallbackOnLight) } + var measured by remember { mutableStateOf(false) } + val box = remember { arrayOfNulls(1) } + val currentFallback by rememberUpdatedState(fallbackOnLight) + val inset = with(LocalDensity.current) { 5.dp.toPx() } + if (sampler != null) { + DisposableEffect(sampler) { + sampler.users++ + onDispose { sampler.users-- } + } + LaunchedEffect(sampler) { + snapshotFlow { sampler.frame }.collect { frame -> + val b = box[0] + if (frame == null || b == null || b.width <= 0f || b.height <= 0f) return@collect + val lum = frame.cornerLuminance(b, inset) + // Hysteresis, so a control over a mid-grey picture doesn't flicker. + onLight = when { + !measured -> lum > 0.60f + lum > 0.62f -> true + lum < 0.58f -> false + else -> onLight + } + measured = true + } + } + } + val useLight = if (sampler != null && measured) onLight else currentFallback + val target = when { + useLight == fallbackOnLight -> fallback + useLight -> InkOnLight + else -> Color.White + } + val color by animateColorAsState(target, tween(200), label = "adaptiveInk") + val modifier = remember { Modifier.onGloballyPositioned { box[0] = it.boundsInWindow() } } + return InkProbe(modifier, color) +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt index a6b0684..c14526b 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DocxEditorScreen.kt @@ -185,6 +185,7 @@ import com.chethan616.clearpdf.docx.XNode import com.chethan616.clearpdf.docx.XmlScan import com.chethan616.clearpdf.docx.findPara import com.chethan616.clearpdf.docx.forEachPara +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.GlassBackButton import com.chethan616.clearpdf.ui.components.GlassColorPicker import com.chethan616.clearpdf.ui.components.GlassDialog @@ -809,6 +810,12 @@ private fun DocxEditorContent( contentHorizontalPadding = 0.dp, headerHorizontalPadding = 12.dp, header = { headerBackdrop -> + // Each control takes its own ink from what is behind it; the paper's ink is the fallback. + val backInk = rememberAdaptiveInk(text) + val titleInk = rememberAdaptiveInk(text) + val findInk = rememberAdaptiveInk(text) + val paperInk = rememberAdaptiveInk(text) + val editInk = rememberAdaptiveInk(text) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -817,7 +824,8 @@ private fun DocxEditorContent( GlassBackButton( onBack = { requestBack() }, backdrop = headerBackdrop, - foreground = text, + modifier = backInk.modifier, + foreground = backInk.color, onLongPressBack = if (viewModel.dirty || editMode) ({ requestBack() }) else LocalBackToLibraryAction.current, longPressLabel = if (viewModel.dirty || editMode) stringResource(R.string.back) else null ) @@ -825,8 +833,9 @@ private fun DocxEditorContent( GlassTitlePill( text = (if (viewModel.dirty) "• " else "") + meta.fileName.ifBlank { stringResource(R.string.viewer_title) }, backdrop = headerBackdrop, + modifier = titleInk.modifier, surfaceColor = chipTint, - contentColor = text + contentColor = titleInk.color ) } if (viewModel.dirty && editable) { @@ -839,14 +848,14 @@ private fun DocxEditorContent( } } if (!editMode) { - LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop, modifier = findInk.modifier) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } - LiquidIconButton(onClick = onTogglePaper, backdrop = headerBackdrop) { + LiquidIconButton(onClick = onTogglePaper, backdrop = headerBackdrop, modifier = paperInk.modifier) { Icon( if (paperDark) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (paperDark) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), - Modifier.size(19.dp), text + Modifier.size(19.dp), paperInk.color ) } } @@ -854,10 +863,11 @@ private fun DocxEditorContent( LiquidIconButton( onClick = { if (editMode) exitEdit() else { editMode = true; showSearch = false; searchQuery = "" } }, backdrop = headerBackdrop, + modifier = editInk.modifier, surfaceColor = if (editMode) accent.copy(0.22f) else Color.Unspecified ) { if (editMode) BasicText(stringResource(R.string.sheet_done), style = TextStyle(accent, 13.sp, FontWeight.SemiBold)) - else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), text) + else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), editInk.color) } } } @@ -1079,9 +1089,14 @@ private fun DocxEditorContent( // ── reading chrome: page counter, print, PDF layout and share ── if (doc != null && flat != null && !editMode && !showSearch) { + val pageInk = rememberAdaptiveInk(text) + val printInk = rememberAdaptiveInk(text) + val pdfInk = rememberAdaptiveInk(text) + val shareInk = rememberAdaptiveInk(text) Row( Modifier.align(Alignment.BottomStart).navigationBarsPadding().padding(start = 12.dp, bottom = 10.dp) .height(44.dp) + .then(pageInk.modifier) .viewerGlass(screenBackdrop.glass, chipTint, shape = { Capsule }) .clip(Capsule) .liquidRowClick(corner = 22.dp) { showGoTo = true } @@ -1091,7 +1106,7 @@ private fun DocxEditorContent( BasicText( stringResource(R.string.docx_page_of, currentPage + 1, flat.pages) + if (zoom != 1f) " · ${(zoom * 100).roundToInt()}%" else "", - style = TextStyle(text, 12.sp, FontWeight.SemiBold), + style = TextStyle(pageInk.color, 12.sp, FontWeight.SemiBold), maxLines = 1 ) } @@ -1099,18 +1114,18 @@ private fun DocxEditorContent( Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 12.dp, bottom = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - LiquidIconButton(onClick = { print() }, backdrop = screenBackdrop.glass, modifier = Modifier.size(44.dp)) { - Icon(Icons.Rounded.Print, stringResource(R.string.docx_print), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { print() }, backdrop = screenBackdrop.glass, modifier = printInk.modifier.size(44.dp)) { + Icon(Icons.Rounded.Print, stringResource(R.string.docx_print), Modifier.size(20.dp), printInk.color) } - LiquidIconButton(onClick = { openAsPdf() }, backdrop = screenBackdrop.glass, modifier = Modifier.size(44.dp)) { - Icon(Icons.Rounded.PictureAsPdf, stringResource(R.string.docx_open_as_pdf), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { openAsPdf() }, backdrop = screenBackdrop.glass, modifier = pdfInk.modifier.size(44.dp)) { + Icon(Icons.Rounded.PictureAsPdf, stringResource(R.string.docx_open_as_pdf), Modifier.size(20.dp), pdfInk.color) } LiquidIconButton( onClick = { viewModel.currentFile(context) { u -> u?.let { shareFile(context, it) } } }, backdrop = screenBackdrop.glass, - modifier = Modifier.size(44.dp) + modifier = shareInk.modifier.size(44.dp) ) { - Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), text) + Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), shareInk.color) } } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt index b6afde9..e23bc94 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfSearchBar.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.CloseCrossIcon import com.chethan616.clearpdf.ui.components.GlassSearchPill import com.chethan616.clearpdf.ui.components.LiquidIconButton @@ -63,6 +64,11 @@ internal fun PdfSearchBar( try { focusRequester.requestFocus() } catch (_: Exception) {} } + // Every control takes its ink from what is behind it; [fg] is the fallback. + val pillInk = rememberAdaptiveInk(fg) + val prevInk = rememberAdaptiveInk(fg) + val nextInk = rememberAdaptiveInk(fg) + val closeInk = rememberAdaptiveInk(fg) Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -74,21 +80,21 @@ internal fun PdfSearchBar( hint = stringResource(R.string.viewer_find_hint), backdrop = backdrop, uiSensor = uiSensor, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).then(pillInk.modifier), focusRequester = focusRequester, // The bar's own slide-up is the entrance; a spring-in on the pill would stack on it. animateIn = false, // Same material as the top bar's title pill, not Home's heavier app chrome. viewerChrome = true, surfaceColor = surface, - contentColor = fg, - hintColor = fgSoft, + contentColor = pillInk.color, + hintColor = pillInk.soft, // The match count belongs with the query it counts, not out beside the buttons. trailing = { if (matchCount > 0) { BasicText( "${currentMatchIndex + 1}/$matchCount", - style = TextStyle(fgSoft, 11.sp, FontWeight.SemiBold) + style = TextStyle(pillInk.soft, 11.sp, FontWeight.SemiBold) ) } else if (query.isNotBlank()) { BasicText( @@ -104,22 +110,22 @@ internal fun PdfSearchBar( LiquidIconButton( onClick = onPrevMatch, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize) + modifier = prevInk.modifier.size(FindBarButtonSize) ) { - Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(20.dp), fg) + Icon(Icons.Rounded.KeyboardArrowUp, stringResource(R.string.previous), Modifier.size(20.dp), prevInk.color) } LiquidIconButton( onClick = onNextMatch, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize) + modifier = nextInk.modifier.size(FindBarButtonSize) ) { - Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(20.dp), fg) + Icon(Icons.Rounded.KeyboardArrowDown, stringResource(R.string.next), Modifier.size(20.dp), nextInk.color) } - val closeTint = if (fg.luminance() > 0.5f) Color(0xFFD78A92) else Color(0xFFA84D58) + val closeTint = if (closeInk.color.luminance() > 0.5f) Color(0xFFD78A92) else Color(0xFFA84D58) LiquidIconButton( onClick = onClose, backdrop = backdrop, - modifier = Modifier.size(FindBarButtonSize), + modifier = closeInk.modifier.size(FindBarButtonSize), surfaceColor = closeTint.copy(alpha = 0.12f) ) { CloseCrossIcon(Modifier.size(13.dp), closeTint) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt index 828d8a7..1b0fb64 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerBottomToolbar.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.chethan616.clearpdf.R import com.chethan616.clearpdf.ui.theme.LiquidGlassColors +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.CloseCrossIcon import com.chethan616.clearpdf.ui.components.GlassMotion import com.chethan616.clearpdf.ui.components.GlassGuideCallout @@ -737,6 +738,10 @@ internal fun PdfViewerBottomToolbar( } // ── Reader actions: editor, auto-scroll, speed, and ShareMorph share one bottom row. + // Each control takes its own ink from what is behind it; the bar's [fg] is the fallback. + val editorInk = rememberAdaptiveInk(fg) + val autoScrollInk = rememberAdaptiveInk(fg) + val speedInk = rememberAdaptiveInk(fg) AnimatedVisibility( // Same `selectorGate` as the tool chips above, so the controls fade out together // before the focused sub-toolbar arrives. @@ -757,25 +762,25 @@ internal fun PdfViewerBottomToolbar( if (!editorOpen) onSetActiveTool(PdfEditTool.None) }, backdrop = backdrop, - modifier = Modifier.size(ReaderControlSize), + modifier = editorInk.modifier.size(ReaderControlSize), tint = if (editorOpen) accent else Color.Unspecified, surfaceColor = if (!editorOpen && (readerTourStep == 0 || readerTourStep == 2)) LiquidGlassColors.Blue.copy(alpha = 0.22f) else Color.Unspecified ) { - Icon(Icons.Rounded.Edit, stringResource(R.string.viewer_editor_tools), Modifier.size(20.dp), if (editorOpen) Color.White else fg) + Icon(Icons.Rounded.Edit, stringResource(R.string.viewer_editor_tools), Modifier.size(20.dp), if (editorOpen) Color.White else editorInk.color) } } Box(Modifier.size(ReaderControlSize), contentAlignment = Alignment.Center) { LiquidIconButton( onClick = onToggleAutoScroll, backdrop = backdrop, - modifier = Modifier.size(ReaderControlSize), + modifier = autoScrollInk.modifier.size(ReaderControlSize), tint = if (autoScrollActive) LiquidGlassColors.Green else Color.Unspecified ) { Icon( if (autoScrollActive) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, stringResource(if (autoScrollActive) R.string.viewer_auto_scroll_pause else R.string.viewer_auto_scroll_start), Modifier.size(20.dp), - if (autoScrollActive) Color.White else fg + if (autoScrollActive) Color.White else autoScrollInk.color ) } } @@ -788,12 +793,12 @@ internal fun PdfViewerBottomToolbar( LiquidButton( onClick = onCycleAutoScrollSpeed, backdrop = backdrop, - modifier = Modifier.widthIn(min = 72.dp), + modifier = speedInk.modifier.widthIn(min = 72.dp), surfaceColor = Color.Transparent ) { BasicText( text = "${autoScrollSpeed}×", - style = TextStyle(fg, 13.sp, FontWeight.SemiBold) + style = TextStyle(speedInk.color, 13.sp, FontWeight.SemiBold) ) } } @@ -805,6 +810,7 @@ internal fun PdfViewerBottomToolbar( } // Every document format uses the same ShareMorph position and open/share actions. + val shareInk = rememberAdaptiveInk(fg) AnimatedVisibility( visible = selectorGate && !showFindBar && !showSignaturePad, enter = fadeIn(tween(180)), @@ -815,7 +821,8 @@ internal fun PdfViewerBottomToolbar( size = ReaderControlSize, backdrop = backdrop, glass = if (readerTourStep == 1) LiquidGlassColors.Blue.copy(alpha = 0.2f) else pillGlass, - fg = fg, + fg = shareInk.color, + modifier = shareInk.modifier, onOpen = onOpenAnotherPdf, onShare = onShareDocument, onShareModeChanged = { shareActive = it; onShareHoldChanged(it) } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt index d311dc5..c7c5500 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerScreen.kt @@ -140,6 +140,7 @@ import com.chethan616.clearpdf.data.repository.AppSettingsManager import com.chethan616.clearpdf.data.repository.OnboardingManager import com.chethan616.clearpdf.data.repository.RecentFilesManager import com.chethan616.clearpdf.data.repository.PdfPageBookmarksManager +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.UnsavedChangesDialog import com.chethan616.clearpdf.ui.components.GlassBottomSheet import com.chethan616.clearpdf.ui.components.DecryptingAnimation @@ -1649,12 +1650,19 @@ fun PdfViewerScreen( horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth() ) { + // Each control picks its own ink from what is behind it (book mode's letterbox, + // a page edge, a photo), with the bar's band reading as the fallback. + val backInk = rememberAdaptiveInk(topFg) + val pillInk = rememberAdaptiveInk(topFg) + val findInk = rememberAdaptiveInk(topFg) + val moonInk = rememberAdaptiveInk(topFg) // No `surfaceColor`, exactly as Home calls it: the circle paints nothing of its own // and is pure refraction. Only the icon's colour adapts to the page. GlassBackButton( onBack = { requestExit() }, backdrop = contentBackdrop, - foreground = topFg, + modifier = backInk.modifier, + foreground = backInk.color, surfaceColor = Color.Unspecified, onLongPressBack = if (hasUnsavedEdits) ({ requestExit() }) else LocalBackToLibraryAction.current, longPressLabel = if (hasUnsavedEdits) stringResource(R.string.back) else null @@ -1666,6 +1674,7 @@ fun PdfViewerScreen( GlassTitlePill( text = stringResource(R.string.viewer_page_of, currentPageIndex + 1, safePageCount), backdrop = contentBackdrop, + modifier = pillInk.modifier, onClick = { showPageJumpDialog = true }, // Transparent, not omitted: omitting falls back to the theme's 0.35 tint, // and this pill sits between two circles that paint nothing at all, so any @@ -1673,7 +1682,7 @@ fun PdfViewerScreen( // `drawRect(Color.Transparent)` is the same no-op `LiquidIconButton` // performs when it is given no `surfaceColor`. surfaceColor = Color.Transparent, - contentColor = topFg, + contentColor = pillInk.color, // The enclosing AnimatedVisibility already slides and fades the whole bar // in every time the chrome returns; the pill's own spring would stack on // top of that and read as a stutter. Same call the find bar makes. @@ -1686,19 +1695,21 @@ fun PdfViewerScreen( if (showFindBar) viewModel.triggerOcrForAllPages(context) else { focusManager.clearFocus(); viewModel.clearSearch(); findQuery = "" } }, - backdrop = contentBackdrop + backdrop = contentBackdrop, + modifier = findInk.modifier ) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), topFg) + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } LiquidIconButton( onClick = { darkPageOverride = !darkPageAppearance }, - backdrop = contentBackdrop + backdrop = contentBackdrop, + modifier = moonInk.modifier ) { Icon( if (darkPageAppearance) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (darkPageAppearance) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), Modifier.size(20.dp), - topFg + moonInk.color ) } } diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt index 0e8691b..6e6522e 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/SpreadsheetViewerScreen.kt @@ -133,6 +133,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlin.math.roundToInt import com.chethan616.clearpdf.R +import com.chethan616.clearpdf.ui.components.rememberAdaptiveInk import com.chethan616.clearpdf.ui.components.GlassBottomSheet import com.chethan616.clearpdf.ui.components.GlassGuideCallout import com.chethan616.clearpdf.ui.components.GlassBackButton @@ -489,6 +490,12 @@ private fun SpreadsheetViewerContent( contentHorizontalPadding = 12.dp, headerHorizontalPadding = 12.dp, header = { headerBackdrop -> + // Each control takes its own ink from what is behind it; the sheet's ink is the fallback. + val backInk = rememberAdaptiveInk(text) + val titleInk = rememberAdaptiveInk(text) + val findInk = rememberAdaptiveInk(text) + val sheetInk = rememberAdaptiveInk(text) + val editInk = rememberAdaptiveInk(text) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -497,7 +504,8 @@ private fun SpreadsheetViewerContent( GlassBackButton( onBack = { requestBack() }, backdrop = headerBackdrop, - foreground = text, + modifier = backInk.modifier, + foreground = backInk.color, onLongPressBack = if (state.dirty || editing) ({ requestBack() }) else LocalBackToLibraryAction.current, longPressLabel = if (state.dirty || editing) stringResource(R.string.back) else null ) @@ -505,8 +513,10 @@ private fun SpreadsheetViewerContent( GlassTitlePill( text = (if (state.dirty) "• " else "") + state.fileName.ifBlank { stringResource(R.string.viewer_title) }, backdrop = headerBackdrop, + modifier = titleInk.modifier, // Clear like the header circles beside it (the PDF viewer's page pill too). - surfaceColor = Color.Transparent + surfaceColor = Color.Transparent, + contentColor = titleInk.color ) } if (state.dirty && state.editable) { @@ -524,14 +534,14 @@ private fun SpreadsheetViewerContent( } } if (!editMode) { - LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop) { - Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), text) + LiquidIconButton(onClick = { showSearch = true }, backdrop = headerBackdrop, modifier = findInk.modifier) { + Icon(Icons.Rounded.Search, stringResource(R.string.viewer_find), Modifier.size(20.dp), findInk.color) } - LiquidIconButton(onClick = onToggleSheet, backdrop = headerBackdrop) { + LiquidIconButton(onClick = onToggleSheet, backdrop = headerBackdrop, modifier = sheetInk.modifier) { Icon( if (sheetDark) Icons.Rounded.LightMode else Icons.Rounded.DarkMode, stringResource(if (sheetDark) R.string.viewer_page_colors_original else R.string.viewer_page_dark_mode), - Modifier.size(19.dp), text + Modifier.size(19.dp), sheetInk.color ) } } @@ -542,10 +552,11 @@ private fun SpreadsheetViewerContent( else { editMode = true; showSearch = false } }, backdrop = headerBackdrop, + modifier = editInk.modifier, surfaceColor = if (editMode || showSpreadsheetTour) accent.copy(0.22f) else Color.Unspecified ) { if (editMode) BasicText(stringResource(R.string.sheet_done), style = TextStyle(accent, 13.sp, FontWeight.SemiBold)) - else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), if (showSpreadsheetTour) accent else text) + else Icon(Icons.Rounded.Edit, stringResource(R.string.sheet_edit), Modifier.size(19.dp), if (showSpreadsheetTour) accent else editInk.color) } } } @@ -726,6 +737,7 @@ private fun SpreadsheetViewerContent( // one control only needs to do the one thing its icon says. No long-press: it used to open // a PDF export, which surprised people tapping-and-holding by habit. if (sheet != null && !showSearch && !editMode) { + val shareInk = rememberAdaptiveInk(text) LiquidIconButton( onClick = { viewModel.shareableUri(context) { u -> u?.let { shareFile(context, it) } } }, backdrop = screenBackdrop.glass, @@ -735,8 +747,9 @@ private fun SpreadsheetViewerContent( // asks for (that's also why the old 52dp here rendered at a full 52dp, not 40dp -- // visibly oversized next to the header's unmodified 40dp icon buttons). modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 12.dp, bottom = 10.dp).size(44.dp) + .then(shareInk.modifier) ) { - Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), text) + Icon(Icons.Rounded.IosShare, stringResource(R.string.viewer_share_document), Modifier.size(20.dp), shareInk.color) } } From 56b0b332e5ad6c782c2563529423b1bd0ddefddc Mon Sep 17 00:00:00 2001 From: chethan616 Date: Thu, 15 Oct 2026 12:54:25 +0530 Subject: [PATCH 3/3] Encrypt PDF with AES-256 and a separate owner password; show real file names in encrypt/decrypt --- .../clearpdf/ui/screen/DecryptPdfScreen.kt | 3 ++- .../clearpdf/ui/screen/EncryptPdfScreen.kt | 3 ++- .../com/chethan616/clearpdf/utils/DisplayName.kt | 13 +++++++++++++ .../kyant/pdfcore/security/PdfSecurityService.kt | 11 +++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt index c065ca4..a0367e9 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/DecryptPdfScreen.kt @@ -54,6 +54,7 @@ import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.utils.displayNameOf import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.pdfcore.security.PdfSecurityService import kotlinx.coroutines.Dispatchers @@ -156,7 +157,7 @@ fun DecryptPdfScreen( sourceUri?.let { source -> BasicText( - source.lastPathSegment ?: stringResource(R.string.viewer_title), + remember(source) { displayNameOf(context, source) } ?: stringResource(R.string.viewer_title), style = TextStyle(text, 14.sp, FontWeight.Medium), maxLines = 1 ) diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt index a663bf7..40272e2 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/EncryptPdfScreen.kt @@ -55,6 +55,7 @@ import com.chethan616.clearpdf.ui.components.GlassScreenScaffold import com.chethan616.clearpdf.ui.components.liquidGlassPanel import com.chethan616.clearpdf.ui.theme.LocalIsDarkMode import com.chethan616.clearpdf.ui.utils.rememberUISensor +import com.chethan616.clearpdf.utils.displayNameOf import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.pdfcore.security.PdfSecurityService import kotlinx.coroutines.Dispatchers @@ -173,7 +174,7 @@ fun EncryptPdfScreen( sourceUri?.let { source -> BasicText( - source.lastPathSegment ?: stringResource(R.string.viewer_title), + remember(source) { displayNameOf(context, source) } ?: stringResource(R.string.viewer_title), style = TextStyle(text, 14.sp, FontWeight.Medium), maxLines = 1 ) diff --git a/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt b/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt new file mode 100644 index 0000000..6e29fff --- /dev/null +++ b/app/src/main/java/com/chethan616/clearpdf/utils/DisplayName.kt @@ -0,0 +1,13 @@ +package com.chethan616.clearpdf.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns + +/** The file's real name from its provider (a picked uri's last path segment is an id like "msf:49918"). */ +fun displayNameOf(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (i != -1 && c.moveToFirst()) c.getString(i)?.takeIf { it.isNotBlank() } else null + } +}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')?.takeIf { !it.contains(':') } diff --git a/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt index 44a9c70..98ea84f 100644 --- a/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt +++ b/pdf-core/src/main/java/com/kyant/pdfcore/security/PdfSecurityService.kt @@ -77,8 +77,10 @@ object PdfSecurityService { setCanModify(false) setCanExtractContent(true) } - val policy = StandardProtectionPolicy(password, password, permissions).apply { - encryptionKeyLength = 128 + // AES-256, and an owner password of its own: with owner == user the open password also + // grants owner rights, so the "no modify" permission would mean nothing. + val policy = StandardProtectionPolicy(randomOwnerPassword(), password, permissions).apply { + encryptionKeyLength = 256 } document.protect(policy) context.contentResolver.openOutputStream(destinationUri)?.use { output -> @@ -92,6 +94,11 @@ object PdfSecurityService { } } + private fun randomOwnerPassword(): String { + val bytes = ByteArray(24).also { java.security.SecureRandom().nextBytes(it) } + return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP) + } + private fun isPasswordError(error: Throwable): Boolean { return generateSequence(error) { it.cause } .any { it::class.java.simpleName.contains("InvalidPassword", ignoreCase = true) }