Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,37 +80,98 @@ object PhoneClipboard {
lastWrittenAtMs = android.os.SystemClock.elapsedRealtime()
}

internal sealed interface ItemChoice<out U> {
data class Text(val index: Int, val text: String, val coerced: Boolean) : ItemChoice<Nothing>
data class Image<out U>(val uri: U) : ItemChoice<U>
data object Empty : ItemChoice<Nothing>
}

internal class ClipCandidate<out U>(
val text: CharSequence? = null,
val uri: U? = null,
val isUriEcho: Boolean = false,
val uriMime: () -> String? = { null },
val coercedText: () -> CharSequence? = { null },
)

internal fun <U : Any> selectClipItem(
items: List<ClipCandidate<U>>,
descHasImage: Boolean = false,
isTextEcho: (String) -> Boolean = { false },
): ItemChoice<U> {
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
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>(
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<String>(
text = null,
uri = null,
coercedText = { "coerced intent text" },
),
),
descHasImage = false,
isTextEcho = { it == "coerced intent text" },
)
assertEquals(ItemChoice.Empty, echoed)
}
}
Loading