From 8bc83be7390101d31fc3d5cb38a86663ffc5910d Mon Sep 17 00:00:00 2001 From: J Wylie Date: Thu, 11 Jun 2026 22:56:35 -0700 Subject: [PATCH] fix(clipboard): verify coordinate copy reaches the system clipboard before claiming success (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report (#92): radial "Copy Coords" shows the confirmation toast but third-party apps paste nothing. Repro first (house rule) — could NOT fault the original write path: - Instrumented tests exercising the exact MapScreen path (composition- resolved LocalClipboardManager + setText(AnnotatedString), Compose BOM 2024.12.01 / ui 1.7.6) pass on stock API 36 (engie_emulator) and Samsung One UI 16 (SM-X210), including the reported press-and-HOLD- then-release gesture: the clip lands as text/plain, content intact per platform ClipboardManager read-back. - Full UI drive of the real app (long-press map → radial → hold "Copy Coords" 900 ms → release) landed "39.24430, -123.15008" cross-app: KEYCODE_PASTE into Google Messages reproduced the exact text. So this ships as hardening, pending device confirmation from the reporter. The only seam left in the old path was an unconditional success toast over a void write API. The copy branch now writes through the platform ClipboardManager (ClipData.newPlainText) and reads the clip back BEFORE toasting — "Copied" only when the read-back matches, the new map.toast.copyFailed (en + zh-Hant) otherwise. An OEM-side silent drop now surfaces as an honest failure (and gives us a signal to chase) instead of lying to the operator. - CoordClipboard: verified platform write; never throws — OEM rejections and focus-blocked read-backs fail closed - MapScreen: radial copy branch gates the toast on the verified result - ClipboardWriteInstrumentedTest: pins write+verify and the held-click gesture on device against the system clipboard third-party pastes coerce from Verified: assembleDebug + testDebugUnitTest green (CI parity); connectedDebugAndroidTest green on engie_emulator (API 36) and SM-X210 (One UI 16); fixed build re-driven end-to-end — verified toast, OS clipboard overlay, share sheet, and a cross-app paste all agree on the same coordinate. Co-Authored-By: Claude Fable 5 --- .../ui/ClipboardWriteInstrumentedTest.kt | 182 ++++++++++++++++++ .../omnitak/mobile/data/CoordClipboard.kt | 37 ++++ .../omnitak/mobile/i18n/LocStrings.kt | 2 + .../omnitak/mobile/ui/screens/MapScreen.kt | 14 +- 4 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/kotlin/soy/engindearing/omnitak/mobile/ui/ClipboardWriteInstrumentedTest.kt create mode 100644 app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/CoordClipboard.kt diff --git a/app/src/androidTest/kotlin/soy/engindearing/omnitak/mobile/ui/ClipboardWriteInstrumentedTest.kt b/app/src/androidTest/kotlin/soy/engindearing/omnitak/mobile/ui/ClipboardWriteInstrumentedTest.kt new file mode 100644 index 0000000..a4bcd11 --- /dev/null +++ b/app/src/androidTest/kotlin/soy/engindearing/omnitak/mobile/ui/ClipboardWriteInstrumentedTest.kt @@ -0,0 +1,182 @@ +package soy.engindearing.omnitak.mobile.ui + +import android.content.ClipDescription +import android.content.Context +import android.os.SystemClock +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import soy.engindearing.omnitak.mobile.data.CoordClipboard +import soy.engindearing.omnitak.mobile.data.CoordFormat +import soy.engindearing.omnitak.mobile.data.CoordFormatter +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Issue #92 repro + regression — radial "Copy Coords" showed the + * confirmation toast while a field report said third-party apps pasted + * nothing. Repro on stock API 36 and Samsung One UI 16 could NOT fault + * the write (the original `LocalClipboardManager.setText` path landed a + * cross-app-pasteable clip every time, tap or press-and-hold), so + * MapScreen moved to [CoordClipboard] — a platform write that's read + * back before the toast claims success. These tests pin that contract + * on a real device: the Boolean driving the toast must agree with what + * the system clipboard — the surface third-party pastes coerce from — + * actually holds. + * + * Instrumented because clipboard truth lives in the system server; JVM + * tests can't observe what other apps would paste. Reads require window + * focus on API 29+, so every assertion runs inside an ActivityScenario + * after polling `hasWindowFocus()`. + */ +@RunWith(AndroidJUnit4::class) +class ClipboardWriteInstrumentedTest { + + /** Pre-write sentinel so a pass can't be a stale-clipboard false positive. */ + private val sentinel = "issue92-sentinel-${System.nanoTime()}" + + /** + * The production write+verify, payload from the production formatter's + * DMS readout — degree/minute/second glyphs cover the "written in an + * incompatible form" theory with non-ASCII text. + */ + @Test + fun coordClipboardCopy_landsAsPlainTextInSystemClipboard() { + // Taipei 101 — Gavin (the reporter) is the Taiwan-team user. + val coord = CoordFormatter.position(25.033964, 121.564468, CoordFormat.LATLON_DMS) + + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + awaitWindowFocus(scenario) + seedSentinel(scenario) + + var claimed = false + scenario.onActivity { claimed = CoordClipboard.copy(it, coord) } + + assertTrue("CoordClipboard.copy reported failure on a healthy device", claimed) + assertSystemClipboardHolds(scenario, coord) + } + } + + /** + * The reporter's gesture: press and HOLD the radial item, release. + * RadialMenu items are plain `Modifier.clickable` (RadialMenu.kt:106), + * which fires onClick on release no matter how long the press — so a + * >long-press-timeout hold must still run the copy branch AND the + * success the toast reports must match the system clipboard. Raw + * MotionEvents go through the activity's decor, the same dispatch the + * real radial sees, with the write inside onClick like MapScreen's + * copy branch. + */ + @Test + fun heldClickRelease_runsCopyBranch_andClipLands() { + val coord = CoordFormatter.position(25.033964, 121.564468, CoordFormat.MGRS) + val clicked = CountDownLatch(1) + val claimed = AtomicBoolean(false) + val composed = CountDownLatch(1) + + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + val ctx = LocalContext.current.applicationContext + Box( + Modifier + .fillMaxSize() + // RadialMenu.kt:106 — items are plain clickable. + .clickable { + claimed.set(CoordClipboard.copy(ctx, coord)) + clicked.countDown() + } + ) + } + composed.countDown() + } + assertTrue("composition never happened", composed.await(5, TimeUnit.SECONDS)) + awaitWindowFocus(scenario) + seedSentinel(scenario) + + // DOWN → hold well past the 400 ms long-press timeout → UP. + val down = SystemClock.uptimeMillis() + scenario.onActivity { a -> a.dispatchTouch(MotionEvent.ACTION_DOWN, down, down) } + SystemClock.sleep(900) + scenario.onActivity { a -> + a.dispatchTouch(MotionEvent.ACTION_UP, down, SystemClock.uptimeMillis()) + } + + assertTrue( + "held-then-released clickable never fired onClick", + clicked.await(5, TimeUnit.SECONDS), + ) + assertTrue("copy branch would have toasted failure", claimed.get()) + assertSystemClipboardHolds(scenario, coord) + } + } + + /** Dispatch a centered single-pointer touch event through the decor view. */ + private fun ComponentActivity.dispatchTouch(action: Int, downTime: Long, eventTime: Long) { + val v = window.decorView + val ev = MotionEvent.obtain( + downTime, eventTime, action, v.width / 2f, v.height / 2f, 0, + ) + dispatchTouchEvent(ev) + ev.recycle() + } + + /** API 29+ gates clipboard reads on window focus — wait for it. */ + private fun awaitWindowFocus(scenario: ActivityScenario) { + val deadline = SystemClock.uptimeMillis() + 10_000 + var focused = false + while (!focused && SystemClock.uptimeMillis() < deadline) { + scenario.onActivity { focused = it.hasWindowFocus() } + if (!focused) SystemClock.sleep(100) + } + assertTrue("activity never gained window focus", focused) + } + + private fun seedSentinel(scenario: ActivityScenario) { + scenario.onActivity { + val cm = it.getSystemService(Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + cm.setPrimaryClip(android.content.ClipData.newPlainText("sentinel", sentinel)) + } + } + + /** + * The oracle: read back through the PLATFORM ClipboardManager — + * the system-server truth a third-party paste target coerces from — + * and require plain-text MIME plus an exact text match. + */ + private fun assertSystemClipboardHolds( + scenario: ActivityScenario, + expected: String, + ) { + var mimeOk = false + var itemText: String? = null + var coerced: String? = null + scenario.onActivity { + val cm = it.getSystemService(Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + val clip = cm.primaryClip + assertNotNull("system clipboard has no primary clip", clip) + mimeOk = clip!!.description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) + itemText = clip.getItemAt(0).text?.toString() + coerced = clip.getItemAt(0).coerceToText(it).toString() + } + assertTrue("clip is not text/plain — unpasteable in plain-text targets", mimeOk) + assertEquals("clip item text mangled", expected, itemText) + assertEquals("coerceToText (what paste targets use) mangled", expected, coerced) + assertTrue("clipboard still holds the pre-write sentinel", itemText != sentinel) + } +} diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/CoordClipboard.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/CoordClipboard.kt new file mode 100644 index 0000000..85f8173 --- /dev/null +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/data/CoordClipboard.kt @@ -0,0 +1,37 @@ +package soy.engindearing.omnitak.mobile.data + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context + +/** + * Verified clipboard write for the radial "Copy Coords" action — issue #92. + * + * A field report (Play closed track) had the success toast showing while + * third-party apps pasted nothing. The write path couldn't be faulted in + * repro — instrumented tests plus a full UI drive (radial → press-and-hold + * Copy → cross-app paste) landed the clip on stock API 36 and Samsung + * One UI 16 — so this hardens the only remaining seam: the old + * `LocalClipboardManager.setText` returned nothing, and the toast claimed + * success unconditionally. Here the clip goes through the platform + * [ClipboardManager] and is read back before anyone says "Copied" — + * an OEM-side silent drop (aggressive clipboard managers, focus races) + * now surfaces as an honest failure instead of a lie. + * + * Read-back needs window focus on API 29+; the radial tap that triggers + * the copy holds it. A blocked read fails closed — wrongly admitting + * failure beats wrongly claiming success. + */ +object CoordClipboard { + + /** + * Writes [text] to the system clipboard as plain text and returns + * whether the clip verifiably landed. Never throws — OEM clipboard + * services that reject the write report `false` instead. + */ + fun copy(context: Context, text: String): Boolean = runCatching { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("coordinates", text)) + cm.primaryClip?.getItemAt(0)?.text?.toString() == text + }.getOrDefault(false) +} diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/i18n/LocStrings.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/i18n/LocStrings.kt index 177fdb7..5bde3f5 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/i18n/LocStrings.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/i18n/LocStrings.kt @@ -117,6 +117,7 @@ internal object LocStrings { "map.toast.panning" to "Panning to %s", "map.toast.measure" to "Measure mode — tap map to add points", "map.toast.copied" to "Copied %s", + "map.toast.copyFailed" to "Copy failed — the clipboard rejected the write. Try again.", "map.toast.adsbNoCenter" to "ADSB needs a position — pan the map or wait for a GPS fix", "map.toast.globeTo2d" to "Switched to 2D map — this tool isn't available on the 3D globe yet", @@ -227,6 +228,7 @@ internal object LocStrings { "map.toast.panning" to "正在移至 %s", "map.toast.measure" to "測量模式 — 點擊地圖新增測量點", "map.toast.copied" to "已複製 %s", + "map.toast.copyFailed" to "複製失敗 — 剪貼簿拒絕寫入,請再試一次", "map.toast.adsbNoCenter" to "ADSB 需要位置 — 請平移地圖或等待 GPS 定位", "map.toast.globeTo2d" to "已切換至 2D 地圖 — 此工具尚不支援 3D 地球", diff --git a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt index af0a790..ed303d5 100644 --- a/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt +++ b/app/src/main/kotlin/soy/engindearing/omnitak/mobile/ui/screens/MapScreen.kt @@ -217,9 +217,6 @@ fun MapScreen(onOpenTab: (String) -> Unit = {}) { val rasterImagery by app.rasterOverlayStore.overlays.collectAsState() val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - // Radial "Copy Coords" — Compose clipboard handle, resolved here - // because LocalClipboardManager is composition-local. - val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current // Issue #16 — Lasso freehand multi-select. // The MapLibreMap reference is captured via TacticalMap.onMapReady @@ -1375,8 +1372,15 @@ fun MapScreen(onOpenTab: (String) -> Unit = {}) { "copy" -> if (ll != null) { val coord = soy.engindearing.omnitak.mobile.data.CoordFormatter .position(ll.latitude, ll.longitude, userPrefs.coordFormat) - clipboard.setText(androidx.compose.ui.text.AnnotatedString(coord)) - toast(Loc.t("map.toast.copied", coord)) + // Issue #92 — verified platform write; the toast + // only claims "Copied" when the clip read back + // intact, so an OEM-side silent drop reports + // failure instead of lying to the operator. + if (soy.engindearing.omnitak.mobile.data.CoordClipboard.copy(appContext, coord)) { + toast(Loc.t("map.toast.copied", coord)) + } else { + toast(Loc.t("map.toast.copyFailed")) + } } "center" -> if (ll != null) { panTarget = ll