PeachClipboard.kt

4.7 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    /** The last value this object put on the clipboard, and which write it was. */35    private var lastWritten: String? = null36    private var writeSeq = 0L3738    fun copy(context: Context, label: String, value: String, hint: Boolean = true) {39        if (value.isEmpty()) return40        val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager41        cm.setPrimaryClip(sensitiveClip(label, value))42        CopyFeedbackBus.pulse()43        if (hint) Toast.makeText(context, "Copied — clears in 30s", Toast.LENGTH_SHORT).show()44        val seq = ++writeSeq45        lastWritten = value46        pending?.cancel()47        pending = scheduleClear(48            scope = scope,49            expected = value,50            readCurrent = { readClip(cm) },51            clear = {52                cm.setPrimaryClip(ClipData.newPlainText("", ""))53                if (writeSeq == seq) lastWritten = null54            },55            superseded = { writeSeq != seq },56        )57    }5859    /**60     * Drop our value now instead of waiting out the window — used when the vault61     * locks, which is the last moment the app can be sure it still holds the62     * clipboard. A value someone else copied in the meantime is left alone.63     */64    fun clearNow(context: Context) {65        pending?.cancel()66        pending = null67        val expected = lastWritten ?: return68        lastWritten = null69        val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager70        val current = readClip(cm)71        if (current == null || current == expected) cm.setPrimaryClip(ClipData.newPlainText("", ""))72    }7374    /**75     * A denied read is a normal result, not an error: from API 29 a process76     * without window focus cannot read the clipboard at all, and OEM managers77     * have been known to throw. Both mean "no evidence of a foreign copy".78     */79    private fun readClip(cm: ClipboardManager): String? =80        runCatching { cm.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull()8182    private fun sensitiveClip(label: String, value: String): ClipData =83        ClipData.newPlainText(label, value).apply {84            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {85                description.extras = PersistableBundle().apply {86                    putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)87                }88            }89        }9091    /**92     * Schedule a clipboard wipe after [delayMs]. Extracted as a pure seam so the93     * timing can be verified on a virtual scheduler without Android.94     *95     * The wipe is skipped only when a *later* Peach copy has superseded this one,96     * or when the clipboard holds a different value that we can actually read —97     * i.e. real evidence the user copied something else. A null read means the98     * process cannot read the clipboard (no window focus from API 29, or a99     * vendor-thrown SecurityException), which is the ordinary state after the100     * user switches to the app they pasted into; treating that as a foreign copy101     * silently disabled the wipe on its primary path (PB-26-119).102     */103    internal fun scheduleClear(104        scope: CoroutineScope,105        expected: String,106        readCurrent: () -> String?,107        clear: () -> Unit,108        superseded: () -> Boolean = { false },109        delayMs: Long = CLEAR_DELAY_MS,110    ): Job = scope.launch {111        delay(delayMs)112        if (superseded()) return@launch113        val current = readCurrent()114        if (current == null || current == expected) clear()115    }116}