fix(agent): wait for fresh PDD clipboard link (#105)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user