Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ dependencies {

// Access settings & model data
implementation(project(":data:settings"))
implementation(project(":data:media"))
implementation(project(":core:model"))

// Camera Preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
Expand All @@ -39,8 +40,16 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext

Expand All @@ -57,16 +66,90 @@ class LocalMediaRepository
private val repositoryScope = CoroutineScope(iODispatcher + SupervisorJob())
private val _currentMedia = MutableStateFlow<MediaDescriptor>(MediaDescriptor.None)

private var thumbnailLoader: suspend (Uri, Uri) -> Bitmap? = { uri, collectionUri ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver.loadThumbnail(uri, Size(640, 480), null)
} else {
if (collectionUri == MediaStore.Images.Media.EXTERNAL_CONTENT_URI) {
MediaStore.Images.Thumbnails.getThumbnail(
context.contentResolver,
ContentUris.parseId(uri),
MediaStore.Images.Thumbnails.MINI_KIND,
null
)
} else { // Video
MediaStore.Video.Thumbnails.getThumbnail(
context.contentResolver,
ContentUris.parseId(uri),
MediaStore.Video.Thumbnails.MINI_KIND,
null
)
}
}
}

/**
* Sets a custom thumbnail loader. Primarily used for testing.
*/
internal fun setThumbnailLoader(loader: suspend (Uri, Uri) -> Bitmap?) {
thumbnailLoader = loader
}

override val currentMedia = _currentMedia.asStateFlow()

/**
* A [StateFlow] that emits the most recently captured media (image or video) from the MediaStore.
*/
@OptIn(ExperimentalCoroutinesApi::class)
override val lastCapturedMedia: StateFlow<MediaDescriptor> =
mediaStoreChangesFlow(context.contentResolver)
.mapLatest { changedUri ->
val targetUri = when {
changedUri == null || isCollectionUri(changedUri) -> findLatestAppSpecificUri()
isAppSpecificUri(changedUri) -> changedUri
else -> null
}

if (targetUri != null) {
getCapturedMedia(targetUri)
} else {
val currentCachedUri = cachedUri
if (currentCachedUri != null && !exists(currentCachedUri)) {
// The current media was deleted. Find the next most recent one.
val fallbackUri = findLatestAppSpecificUri()
if (fallbackUri != null) {
getCapturedMedia(fallbackUri)
} else {
cachedUri = null
cachedMediaDescriptor = null
MediaDescriptor.None
}
} else {
// Something else changed, but our current media is still valid.
cachedMediaDescriptor ?: MediaDescriptor.None
}
}
}
.distinctUntilChanged()
.stateIn(
scope = repositoryScope,
started = SharingStarted.Eagerly,
initialValue = MediaDescriptor.None
)

private fun isCollectionUri(uri: Uri): Boolean {
val segments = uri.pathSegments
return (segments.contains("images") || segments.contains("video")) &&
segments.lastOrNull() == "media"
}

/**
* Sets the current media descriptor.
*
* @param pendingMedia The [MediaDescriptor] to set as current.
*/
override suspend fun setCurrentMedia(pendingMedia: MediaDescriptor) {
_currentMedia.update { pendingMedia }
Log.d(TAG, "set new media $pendingMedia")
}

/**
Expand Down Expand Up @@ -128,12 +211,84 @@ class LocalMediaRepository
return@withContext false
}

private var cachedUri: Uri? = null
private var cachedMediaDescriptor: MediaDescriptor? = null
Comment thread
temcguir marked this conversation as resolved.
Outdated

/**
* Returns the most recent captured media (image or video) from the MediaStore.
* Returns the [MediaDescriptor] for the given [Uri] from the MediaStore.
*
* @return The [MediaDescriptor] of the last captured media, or [MediaDescriptor.None] if no media is found.
* @param uri The [Uri] of the media to retrieve.
* @return The [MediaDescriptor] of the media, or [MediaDescriptor.None] if no media is found.
*/
override suspend fun getLastCapturedMedia(): MediaDescriptor {
private suspend fun getCapturedMedia(uri: Uri): MediaDescriptor {
val cachedDesc = cachedMediaDescriptor
if (uri == cachedUri &&
cachedDesc is MediaDescriptor.Content &&
cachedDesc.thumbnail != null
) {
return cachedDesc
}

val descriptor = if (uri.toString().contains("video")) {
getVideoMediaDescriptor(uri)
} else {
getImageMediaDescriptor(uri)
}

if (descriptor is MediaDescriptor.Content && descriptor.thumbnail != null) {
cachedUri = uri
cachedMediaDescriptor = descriptor
return descriptor
}

if (cachedDesc != null && cachedDesc is MediaDescriptor.Content) {
// The new thumbnail isn't ready yet, so return the old cached image to prevent transient unmounting.
return cachedDesc
}

return descriptor
}

/**
* Checks if the given [Uri] belongs to the app.
*
* @param uri The [Uri] to check.
* @return `true` if the URI is app-specific, `false` otherwise.
*/
private suspend fun isAppSpecificUri(uri: Uri): Boolean = withContext(iODispatcher) {
val projection = mutableListOf(MediaStore.MediaColumns.DISPLAY_NAME)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
projection.add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME)
}

