From f0d016678c5c482e9618a7bb12dd77b723caa005 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 11:27:52 +0800 Subject: [PATCH] fix(agent): wait for fresh PDD clipboard link (#105) --- android/app/build.gradle.kts | 4 +- .../goauto/agent/ClipboardRelayActivity.kt | 119 +++++++++++++++--- .../goauto/agent/ClipboardRelayPolicyTest.kt | 61 +++++++++ 3 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/ClipboardRelayPolicyTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9bf960a..176cbdd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 14 - versionName = "0.9.1" + versionCode = 15 + versionName = "0.9.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt index 93daa45..7e5f3bc 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ClipboardRelayActivity.kt @@ -9,6 +9,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -16,35 +17,74 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference class ClipboardRelayActivity : Activity() { + private val handler = Handler(Looper.getMainLooper()) + private val pollRunnable = Runnable { readOrRetry() } private var completed = false + private lateinit var pollSession: ClipboardPollSession override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.setDimAmount(0f) + pollSession = ClipboardPollSession(intent.getLongExtra(EXTRA_DEADLINE_ELAPSED, 0L)) } override fun onResume() { super.onResume() - Handler(Looper.getMainLooper()).postDelayed(::readAndFinish, 120) + handler.removeCallbacks(pollRunnable) + handler.post(pollRunnable) } - private fun readAndFinish() { + override fun onDestroy() { + handler.removeCallbacks(pollRunnable) + if (!completed) { + completed = true + deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), null) + } + super.onDestroy() + } + + private fun readOrRetry() { if (completed) return - completed = true val requestId = intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty() + if (requestId.isEmpty() || !pending.containsKey(requestId)) { + complete(null) + return + } val minTimestamp = intent.getLongExtra(EXTRA_MIN_TIMESTAMP, 0L) val clipboard = getSystemService(ClipboardManager::class.java) val description = clipboard.primaryClipDescription - val fresh = when { - description == null -> false - Build.VERSION.SDK_INT < Build.VERSION_CODES.O -> true - description.timestamp <= 0L -> false - else -> description.timestamp >= minTimestamp - TIMESTAMP_TOLERANCE_MILLIS + val hasPlainText = description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true + val clip = if (hasPlainText) clipboard.primaryClip else null + val timestampSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + val text = ClipboardContentPolicy.freshText( + descriptionPresent = description != null, + timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L, + timestampSupported = timestampSupported, + hasPlainText = hasPlainText, + itemCount = clip?.itemCount ?: 0, + text = clip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.toString(), + minTimestampMillis = minTimestamp, + ) + when (pollSession.next(SystemClock.elapsedRealtime(), text != null)) { + ClipboardPollDecision.SUCCESS -> complete(text) + ClipboardPollDecision.TIMEOUT -> complete(null) + ClipboardPollDecision.RETRY -> { + val remaining = (pollSession.deadlineElapsedMillis - SystemClock.elapsedRealtime()).coerceAtLeast(1L) + handler.postDelayed(pollRunnable, minOf(POLL_INTERVAL_MILLIS, remaining)) + } + ClipboardPollDecision.IGNORE -> Unit } - val text = if (fresh && description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true) { - clipboard.primaryClip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.toString()?.takeIf { it.length <= MAX_CLIPBOARD_CHARS } - } else null - deliver(requestId, text) + } + + private fun complete(value: String?) { + if (completed) return + completed = true + handler.removeCallbacks(pollRunnable) + deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), value) + finishRelay() + } + + private fun finishRelay() { finishAndRemoveTask() overridePendingTransition(0, 0) } @@ -52,11 +92,12 @@ class ClipboardRelayActivity : Activity() { companion object { private const val EXTRA_REQUEST_ID = "clipboard_request_id" private const val EXTRA_MIN_TIMESTAMP = "clipboard_min_timestamp" - private const val MAX_CLIPBOARD_CHARS = 4096 - private const val TIMESTAMP_TOLERANCE_MILLIS = 1_500L + private const val EXTRA_DEADLINE_ELAPSED = "clipboard_deadline_elapsed" + private const val POLL_INTERVAL_MILLIS = 100L private val pending = ConcurrentHashMap() fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): CharSequence? { + val boundedTimeout = timeoutMillis.coerceIn(500L, 10_000L) val requestId = UUID.randomUUID().toString() val request = PendingRead() pending[requestId] = request @@ -65,6 +106,7 @@ class ClipboardRelayActivity : Activity() { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) putExtra(EXTRA_REQUEST_ID, requestId) putExtra(EXTRA_MIN_TIMESTAMP, minTimestamp) + putExtra(EXTRA_DEADLINE_ELAPSED, SystemClock.elapsedRealtime() + boundedTimeout) }) true }.getOrDefault(false) @@ -72,7 +114,7 @@ class ClipboardRelayActivity : Activity() { pending.remove(requestId) return null } - request.latch.await(timeoutMillis.coerceIn(500L, 10_000L), TimeUnit.MILLISECONDS) + request.latch.await(boundedTimeout, TimeUnit.MILLISECONDS) pending.remove(requestId) return request.value.get() } @@ -90,3 +132,50 @@ class ClipboardRelayActivity : Activity() { val value = AtomicReference(null) } } + +internal enum class ClipboardPollDecision { + SUCCESS, + RETRY, + TIMEOUT, + IGNORE, +} + +internal class ClipboardPollSession(val deadlineElapsedMillis: Long) { + private var completed = false + + fun next(nowElapsedMillis: Long, hasFreshText: Boolean): ClipboardPollDecision { + if (completed) return ClipboardPollDecision.IGNORE + if (nowElapsedMillis > deadlineElapsedMillis) { + completed = true + return ClipboardPollDecision.TIMEOUT + } + if (hasFreshText) { + completed = true + return ClipboardPollDecision.SUCCESS + } + if (nowElapsedMillis == deadlineElapsedMillis) { + completed = true + return ClipboardPollDecision.TIMEOUT + } + return ClipboardPollDecision.RETRY + } +} + +internal object ClipboardContentPolicy { + private const val MAX_CLIPBOARD_CHARS = 4096 + private const val TIMESTAMP_TOLERANCE_MILLIS = 1_500L + + fun freshText( + descriptionPresent: Boolean, + timestampMillis: Long, + timestampSupported: Boolean, + hasPlainText: Boolean, + itemCount: Int, + text: String?, + minTimestampMillis: Long, + ): String? { + if (!descriptionPresent || !hasPlainText || itemCount != 1) return null + if (timestampSupported && (timestampMillis <= 0L || timestampMillis < minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS)) return null + return text?.takeIf { it.length <= MAX_CLIPBOARD_CHARS } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ClipboardRelayPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ClipboardRelayPolicyTest.kt new file mode 100644 index 0000000..4db9f39 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ClipboardRelayPolicyTest.kt @@ -0,0 +1,61 @@ +package cn.ilapage.goauto.agent + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ClipboardRelayPolicyTest { + @Test + fun retriesStaleClipboardUntilFreshTextArrives() { + val session = ClipboardPollSession(deadlineElapsedMillis = 1_000L) + + assertEquals(ClipboardPollDecision.RETRY, session.next(100L, hasFreshText = false)) + assertEquals(ClipboardPollDecision.SUCCESS, session.next(200L, hasFreshText = true)) + assertEquals(ClipboardPollDecision.IGNORE, session.next(300L, hasFreshText = true)) + } + + @Test + fun timesOutOnceAtDeadline() { + val session = ClipboardPollSession(deadlineElapsedMillis = 1_000L) + + assertEquals(ClipboardPollDecision.RETRY, session.next(999L, hasFreshText = false)) + assertEquals(ClipboardPollDecision.TIMEOUT, session.next(1_000L, hasFreshText = false)) + assertEquals(ClipboardPollDecision.IGNORE, session.next(1_100L, hasFreshText = true)) + } + + @Test + fun acceptsOnlyFreshSinglePlainTextWithinLengthLimit() { + val valid = ClipboardContentPolicy.freshText( + descriptionPresent = true, + timestampMillis = 10_000L, + timestampSupported = true, + hasPlainText = true, + itemCount = 1, + text = "https://p.pinduoduo.com/test", + minTimestampMillis = 10_000L, + ) + + assertEquals("https://p.pinduoduo.com/test", valid) + assertNull(candidate(timestampMillis = 8_499L)) + assertNull(candidate(hasPlainText = false)) + assertNull(candidate(itemCount = 2)) + assertNull(candidate(text = "x".repeat(4097))) + assertNull(candidate(descriptionPresent = false)) + } + + private fun candidate( + descriptionPresent: Boolean = true, + timestampMillis: Long = 10_000L, + hasPlainText: Boolean = true, + itemCount: Int = 1, + text: String = "https://p.pinduoduo.com/test", + ): String? = ClipboardContentPolicy.freshText( + descriptionPresent = descriptionPresent, + timestampMillis = timestampMillis, + timestampSupported = true, + hasPlainText = hasPlainText, + itemCount = itemCount, + text = text, + minTimestampMillis = 10_000L, + ) +}