package com.peachpasswords.shared import android.content.ClipData import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.os.Build import android.os.PersistableBundle import android.widget.Toast import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * Centralised clipboard writes for sensitive values (MF-8). Every copy: * - flags the clip IS_SENSITIVE on API 33+ so the value is kept out of the * clipboard preview overlay and clipboard history, * Auto-clears CLEAR_DELAY_MS later, but only if our value is still on the * clipboard — it never stomps something the user copied afterwards, * - shows a brief "Copied — clears in 30s" hint, matching the browser. * * The wipe runs on a process-lifetime scope, NOT the caller's, so navigating away * from the screen that triggered the copy cannot cancel it. */ object PeachClipboard { const val CLEAR_DELAY_MS = 30_000L private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) private var pending: Job? = null /** The last value this object put on the clipboard, and which write it was. */ private var lastWritten: String? = null private var writeSeq = 0L fun copy(context: Context, label: String, value: String, hint: Boolean = true) { if (value.isEmpty()) return val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(sensitiveClip(label, value)) CopyFeedbackBus.pulse() if (hint) Toast.makeText(context, "Copied — clears in 30s", Toast.LENGTH_SHORT).show() val seq = ++writeSeq lastWritten = value pending?.cancel() pending = scheduleClear( scope = scope, expected = value, readCurrent = { readClip(cm) }, clear = { cm.setPrimaryClip(ClipData.newPlainText("", "")) if (writeSeq == seq) lastWritten = null }, superseded = { writeSeq != seq }, ) } /** * Drop our value now instead of waiting out the window — used when the vault * locks, which is the last moment the app can be sure it still holds the * clipboard. A value someone else copied in the meantime is left alone. */ fun clearNow(context: Context) { pending?.cancel() pending = null val expected = lastWritten ?: return lastWritten = null val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val current = readClip(cm) if (current == null || current == expected) cm.setPrimaryClip(ClipData.newPlainText("", "")) } /** * A denied read is a normal result, not an error: from API 29 a process * without window focus cannot read the clipboard at all, and OEM managers * have been known to throw. Both mean "no evidence of a foreign copy". */ private fun readClip(cm: ClipboardManager): String? = runCatching { cm.primaryClip?.getItemAt(0)?.text?.toString() }.getOrNull() private fun sensitiveClip(label: String, value: String): ClipData = ClipData.newPlainText(label, value).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) } } } /** * Schedule a clipboard wipe after [delayMs]. Extracted as a pure seam so the * timing can be verified on a virtual scheduler without Android. * * The wipe is skipped only when a *later* Peach copy has superseded this one, * or when the clipboard holds a different value that we can actually read — * i.e. real evidence the user copied something else. A null read means the * process cannot read the clipboard (no window focus from API 29, or a * vendor-thrown SecurityException), which is the ordinary state after the * user switches to the app they pasted into; treating that as a foreign copy * silently disabled the wipe on its primary path (PB-26-119). */ internal fun scheduleClear( scope: CoroutineScope, expected: String, readCurrent: () -> String?, clear: () -> Unit, superseded: () -> Boolean = { false }, delayMs: Long = CLEAR_DELAY_MS, ): Job = scope.launch { delay(delayMs) if (superseded()) return@launch val current = readCurrent() if (current == null || current == expected) clear() } }