try {
context.contentResolver.query(uri, projection.toTypedArray(), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val ownerColumn =
cursor.getColumnIndex(MediaStore.MediaColumns.OWNER_PACKAGE_NAME)
if (ownerColumn != -1 && !cursor.isNull(ownerColumn)) {
val owner = cursor.getString(ownerColumn)
if (owner == context.packageName) return@withContext true
}
}
val nameColumn =
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val name = cursor.getString(nameColumn)
return@withContext name?.startsWith("JCA") == true
Comment thread
temcguir marked this conversation as resolved.
Outdated
}
}
} catch (e: Exception) {
Log.e(TAG, "Error checking app specificity for $uri", e)
}
false
}

/**
* Returns the most recent app-specific media URI from the MediaStore.
*/
private suspend fun findLatestAppSpecificUri(): Uri? = withContext(iODispatcher) {
val imagePair =
getLastSavedMediaUriWithDate(
context.contentResolver,
Expand All @@ -145,23 +300,13 @@ class LocalMediaRepository
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
)

return if (imagePair != null && videoPair != null) {
// Case 1: BOTH exist. Compare dates.
if (imagePair.second >= videoPair.second) {
getImageMediaDescriptor(imagePair.first)
} else {
getVideoMediaDescriptor(videoPair.first)
}
} else if (imagePair != null) {
// Case 2: Only image exists
getImageMediaDescriptor(imagePair.first)
} else if (videoPair != null) {
// Case 3: Only video exists
getVideoMediaDescriptor(videoPair.first)
val latestPair = if (imagePair != null && videoPair != null) {
if (imagePair.second >= videoPair.second) imagePair else videoPair
} else {
// Case 4: Neither exist
MediaDescriptor.None
imagePair ?: videoPair
}

latestPair?.first
}

/**
Expand Down Expand Up @@ -432,28 +577,10 @@ class LocalMediaRepository
withContext(iODispatcher) {
if (uri.scheme != ContentResolver.SCHEME_CONTENT) {
Log.e(TAG, "URI is not managed by a content provider")
return@withContext null
null
} else {
return@withContext try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.contentResolver.loadThumbnail(uri, Size(640, 480), null)
} else {
if (collectionUri == MediaStore.Images.Media.EXTERNAL_CONTENT_URI) {
MediaStore.Images.Thumbnails.getThumbnail(
context.contentResolver,
ContentUris.parseId(uri),
MediaStore.Images.Thumbnails.MINI_KIND,
null
)
} else { // Video
MediaStore.Video.Thumbnails.getThumbnail(
context.contentResolver,
ContentUris.parseId(uri),
MediaStore.Video.Thumbnails.MINI_KIND,
null
)
}
}
try {
thumbnailLoader(uri, collectionUri)
} catch (e: Exception) {
Log.e(TAG, "Error retrieving thumbnail: ${e.message}", e)
null
Expand Down Expand Up @@ -509,3 +636,27 @@ class LocalMediaRepository
return null
}
}

private fun mediaStoreChangesFlow(contentResolver: ContentResolver): Flow<Uri?> = callbackFlow {
val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
trySend(uri)
}
}
contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true,
observer
)
contentResolver.registerContentObserver(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
true,
observer
)
// Trigger initial emission
trySend(null)

awaitClose {
contentResolver.unregisterContentObserver(observer)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@ import kotlinx.coroutines.flow.StateFlow
*/
interface MediaRepository {
val currentMedia: StateFlow<MediaDescriptor>

/**
* A [StateFlow] that emits the most recently captured media (image or video) from the MediaStore.
*
* The flow will emit a new [MediaDescriptor] whenever a new media item is saved to the
* MediaStore. The initial value is the most recent media available at the time of collection.
*/
val lastCapturedMedia: StateFlow<MediaDescriptor>

suspend fun setCurrentMedia(pendingMedia: MediaDescriptor)
suspend fun getLastCapturedMedia(): MediaDescriptor

suspend fun deleteMedia(mediaDescriptor: MediaDescriptor.Content): Boolean

Expand All @@ -50,13 +58,13 @@ sealed interface MediaDescriptor {
val thumbnail: Bitmap?
val isCached: Boolean

class Image(
data class Image(
override val uri: Uri,
override val thumbnail: Bitmap?,
override val isCached: Boolean = false
) : Content

class Video(
data class Video(
override val uri: Uri,
override val thumbnail: Bitmap?,
override val isCached: Boolean = false
Expand Down
Loading
Loading