From 5b7659f642d5ef5faca4058ee7fe00b75ccd5af5 Mon Sep 17 00:00:00 2001 From: junwen-k <40173716+junwen-k@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:01:52 +0800 Subject: [PATCH] fix: getFileMetadata skips NULL cursor columns instead of throwing getColumnIndexForNames picks the first column that exists, and its value was then read unconditionally. MediaStore rows always carry DATE_TAKEN, but it is NULL for anything that is not a photo or video, so a PDF's content:// uri threw "getString(...) must not be null". Size, last-modified and created are now read through getLongForNames, which takes the first listed column that exists and is not NULL for the row, using core-ktx's getStringOrNull. SIZE and COLUMN_LAST_MODIFIED are documented as null when unknown, so document providers are covered too. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +++ .../helper/IONFILEContentHelper.kt | 42 +++++++++++-------- .../common/IONFILETestFileContentProvider.kt | 6 ++- .../helper/IONFILEContentHelperTest.kt | 32 ++++++++++++++ 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d97620..313ab96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 2026-09-14 + +- Fix: `getFileMetadata` no longer fails on MediaStore `content://` uris whose `DATE_TAKEN` is NULL, such as PDFs; the next column with a value is used instead. + ## [1.1.0] ### 2025-12-31 diff --git a/src/main/kotlin/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelper.kt b/src/main/kotlin/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelper.kt index 558d668..cabbbd4 100644 --- a/src/main/kotlin/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelper.kt +++ b/src/main/kotlin/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelper.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.MediaStore import android.provider.OpenableColumns +import androidx.core.database.getStringOrNull import io.ionic.libs.ionfilesystemlib.helper.common.FILE_MIME_TYPE_FALLBACK import io.ionic.libs.ionfilesystemlib.helper.common.readByChunks import io.ionic.libs.ionfilesystemlib.helper.common.readFull @@ -177,15 +178,10 @@ internal class IONFILEContentHelper(private val contentResolver: ContentResolver * @return the size of the file, or 0 if it cannot be retrieved; throws exceptions in case file cannot be opened */ private fun getSizeForContentUri(cursor: Cursor, uri: Uri): Long = - cursor.getColumnIndex(OpenableColumns.SIZE).let { index -> - if (index >= 0) { - cursor.getString(index).toLongOrNull() - } else { - null - } - } ?: contentResolver.openAssetFileDescriptor(uri, "r")?.use { - it.length - } ?: 0L + cursor.getLongForNames(columnNames = listOf(OpenableColumns.SIZE)) + ?: contentResolver.openAssetFileDescriptor(uri, "r")?.use { + it.length + } ?: 0L /** * Gets the last modified timestamp for a file in content uri @@ -193,17 +189,15 @@ internal class IONFILEContentHelper(private val contentResolver: ContentResolver * @param cursor the android [Cursor] containing information about the uri * @return the timestamp of last modification for the file, or 0 if not found */ - private fun getLastModifiedTimestampForContentUri(cursor: Cursor): Long { - val columnIndex = cursor.getColumnIndexForNames( + private fun getLastModifiedTimestampForContentUri(cursor: Cursor): Long = + cursor.getLongForNames( columnNames = listOf( MediaStore.MediaColumns.DATE_MODIFIED, DocumentsContract.Document.COLUMN_LAST_MODIFIED ) ) - return columnIndex?.let { cursor.getString(columnIndex).toLongOrNull() } // Images from photoPicker in MediaStore may not have modification date; fallback to date of creation if available ?: getCreatedTimestampForContentUri(cursor) - } /** * Gets the created timestamp for a file in content uri @@ -211,20 +205,32 @@ internal class IONFILEContentHelper(private val contentResolver: ContentResolver * @param cursor the android [Cursor] containing information about the uri * @return the timestamp of creation for file, or 0 if not found */ - private fun getCreatedTimestampForContentUri(cursor: Cursor): Long { - val columnIndex = cursor.getColumnIndexForNames( + private fun getCreatedTimestampForContentUri(cursor: Cursor): Long = + cursor.getLongForNames( columnNames = listOf( MediaStore.MediaColumns.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED ) - ) - return columnIndex?.let { cursor.getString(columnIndex).toLongOrNull() } ?: 0L - } + ) ?: 0L private fun Cursor.getColumnIndexForNames( columnNames: List ): Int? = columnNames.firstNotNullOfOrNull { getColumnIndex(it).takeIf { index -> index >= 0 } } + /** + * Gets the value of the first column in [columnNames] that exists and holds a number for the current row. + * + * A column that exists can still be NULL: MediaStore's DATE_TAKEN is NULL for anything that is not a photo or video, + * so the next column is tried instead of reading the NULL. + */ + private fun Cursor.getLongForNames( + columnNames: List + ): Long? = columnNames.firstNotNullOfOrNull { name -> + getColumnIndex(name) + .takeIf { index -> index >= 0 } + ?.let { index -> getStringOrNull(index)?.toLongOrNull() } + } + private fun Result.mapError(uri: Uri): Result = exceptionOrNull()?.let { Result.failure(it.mapError(uri)) } ?: this diff --git a/src/test/java/io/ionic/libs/ionfilesystemlib/common/IONFILETestFileContentProvider.kt b/src/test/java/io/ionic/libs/ionfilesystemlib/common/IONFILETestFileContentProvider.kt index 4747ac6..151042d 100644 --- a/src/test/java/io/ionic/libs/ionfilesystemlib/common/IONFILETestFileContentProvider.kt +++ b/src/test/java/io/ionic/libs/ionfilesystemlib/common/IONFILETestFileContentProvider.kt @@ -104,6 +104,7 @@ internal class IONFILETestFileContentProvider : ContentProvider() { BaseColumns._ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, OpenableColumns.SIZE, + MediaStore.MediaColumns.DATE_TAKEN, MediaStore.MediaColumns.DATE_ADDED ) ) @@ -117,6 +118,7 @@ internal class IONFILETestFileContentProvider : ContentProvider() { id, testFile.name, testFile.data.length, + testFile.dateTaken, TEST_TIMESTAMP ) ) @@ -155,7 +157,9 @@ internal class IONFILETestFileContentProvider : ContentProvider() { internal data class TestFileContent( val name: String, val data: String, - val mimeType: String? + val mimeType: String?, + // MediaStore has a DATE_TAKEN column for every row, but it is NULL for anything that is not a photo or video + val dateTaken: Long? = null ) { val nameWithoutExtension = name.substringBefore('.') } diff --git a/src/test/java/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelperTest.kt b/src/test/java/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelperTest.kt index 45837b4..d6203e1 100644 --- a/src/test/java/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelperTest.kt +++ b/src/test/java/io/ionic/libs/ionfilesystemlib/helper/IONFILEContentHelperTest.kt @@ -192,6 +192,38 @@ class IONFILEContentHelperTest { ) } + @Test + fun `given file with date taken, when getting metadata from content uri, date taken is returned as timestamps`() = + runTest { + val fileName = "photo.jpeg" + val dateTaken = TEST_TIMESTAMP - 60_000 + contentProvider.addToProvider( + IONFILETestFileContentProvider.TestFileContent( + fileName, + IMAGE_FILE_CONTENT, + mimeType = "image/jpeg", + dateTaken = dateTaken + ) + ) + val uri = Uri.parse("content://$TEST_CONTENT_PROVIDER_NAME/$fileName") + + val result = sut.getFileMetadata(uri) + + assertTrue(result.isSuccess) + assertEquals( + IONFILEMetadataResult( + fullPath = uri.path ?: "", + name = fileName, + uri = uri, + size = IMAGE_FILE_CONTENT.length.toLong(), + type = IONFILEFileType.File("image/jpeg"), + createdTimestamp = dateTaken, + lastModifiedTimestamp = dateTaken + ), + result.getOrNull() + ) + } + @Test fun `given non-existent file, when getting metadata from content uri, DoesNotExist error is returned`() = runTest {