From 3ef3bdd12acc30b11398ec71c1a173a710d4b3fd Mon Sep 17 00:00:00 2001 From: Chethan616 Date: Sat, 22 Aug 2026 00:06:04 +0530 Subject: [PATCH 1/2] feat: native-style text selection + v1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the PDF text selector to behave like the Android system selector: - Character-precise selection (ocrCharRangesBetween) so a drag extends smoothly across words, lines and paragraphs instead of snapping one word at a time. - Native platform magnifier loupe (Modifier.magnifier) follows the finger while dragging a selection or a handle; hidden and free outside a drag, no-op < API 28. - "Select all" action added to the contextual pill (Copy | Select all | Highlight). - Fix: highlighting no longer clears the selection, so the pill immediately offers Recolor / Delete — restores the missing delete affordance on a highlight. Bump versionName 1.2.0 -> 1.3.0 (versionCode 3 -> 4). Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../clearpdf/ui/screen/PdfContinuousPage.kt | 36 +++++++++++- .../clearpdf/ui/screen/PdfViewerInternals.kt | 56 +++++++++++++++++++ .../clearpdf/ui/screen/PdfViewerScreen.kt | 15 ++++- 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1013ce3..d332b19 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,8 +38,8 @@ android { applicationId = "com.chethan616.clearpdf" minSdk = 23 targetSdk = 36 - versionCode = 3 - versionName = "1.2.0" + versionCode = 4 + versionName = "1.3.0" // Keep every locale declared by the app. Filtering this to English // removes values-pt-rBR from the packaged APK, so the language picker // can appear to work while the app continues to resolve English. 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 9cc3139..a3900a3 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.magnifier import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures @@ -53,6 +54,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -106,13 +108,18 @@ internal fun PdfContinuousPage( onSelectMarkup: (Int) -> Unit = {}, onDeleteMarkup: (Int) -> Unit = {}, onCopySelection: () -> Unit = {}, - onHighlightSelection: () -> Unit = {} + onHighlightSelection: () -> Unit = {}, + onSelectAll: () -> Unit = {} ) { var draftPoints by remember(page, activeTool) { mutableStateOf>(emptyList()) } var draftRectStart by remember(page, activeTool) { mutableStateOf(null) } var draftRectEnd by remember(page, activeTool) { mutableStateOf(null) } var selDragStart by remember(page, activeTool) { mutableStateOf(null) } var selDragEnd by remember(page, activeTool) { mutableStateOf(null) } + // Live focus point for the native magnifier loupe. Offset.Unspecified hides it; while a + // selection drag is in flight it tracks the finger so text stays legible under the fingertip, + // exactly like the platform text selector. + var magnifierFocus by remember(page, activeTool) { mutableStateOf(Offset.Unspecified) } val selectionHandleDiameterPx = with(LocalDensity.current) { 32.dp.toPx() } val selectionHandleHitRadiusPx = with(LocalDensity.current) { 30.dp.toPx() } @@ -127,6 +134,15 @@ internal fun PdfContinuousPage( .padding(vertical = 6.dp) .then(if (bitmap == null) Modifier.aspectRatio(1f / 1.414f) else Modifier) .background(Color(0xFF15181E)) + // Native platform loupe (Android 9+). Inactive — and a no-op on older devices — + // whenever the focus point is Unspecified, so it costs nothing outside a drag. + .magnifier( + sourceCenter = { magnifierFocus }, + zoom = 1.5f, + size = DpSize(112.dp, 64.dp), + cornerRadius = 32.dp, + elevation = 4.dp + ) .onSizeChanged { sz -> pageCanvasSizes[page] = Size(sz.width.toFloat(), sz.height.toFloat()) if (bitmap != null) pageBitmapSizes[page] = Size(bitmap.width.toFloat(), bitmap.height.toFloat()) @@ -569,7 +585,10 @@ internal fun PdfContinuousPage( val s = selDragStart; val e = selDragEnd if (s == null || e == null || ocrBlocks.isEmpty()) return val frame = Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()) - onSelectOcrRange(ocrWordRangesBetween(ocrBlocks, frame, s, e)) + // Character-precise sweep (native granularity) — extends smoothly across + // words, lines and paragraphs instead of snapping a whole word at a time. + onSelectOcrRange(ocrCharRangesBetween(ocrBlocks, frame, s, e)) + magnifierFocus = e } // Do not use detectDragGestures here: it commits to a one-finger drag before @@ -605,6 +624,7 @@ internal fun PdfContinuousPage( } selDragStart = null selDragEnd = null + magnifierFocus = Offset.Unspecified } else if (!multiTouch) { val change = event.changes.firstOrNull { it.id == pointerId } if (change != null) { @@ -626,6 +646,7 @@ internal fun PdfContinuousPage( } while (event.changes.any { it.pressed }) selDragStart = null selDragEnd = null + magnifierFocus = Offset.Unspecified } } ) @@ -655,7 +676,7 @@ internal fun PdfContinuousPage( } val gapPx = with(density) { 8.dp.toPx() } val bubbleHpx = with(density) { 44.dp.toPx() } - val bubbleWpx = with(density) { (if (selectionHasHighlight) 226.dp else 150.dp).toPx() } + val bubbleWpx = with(density) { (if (selectionHasHighlight) 372.dp else 296.dp).toPx() } // Flip below the selection when it's too close to the page top. val placeBelow = selTop < bubbleHpx + gapPx val by = (if (placeBelow) selBottom + gapPx else selTop - bubbleHpx - gapPx).coerceIn(0f, (csz.height - bubbleHpx).coerceAtLeast(0f)) @@ -679,6 +700,15 @@ internal fun PdfContinuousPage( .padding(horizontal = 16.dp, vertical = 9.dp) ) Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) + BasicText( + "Select all", + style = TextStyle(Color.White, 13.sp, FontWeight.SemiBold), + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable { onSelectAll() } + .padding(horizontal = 16.dp, vertical = 9.dp) + ) + Box(Modifier.width(1.dp).height(20.dp).background(Color.White.copy(0.14f))) BasicText( if (selectionHasHighlight) "Recolor" else "Highlight", style = TextStyle(Color(0xFFFFCC33), 13.sp, FontWeight.SemiBold), diff --git a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt index 074ce7d..0321c7b 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/screen/PdfViewerInternals.kt @@ -529,6 +529,62 @@ internal fun ocrWordRangesBetween( } } +/** + * Character-precise contiguous selection between two content points — the granularity the + * native Android text selector uses. Every extracted glyph box (per-character metrics) becomes a + * hit target; the run from the character nearest [p1] to the one nearest [p2], in reading order, + * is returned as merged per-line ranges. Falls back to word granularity for lines that expose no + * per-character metrics so selection never dead-ends. + */ +internal fun ocrCharRangesBetween( + blocks: List, + frame: Rect, + p1: Offset, + p2: Offset +): List { + if (blocks.isEmpty()) return emptyList() + val chars = ArrayList() + blocks.readingOrder().forEach { block -> + val n = minOf(block.charLefts.size, block.charRights.size, block.text.length) + val top = frame.top + block.top * frame.height + val bottom = frame.top + block.bottom * frame.height + for (i in 0 until n) { + val l = frame.left + block.charLefts[i] * frame.width + val r = frame.left + block.charRights[i] * frame.width + chars.add(OcrCharHit(block.id, i, Rect(min(l, r), top, max(l, r), bottom))) + } + } + // No glyph metrics on this page (image-only OCR) → keep the proven word sweep. + if (chars.isEmpty()) return ocrWordRangesBetween(blocks, frame, p1, p2) + val i1 = chars.nearestCharIndex(p1) + val i2 = chars.nearestCharIndex(p2) + val lo = min(i1, i2) + val hi = max(i1, i2) + // Each block's glyphs are emitted contiguously, so a contiguous slice yields one clean + // [start, end) range per block — exactly what the highlight / copy models expect. + return chars.subList(lo, hi + 1) + .groupBy { it.blockId } + .map { (id, hits) -> OcrTextRange(id, hits.minOf { it.charIndex }, hits.maxOf { it.charIndex } + 1) } +} + +private data class OcrCharHit(val blockId: String, val charIndex: Int, val rect: Rect) + +private fun List.nearestCharIndex(point: Offset): Int = + indices.minByOrNull { index -> + val rect = this[index].rect + val dx = when { + point.x < rect.left -> rect.left - point.x + point.x > rect.right -> point.x - rect.right + else -> 0f + } + val dy = when { + point.y < rect.top -> rect.top - point.y + point.y > rect.bottom -> point.y - rect.bottom + else -> 0f + } + dx * dx + dy * dy + } ?: 0 + private data class OcrWordHit(val range: OcrTextRange, val rect: Rect) private fun List.nearestWordIndex(point: Offset): Int = 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 0a19264..206b6fc 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 @@ -913,10 +913,19 @@ fun PdfViewerScreen( onHighlightSelection = { val m = getPageMarks(page) selectedTextRanges(page).forEach { range -> - if (!m.any { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end }) - m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) + // Recolor semantics: drop any highlight already covering this exact + // range so a re-tap replaces the colour instead of stacking layers. + m.removeAll { it is PdfMarkup.TextBlockHighlightMarkup && it.blockId == range.blockId && it.start == range.start && it.end == range.end } + m.add(PdfMarkup.TextBlockHighlightMarkup(range.blockId, Color(currentColorLong), 0.38f, range.start, range.end)) } - viewModel.clearOcrSelection(page) + // Keep the selection live so the pill immediately offers Recolor / Delete — + // this is the fix for "after highlighting there's no delete option". + lastInteractionAtMs = System.currentTimeMillis() + }, + onSelectAll = { + val ids = state.ocrBlocksByPage[page].orEmpty().map { it.id }.toSet() + if (ids.isNotEmpty()) viewModel.selectOcrBlocks(page, ids, append = false) + lastInteractionAtMs = System.currentTimeMillis() } ) } From 380636c96d8bc9056891ecdb813c9abc25a55a71 Mon Sep 17 00:00:00 2001 From: Chethan616 Date: Sat, 22 Aug 2026 00:11:28 +0530 Subject: [PATCH 2/2] feat: re-show onboarding after app updates; revert version to 1.2.0/3 - OnboardingManager tracks the versionCode onboarding was last completed on (shouldShowOnboarding) so the tour reappears on the first launch after an update, not only on a fresh install. resetOnboarding clears it for replay. - Existing users get the tour once on this build (version key starts unset). - Revert versionCode 4 -> 3 and versionName 1.3.0 -> 1.2.0; versioning for the release will be set manually. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 4 +- .../data/repository/OnboardingManager.kt | 37 ++++++++++++++++++- .../com/chethan616/clearpdf/ui/DocsApp.kt | 3 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d332b19..1013ce3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,8 +38,8 @@ android { applicationId = "com.chethan616.clearpdf" minSdk = 23 targetSdk = 36 - versionCode = 4 - versionName = "1.3.0" + versionCode = 3 + versionName = "1.2.0" // Keep every locale declared by the app. Filtering this to English // removes values-pt-rBR from the packaged APK, so the language picker // can appear to work while the app continues to resolve English. diff --git a/app/src/main/java/com/chethan616/clearpdf/data/repository/OnboardingManager.kt b/app/src/main/java/com/chethan616/clearpdf/data/repository/OnboardingManager.kt index eda2266..d142511 100644 --- a/app/src/main/java/com/chethan616/clearpdf/data/repository/OnboardingManager.kt +++ b/app/src/main/java/com/chethan616/clearpdf/data/repository/OnboardingManager.kt @@ -10,6 +10,10 @@ object OnboardingManager { private const val PREFS_NAME = "clearpdf_onboarding" private const val KEY_COMPLETED = "onboarding_completed" private const val KEY_LOCALE = "selected_locale" + // The app versionCode the user last finished onboarding on. When a newer build is + // installed this is behind [currentVersionCode], so the tour is shown again to surface + // what changed in the update. + private const val KEY_ONBOARDED_VERSION = "onboarded_version_code" private fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -17,11 +21,40 @@ object OnboardingManager { fun hasCompletedOnboarding(context: Context): Boolean = prefs(context).getBoolean(KEY_COMPLETED, false) + /** The installed app's versionCode (0 if it can't be resolved). */ + fun currentVersionCode(context: Context): Int = try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + if (android.os.Build.VERSION.SDK_INT >= 28) info.longVersionCode.toInt() + else @Suppress("DEPRECATION") info.versionCode + } catch (e: Exception) { + 0 + } + + private fun onboardedVersionCode(context: Context): Int = + prefs(context).getInt(KEY_ONBOARDED_VERSION, -1) + + /** + * Onboarding is shown on the very first launch AND again after every app update — i.e. + * whenever the installed [currentVersionCode] is newer than the one the user last completed + * the tour on. Completing (or replaying) it records the current version so it won't repeat + * until the next update. + */ + fun shouldShowOnboarding(context: Context): Boolean { + if (!hasCompletedOnboarding(context)) return true + return onboardedVersionCode(context) < currentVersionCode(context) + } + fun setOnboardingComplete(context: Context) = - prefs(context).edit().putBoolean(KEY_COMPLETED, true).apply() + prefs(context).edit() + .putBoolean(KEY_COMPLETED, true) + .putInt(KEY_ONBOARDED_VERSION, currentVersionCode(context)) + .apply() fun resetOnboarding(context: Context) = - prefs(context).edit().putBoolean(KEY_COMPLETED, false).apply() + prefs(context).edit() + .putBoolean(KEY_COMPLETED, false) + .remove(KEY_ONBOARDED_VERSION) + .apply() fun getSelectedLocale(context: Context): String = prefs(context).getString(KEY_LOCALE, "en") ?: "en" 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 a932bf5..1ba73d0 100644 --- a/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt +++ b/app/src/main/java/com/chethan616/clearpdf/ui/DocsApp.kt @@ -96,7 +96,8 @@ fun DocsApp(shortcutRoute: String? = null, incomingPdfUri: android.net.Uri? = nu // share or a shortcut skips the tour and goes straight to the document — the flag is left unset, // so onboarding still appears on the next ordinary cold start rather than being lost. val needsOnboarding = remember { - !OnboardingManager.hasCompletedOnboarding(context) && + // Shows on first launch and again after each app update (version-aware). + OnboardingManager.shouldShowOnboarding(context) && shortcutRoute == null && incomingPdfUri == null } // The locale the Activity actually booted with. Onboarding changes `selectedLocale` in place for