From a73260a5c27814420b5f701dcb2b9dddbcd324aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 07:25:19 -0700 Subject: [PATCH] fix(android): preserve filenames for shared images (#130156) --- .../ai/openclaw/app/ui/chat/ChatImageCodec.kt | 2 +- .../app/ui/chat/ChatImageCodecTest.kt | 102 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt index 548dfd7dda04..f55db556c2d0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatImageCodec.kt @@ -171,7 +171,7 @@ internal fun loadSizedImageAttachment( resolver: ContentResolver, uri: Uri, ): PendingAttachment { - val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/')) + val fileName = normalizeAttachmentFileName(sharedAttachmentFileName(resolver, uri)) val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH) if (bitmap == null) { throw IllegalStateException("unsupported attachment") diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt index 200fda5041f8..4c3a23069cf5 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatImageCodecTest.kt @@ -1,10 +1,17 @@ package ai.openclaw.app.ui.chat import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.database.MatrixCursor import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Color import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns import android.util.Base64 import androidx.exifinterface.media.ExifInterface import org.junit.After @@ -16,6 +23,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.GraphicsMode +import org.robolectric.shadows.ShadowContentResolver import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -121,6 +129,41 @@ class ChatImageCodecTest { ) } + @Test + fun pickedImagePreservesProviderDisplayNameInsteadOfContentUriId() { + val attachment = loadProviderImage(displayName = "vacation-photo.png") + + assertEquals("vacation-photo.jpg", attachment.fileName) + } + + @Test + fun pickedImageSanitizesUnsafeProviderDisplayName() { + val attachment = loadProviderImage(displayName = "Summer/Trips\\2026\u0007.png") + + assertEquals("Summer_Trips_2026_.jpg", attachment.fileName) + } + + @Test + fun pickedImagePreservesUnicodeProviderDisplayName() { + val attachment = loadProviderImage(displayName = "旅-été.png") + + assertEquals("旅-été.jpg", attachment.fileName) + } + + @Test + fun pickedImageFallsBackWhenProviderDisplayNameIsBlank() { + val attachment = loadProviderImage(displayName = " ") + + assertEquals("42.jpg", attachment.fileName) + } + + @Test + fun pickedImageFallsBackWhenProviderDisplayNameQueryFails() { + val attachment = loadProviderImage(displayName = "unavailable.png", failQuery = true) + + assertEquals("42.jpg", attachment.fileName) + } + @Test fun imageDecoderPreservesImagesWithoutExifMetadata() { val bitmap = createAsymmetricBitmap() @@ -139,6 +182,21 @@ class ChatImageCodecTest { ) } + private fun loadProviderImage( + displayName: String, + failQuery: Boolean = false, + ): PendingAttachment { + val image = createTaggedImage(ExifInterface.ORIENTATION_NORMAL) + val authority = "ai.openclaw.app.image-proof" + val provider = TestImageContentProvider(image, displayName, failQuery) + provider.attachInfo(RuntimeEnvironment.getApplication(), ProviderInfo().apply { this.authority = authority }) + ShadowContentResolver.registerProviderInternal(authority, provider) + return loadSizedImageAttachment( + RuntimeEnvironment.getApplication().contentResolver, + Uri.parse("content://$authority/images/42"), + ) + } + private fun createTaggedImage(orientation: Int): File { val image = File.createTempFile("chat-image-orientation-", ".jpg", RuntimeEnvironment.getApplication().cacheDir) temporaryImages += image @@ -212,3 +270,47 @@ class ChatImageCodecTest { val YELLOW = Color.rgb(255, 255, 0) } } + +private class TestImageContentProvider( + private val image: File, + private val displayName: String, + private val failQuery: Boolean, +) : ContentProvider() { + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + if (failQuery) throw SecurityException("display name unavailable") + return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME)).apply { addRow(arrayOf(displayName)) } + } + + override fun openFile( + uri: Uri, + mode: String, + ): ParcelFileDescriptor = ParcelFileDescriptor.open(image, ParcelFileDescriptor.MODE_READ_ONLY) + + override fun getType(uri: Uri): String = "image/jpeg" + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +}