fix(agent): wait for fresh PDD clipboard link (#105)

This commit is contained in:
QiuSW
2026-08-27 11:27:52 +08:00
parent 95a0660e8e
commit f0d016678c
3 changed files with 167 additions and 17 deletions
+2 -2
View File
@@ -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"
@@ -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<String, PendingRead>()
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<String?>(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 }
}
}
@@ -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,
)
}