Skip to content
Draft
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
1 change: 1 addition & 0 deletions data/media/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
95 changes: 95 additions & 0 deletions data/media/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.dagger.hilt.android)
}

android {
namespace = "com.google.jetpackcamera.data.media"
compileSdk = libs.versions.compileSdk.get().toInt()

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testOptions.targetSdk = libs.versions.targetSdk.get().toInt()
lint.targetSdk = libs.versions.targetSdk.get().toInt()

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

flavorDimensions += "flavor"
productFlavors {
create("stable") {
dimension = "flavor"
isDefault = true
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}

@Suppress("UnstableApiUsage")
testOptions {
managedDevices {
localDevices {
create("pixel2Api28") {
device = "Pixel 2"
apiLevel = 28
}
create("pixel8Api34") {
device = "Pixel 8"
apiLevel = 34
systemImageSource = "aosp_atd"
}
}
}
unitTests {
isReturnDefaultValues = true
}
}
}

dependencies {
implementation(libs.kotlinx.coroutines.core)

// Hilt
implementation(libs.dagger.hilt.android)
implementation(libs.core.ktx)
kapt(libs.dagger.hilt.compiler)

// Testing
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.truth)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)

// Project dependencies
implementation(project(":core:common"))
}

// Allow references to generated code
kapt {
correctErrorTypes = true
}
Empty file added data/media/consumer-rules.pro
Empty file.
21 changes: 21 additions & 0 deletions data/media/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
19 changes: 19 additions & 0 deletions data/media/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2023 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.data.media

import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Size
import com.google.jetpackcamera.core.common.IODispatcher
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext

class LocalMediaRepository
@Inject constructor(
private val context: Context,
@IODispatcher private val iODispatcher: CoroutineDispatcher
) : MediaRepository {

override suspend fun load(mediaDescriptor: MediaDescriptor): Media {
Copy link
Collaborator

@Kimblebee Kimblebee Mar 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i think it would be helpful for fresh eyes down the road to include some kdocs that describe the significance between what load() and getLastCapturedMedia() are doing.

return when (mediaDescriptor) {
is MediaDescriptor.Image -> loadImage(mediaDescriptor.uri)
MediaDescriptor.None -> Media.None
is MediaDescriptor.Video -> Media.Video(mediaDescriptor.uri)
}
}

override suspend fun getLastCapturedMedia(): MediaDescriptor {
val imagePair =
getLastMediaUriWithDate(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
val videoPair =
getLastMediaUriWithDate(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)

return when {
imagePair == null && videoPair == null -> MediaDescriptor.None
imagePair == null && videoPair != null -> getVideoMediaDescriptor(videoPair.first)
videoPair == null && imagePair != null -> getImageMediaDescriptor(imagePair.first)
imagePair != null && videoPair != null -> {
if (imagePair.second > videoPair.second) {
getImageMediaDescriptor(imagePair.first)
} else {
getVideoMediaDescriptor(videoPair.first)
}
}

else -> MediaDescriptor.None // Should not happen
}
}

private suspend fun loadImage(uri: Uri): Media = withContext(iODispatcher) {
try {
val loadedBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10 (API 29) and above: Use ImageDecoder
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
} else {
// Android 9 (API 28) and below: Use BitmapFactory
context.contentResolver.openInputStream(uri)?.use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
Comment on lines +69 to +78
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are using BitmapFactory for both of these cases

}

return@withContext if(loadedBitmap != null) {
Media.Image(loadedBitmap)
} else {
Media.Error
}

} catch (e: Exception) {
e.printStackTrace()
return@withContext Media.Error
}
}

private suspend fun getVideoMediaDescriptor(uri: Uri): MediaDescriptor {
val thumbnail = getThumbnail(uri, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
return MediaDescriptor.Video(uri, thumbnail)
}

private suspend fun getImageMediaDescriptor(uri: Uri): MediaDescriptor {
val thumbnail = getThumbnail(uri, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
return MediaDescriptor.Image(uri, thumbnail)
}

private suspend fun getThumbnail(uri: Uri, collectionUri: Uri): Bitmap? =
withContext(iODispatcher) {
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
)
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}

private fun getLastMediaUriWithDate(context: Context, collectionUri: Uri): Pair<Uri, Long>? {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATE_ADDED
)

// Filter by filenames starting with "JCA"
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("JCA%")

// Sort the results so that the most recently added media appears first.
val sortOrder = "${MediaStore.MediaColumns.DATE_ADDED} DESC"

// Perform the query on the MediaStore.
context.contentResolver.query(
collectionUri,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val dateAddedColumn = cursor.getColumnIndexOrThrow(
MediaStore.MediaColumns.DATE_ADDED
)

val id = cursor.getLong(idColumn)
val dateAdded = cursor.getLong(dateAddedColumn)

val uri = ContentUris.withAppendedId(collectionUri, id)
return Pair(uri, dateAdded)
}
}
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.data.media

import android.content.Context
import com.google.jetpackcamera.core.common.IODispatcher
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher

/**
* Dagger [Module] for Media dependencies.
*/
@Module
@InstallIn(SingletonComponent::class)
class MediaModule {

@Provides
@Singleton
fun provideMediaRepository(
@ApplicationContext context: Context,
@IODispatcher ioDispatcher: CoroutineDispatcher
): MediaRepository = LocalMediaRepository(context, ioDispatcher)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.data.media

import android.graphics.Bitmap
import android.net.Uri

/**
* Data layer for Media.
*/
interface MediaRepository {
suspend fun getLastCapturedMedia(): MediaDescriptor
suspend fun load(mediaDescriptor: MediaDescriptor): Media
}

/**
* Descriptors used for [Media].
*/
sealed class MediaDescriptor {
data object None : MediaDescriptor()
class Image(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor()
class Video(val uri: Uri, val thumbnail: Bitmap?) : MediaDescriptor()
}

/**
* Media items that are supported by [MediaRepository].
*/
sealed class Media {
data object None : Media()
data object Error : Media()
class Image(val bitmap: Bitmap) : Media()
class Video(val uri: Uri) : Media()
}
Loading