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