diff --git a/android/app/src/main/kotlin/app/nectarlink/android/clipboard/PhoneClipboard.kt b/android/app/src/main/kotlin/app/nectarlink/android/clipboard/PhoneClipboard.kt index 60edbdd6..859a8025 100644 --- a/android/app/src/main/kotlin/app/nectarlink/android/clipboard/PhoneClipboard.kt +++ b/android/app/src/main/kotlin/app/nectarlink/android/clipboard/PhoneClipboard.kt @@ -80,37 +80,98 @@ object PhoneClipboard { lastWrittenAtMs = android.os.SystemClock.elapsedRealtime() } + internal sealed interface ItemChoice { + data class Text(val index: Int, val text: String, val coerced: Boolean) : ItemChoice + data class Image(val uri: U) : ItemChoice + data object Empty : ItemChoice + } + + internal class ClipCandidate( + val text: CharSequence? = null, + val uri: U? = null, + val isUriEcho: Boolean = false, + val uriMime: () -> String? = { null }, + val coercedText: () -> CharSequence? = { null }, + ) + + internal fun selectClipItem( + items: List>, + descHasImage: Boolean = false, + isTextEcho: (String) -> Boolean = { false }, + ): ItemChoice { + for ((index, item) in items.withIndex()) { + val direct = item.text?.toString()?.takeIf(String::isNotBlank) + if (direct != null) { + return ItemChoice.Text(index, direct, coerced = false) + } + // Checked before coercing to text, which would turn it into its address. + val uri = item.uri + if (uri != null) { + if (item.isUriEcho) return ItemChoice.Empty + val mime = item.uriMime() + if (mime?.startsWith("image/") == true || (mime == null && descHasImage)) { + return ItemChoice.Image(uri) + } + } + } + val coerced = items.asSequence() + .withIndex() + // Never call coerceToText on an item with a content URI because Android opens a FileInputStream + // on the URI and decodes raw binary file bytes (e.g. JPEG/JFIF) as UTF-8 text. + .filter { (_, item) -> item.uri == null } + .firstNotNullOfOrNull { (index, item) -> + item.coercedText()?.toString()?.takeIf(String::isNotBlank)?.let { index to it } + } + return if (coerced != null && !isTextEcho(coerced.second)) { + ItemChoice.Text(coerced.first, coerced.second, coerced = true) + } else { + ItemChoice.Empty + } + } + fun read(context: Context): Read { appContext = context.applicationContext val clip = manager(context)?.primaryClip ?: return Read.Empty if (clip.description.isSensitive()) return Read.Private val resolver = context.contentResolver - for (i in 0 until clip.itemCount) { + val descHasImage = (0 until clip.description.mimeTypeCount) + .any { clip.description.getMimeType(it)?.startsWith("image/") == true } + val candidates = (0 until clip.itemCount).map { i -> val item = clip.getItemAt(i) - val cs = item.text - cs?.toString()?.takeIf(String::isNotBlank)?.let { text -> - val html = item.htmlText?.takeIf(String::isNotBlank) ?: run { - if (cs is android.text.Spanned && - cs.getSpans(0, cs.length, android.text.style.CharacterStyle::class.java).isNotEmpty() - ) { - android.text.Html.toHtml(cs, android.text.Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) - .takeIf(String::isNotBlank) - } else if (clip.description?.label == "host clipboard" && isRecentEcho(text)) { - lastWrittenHtml - } else { - null + val uri = item.uri + ClipCandidate( + text = item.text, + uri = uri, + isUriEcho = uri != null && isRecentUriEcho(uri), + uriMime = { uri?.let { runCatching { resolver.getType(it) }.getOrNull() } }, + coercedText = { item.coerceToText(context) }, + ) + } + return when (val choice = selectClipItem(candidates, descHasImage, ::isRecentEcho)) { + is ItemChoice.Text -> { + if (choice.coerced) { + Read.Text(choice.text) + } else { + val item = clip.getItemAt(choice.index) + val cs = item.text + val html = item.htmlText?.takeIf(String::isNotBlank) ?: run { + if (cs is android.text.Spanned && + cs.getSpans(0, cs.length, android.text.style.CharacterStyle::class.java).isNotEmpty() + ) { + android.text.Html.toHtml(cs, android.text.Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .takeIf(String::isNotBlank) + } else if (clip.description?.label == "host clipboard" && isRecentEcho(choice.text)) { + lastWrittenHtml + } else { + null + } } + Read.Text(choice.text, html) } - return Read.Text(text, html) } - // Checked before coercing to text, which would turn it into its address. - val uri = item.uri - if (uri != null && isRecentUriEcho(uri)) return Read.Empty - if (uri != null && resolver.getType(uri)?.startsWith("image/") == true) return Read.Image(uri) + is ItemChoice.Image -> Read.Image(choice.uri) + ItemChoice.Empty -> Read.Empty } - val text = (0 until clip.itemCount) - .firstNotNullOfOrNull { clip.getItemAt(it).coerceToText(context)?.toString()?.takeIf(String::isNotBlank) } - return text?.takeUnless { isRecentEcho(it) }?.let { Read.Text(it) } ?: Read.Empty } /** diff --git a/android/app/src/test/kotlin/app/nectarlink/android/clipboard/PhoneClipboardTest.kt b/android/app/src/test/kotlin/app/nectarlink/android/clipboard/PhoneClipboardTest.kt new file mode 100644 index 00000000..ee630cfa --- /dev/null +++ b/android/app/src/test/kotlin/app/nectarlink/android/clipboard/PhoneClipboardTest.kt @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package app.nectarlink.android.clipboard + +import app.nectarlink.android.clipboard.PhoneClipboard.ClipCandidate +import app.nectarlink.android.clipboard.PhoneClipboard.ItemChoice +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class PhoneClipboardTest { + + @Test + fun `selectClipItem prefers item text over URI even when clip description advertises an image`() { + val choice = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = "Caption text", + uri = "content://media/external/images/1", + uriMime = { "image/jpeg" }, + ), + ), + descHasImage = true, + ) + assertEquals(ItemChoice.Text(index = 0, text = "Caption text", coerced = false), choice) + } + + @Test + fun `selectClipItem returns Image when item has an image URI and no direct text`() { + val byResolverMime = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = "content://media/external/images/2", + uriMime = { "image/png" }, + ), + ), + descHasImage = false, + ) + assertEquals(ItemChoice.Image("content://media/external/images/2"), byResolverMime) + + val byClipDescription = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = "content://provider/untyped-image", + uriMime = { null }, + ), + ), + descHasImage = true, + ) + assertEquals(ItemChoice.Image("content://provider/untyped-image"), byClipDescription) + } + + @Test + fun `selectClipItem never coerces a non-image URI item to text`() { + var coerceCalled = false + val choice = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = "content://downloads/document.pdf", + uriMime = { "application/pdf" }, + coercedText = { + coerceCalled = true + "content://downloads/document.pdf" + }, + ), + ), + descHasImage = false, + ) + assertEquals(ItemChoice.Empty, choice) + assertFalse("coerceToText must not be called for URI-backed items", coerceCalled) + + val withImageDesc = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = "content://downloads/document.pdf", + uriMime = { "application/pdf" }, + ), + ), + descHasImage = true, + ) + assertEquals(ItemChoice.Empty, withImageDesc) + } + + @Test + fun `selectClipItem falls back to coercedText only for items without a URI`() { + val choice = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = null, + coercedText = { "coerced intent text" }, + ), + ), + descHasImage = false, + ) + assertEquals(ItemChoice.Text(index = 0, text = "coerced intent text", coerced = true), choice) + + val echoed = PhoneClipboard.selectClipItem( + items = listOf( + ClipCandidate( + text = null, + uri = null, + coercedText = { "coerced intent text" }, + ), + ), + descHasImage = false, + isTextEcho = { it == "coerced intent text" }, + ) + assertEquals(ItemChoice.Empty, echoed) + } +}