Skip to content
Open
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 @@ -20,6 +20,7 @@ import okhttp3.Request
import okhttp3.Response
import java.io.FileOutputStream
import java.io.IOException
import java.lang.Character
import javax.inject.Inject
import kotlin.io.path.Path
import kotlin.io.path.createFile
Expand All @@ -36,8 +37,10 @@ class AndroidImageDownloader @Inject constructor(
Request.Builder().url(imageUrl).build().runCatching {
okHttpClient.newCall(this).execute().run {

val fileName = URLUtil.guessFileName(
imageUrl, header("Content-Disposition"), body.contentType()?.toString()
val fileName = sanitizeFileName(
URLUtil.guessFileName(
imageUrl, header("Content-Disposition"), body.contentType()?.toString()
)
)

val relativePath =
Expand Down Expand Up @@ -90,6 +93,34 @@ class AndroidImageDownloader @Inject constructor(

}

// ENAMETOOLONG is a limit on encoded bytes (NAME_MAX = 255 on ext4/f2fs), not on
// Kotlin/UTF-16 character count. A filename guessed from feed content can contain
// any script, so truncating by .length alone can still leave a >255-byte name for
// CJK, Cyrillic, emoji, etc. — truncate by UTF-8 byte length instead, one whole
// code point at a time so a surrogate pair or multi-byte sequence is never split.
internal fun sanitizeFileName(fileName: String): String {
if (fileName.toByteArray(Charsets.UTF_8).size <= MAX_FILENAME_BYTES) return fileName
val dot = fileName.lastIndexOf('.')
val ext = if (dot > 0 && fileName.length - dot in 2..10) fileName.substring(dot) else ""
val base = if (ext.isEmpty()) fileName else fileName.substring(0, dot)
val maxBaseBytes = (MAX_FILENAME_BYTES - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
return truncateToUtf8ByteLimit(base, maxBaseBytes) + ext
}

private fun truncateToUtf8ByteLimit(text: String, maxBytes: Int): String {
var byteCount = 0
var charIndex = 0
while (charIndex < text.length) {
val codePoint = text.codePointAt(charIndex)
val codePointChars = Character.charCount(codePoint)
val codePointBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8).size
if (byteCount + codePointBytes > maxBytes) break
byteCount += codePointBytes
charIndex += codePointChars
}
return text.substring(0, charIndex)
}

@DeprecatedSinceApi(29)
private fun Response.saveImageForAndroidP(
fileName: String,
Expand All @@ -109,4 +140,8 @@ class AndroidImageDownloader @Inject constructor(

return contentUri
}

private companion object {
const val MAX_FILENAME_BYTES = 200
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package me.ash.reader.infrastructure.android

import android.content.Context
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.junit.MockitoJUnitRunner

@RunWith(MockitoJUnitRunner::class)
class AndroidImageDownloaderTest {

private val downloader = AndroidImageDownloader(
context = mock(Context::class.java),
ioDispatcher = Dispatchers.Unconfined,
okHttpClient = mock(OkHttpClient::class.java),
)

private fun byteLength(name: String) = name.toByteArray(Charsets.UTF_8).size

@Test
fun `short ascii filename is returned unchanged`() {
val name = "photo.jpg"
assertEquals(name, downloader.sanitizeFileName(name))
}

@Test
fun `long ascii filename is truncated by character count`() {
val name = "a".repeat(300) + ".jpg"
val result = downloader.sanitizeFileName(name)
assertTrue(byteLength(result) <= 200)
assertTrue(result.endsWith(".jpg"))
}

@Test
fun `long multibyte filename is truncated by UTF-8 byte length, not character count`() {
// Each character here is 3 bytes in UTF-8. 100 of them alone is already
// 300 bytes — well past the 200-byte budget even though only 100 chars.
val name = "文".repeat(100) + ".jpg"
val result = downloader.sanitizeFileName(name)
assertTrue(byteLength(result) <= 200)
assertTrue(result.endsWith(".jpg"))
}

@Test
fun `truncation never splits a surrogate pair`() {
// U+1F600 GRINNING FACE is a surrogate pair in UTF-16 and 4 bytes in UTF-8.
val emoji = "😀"
val name = emoji.repeat(80) + ".png"
val result = downloader.sanitizeFileName(name)
assertTrue(byteLength(result) <= 200)
// A split surrogate pair would make this string invalid / throw on re-encoding.
assertEquals(result, String(result.toByteArray(Charsets.UTF_8), Charsets.UTF_8))
}

@Test
fun `filename with no extension is truncated as-is`() {
val name = "b".repeat(300)
val result = downloader.sanitizeFileName(name)
assertTrue(byteLength(result) <= 200)
}

@Test
fun `implausibly long suffix after the last dot is not treated as an extension`() {
val name = "a".repeat(250) + "." + "b".repeat(50)
val result = downloader.sanitizeFileName(name)
assertTrue(byteLength(result) <= 200)
}
}
Loading