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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,54 +178,59 @@ 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
*
* @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
*
* @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<String>
): 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<String>
): Long? = columnNames.firstNotNullOfOrNull { name ->
getColumnIndex(name)
.takeIf { index -> index >= 0 }
?.let { index -> getStringOrNull(index)?.toLongOrNull() }
}

private fun <T> Result<T>.mapError(uri: Uri): Result<T> =
exceptionOrNull()?.let { Result.failure(it.mapError(uri)) } ?: this

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)
Expand All @@ -117,6 +118,7 @@ internal class IONFILETestFileContentProvider : ContentProvider() {
id,
testFile.name,
testFile.data.length,
testFile.dateTaken,
TEST_TIMESTAMP
)
)
Expand Down Expand Up @@ -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('.')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down