PeachClipboard.kt

2.8 KB · raw

1package com.peachpasswords.shared23import android.content.ClipData4import android.content.ClipDescription5import android.content.ClipboardManager6import android.content.Context7import android.os.Build8import android.os.PersistableBundle9import android.widget.Toast10import kotlinx.coroutines.CoroutineScope11import kotlinx.coroutines.Dispatchers12import kotlinx.coroutines.Job13import kotlinx.coroutines.SupervisorJob14import kotlinx.coroutines.delay15import kotlinx.coroutines.launch1617/**18 * Centralised clipboard writes for sensitive values (MF-8). Every copy:19 *  - flags the clip IS_SENSITIVE on API 33+ so the value is kept out of the20 *    clipboard preview overlay and clipboard history,21 *  - auto-clears [CLEAR_DELAY_MS] later, but only if our value is still on the22 *    clipboard — it never stomps something the user copied afterwards,23 *  - shows a brief "Copied — clears in 30s" hint, matching the browser.24 *25 * The wipe runs on a process-lifetime scope, NOT the caller's, so navigating away26 * from the screen that triggered the copy cannot cancel it.27 */28object PeachClipboard {29    const val CLEAR_DELAY_MS = 30_000L3031    private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())32    private var pending: Job? = null3334    fun copy(context: Context, label: String, value: String, hint: Boolean = true) {35        if (value.isEmpty()) return36        val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager37        cm.setPrimaryClip(sensitiveClip(label, value))38        CopyFeedbackBus.pulse()39        if (hint) Toast.makeText(context, "Copied — clears in 30s", Toast.LENGTH_SHORT).show()40        pending?.cancel()41        pending = scheduleClear(42            scope = scope,43            expected = value,44            readCurrent = { cm.primaryClip?.getItemAt(0)?.text?.toString() },45            clear = { cm.setPrimaryClip(ClipData.newPlainText("", "")) },46        )47    }4849    private fun sensitiveClip(label: String, value: String): ClipData =50        ClipData.newPlainText(label, value).apply {51            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {52                description.extras = PersistableBundle().apply {53                    putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)54                }55            }56        }5758    /**59     * Schedule a clipboard wipe after [delayMs] that fires only if [readCurrent]60     * still returns [expected]. Extracted as a pure seam so the timing can be61     * verified on a virtual scheduler without Android.62     */63    internal fun scheduleClear(64        scope: CoroutineScope,65        expected: String,66        readCurrent: () -> String?,67        clear: () -> Unit,68        delayMs: Long = CLEAR_DELAY_MS,69    ): Job = scope.launch {70        delay(delayMs)71        if (readCurrent() == expected) clear()72    }73}