diff --git a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidImageDownloader.kt b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidImageDownloader.kt index 6e51ddfb6..938daf528 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/android/AndroidImageDownloader.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/android/AndroidImageDownloader.kt @@ -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 @@ -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 = @@ -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, @@ -109,4 +140,8 @@ class AndroidImageDownloader @Inject constructor( return contentUri } + + private companion object { + const val MAX_FILENAME_BYTES = 200 + } } \ No newline at end of file diff --git a/app/src/test/java/me/ash/reader/infrastructure/android/AndroidImageDownloaderTest.kt b/app/src/test/java/me/ash/reader/infrastructure/android/AndroidImageDownloaderTest.kt new file mode 100644 index 000000000..a48c4377b --- /dev/null +++ b/app/src/test/java/me/ash/reader/infrastructure/android/AndroidImageDownloaderTest.kt @@ -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) + } +}