Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic Camera sample #382

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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 gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ androidx-test = "1.6.1"
androidx-test-espresso = "3.6.1"
androidx-window = "1.3.0"
androidxHiltNavigationCompose = "1.2.0"
cameraCompose = "1.5.0-alpha02"
coil = "2.7.0"
# @keep
compileSdk = "34"
Expand Down Expand Up @@ -50,6 +51,8 @@ targetSdk = "34"
version-catalog-update = "0.8.4"
wearComposeFoundation = "1.4.0"
wearComposeMaterial = "1.4.0"
junitVersion = "1.2.1"
cameraLifecycle = "1.3.4"

[libraries]
accompanist-adaptive = { module = "com.google.accompanist:accompanist-adaptive", version.ref = "accompanist" }
Expand All @@ -59,6 +62,7 @@ accompanist-theme-adapter-material = { module = "com.google.accompanist:accompan
accompanist-theme-adapter-material3 = { module = "com.google.accompanist:accompanist-themeadapter-material3", version.ref = "accompanist" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraCompose" }
androidx-compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose-latest" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose-latest" }
Expand Down Expand Up @@ -127,6 +131,8 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "playServicesWearable" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" }

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
Expand Down
1 change: 1 addition & 0 deletions media/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
65 changes: 65 additions & 0 deletions media/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}

android {
namespace = "com.example.media"
compileSdk = 35

defaultConfig {
applicationId = "com.example.media"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}

dependencies {

// Add CameraX Compose dependency
implementation(libs.androidx.camera.compose)
implementation("androidx.camera:camera-core:1.3.4")
implementation("androidx.camera:camera-camera2:1.3.4")

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime)
JolandaVerhoef marked this conversation as resolved.
Show resolved Hide resolved
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.camera.lifecycle)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
21 changes: 21 additions & 0 deletions 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
24 changes: 24 additions & 0 deletions media/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

JolandaVerhoef marked this conversation as resolved.
Show resolved Hide resolved
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Snippets">
<activity
android:name=".MediaSnippetsActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Snippets">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
26 changes: 26 additions & 0 deletions media/src/main/java/com/example/media/MediaSnippetsActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.media

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import com.example.media.camera.CameraPreviewScreen
import com.example.media.ui.theme.SnippetsTheme

class MediaSnippetsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SnippetsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
CameraPreviewScreen(Modifier.padding(innerPadding))
}
}
}
}
}
89 changes: 89 additions & 0 deletions media/src/main/java/com/example/media/camera/CameraSnippets.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.example.media.camera

import android.content.Context
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.takePicture
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.lifecycle.awaitInstance
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class CameraPreviewViewModel(private val appContext: Context): ViewModel() {
private val _surfaceRequest = MutableStateFlow<SurfaceRequest?>(null)
val surfaceRequest: StateFlow<SurfaceRequest?> = _surfaceRequest

private lateinit var processCameraProvider: ProcessCameraProvider
private val previewUseCase = Preview.Builder().build()
JolandaVerhoef marked this conversation as resolved.
Show resolved Hide resolved
private val captureUseCase = ImageCapture.Builder().build()
private val useCaseGroup = UseCaseGroup.Builder().apply {
addUseCase(previewUseCase)
addUseCase(captureUseCase)
}.build()

private var runningCameraJob: Job? = null

fun startCamera() {
stopCamera()
runningCameraJob = viewModelScope.launch {
processCameraProvider = ProcessCameraProvider.awaitInstance(appContext)
processCameraProvider.runWith(CameraSelector.DEFAULT_BACK_CAMERA, useCaseGroup) {
previewUseCase.setSurfaceProvider { newSurfaceRequest ->
_surfaceRequest.value = newSurfaceRequest
}
JolandaVerhoef marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

fun stopCamera() {
runningCameraJob?.apply {
if (isActive) {
cancel()
}
}
}

fun takePicture() {
viewModelScope.launch {
val imageProxy = captureUseCase.takePicture()
// Do something with the image
}
}
}

@Composable
fun CameraPreviewScreen(modifier: Modifier = Modifier) {
val viewModel = CameraPreviewViewModel(LocalContext.current.applicationContext)

Choose a reason for hiding this comment

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

ViewModel should be retrieved via androidx.lifecycle.viewmodel.compose.viewModel. Something like:

@Composable
fun CameraPreviewScreen(
    modifier: Modifier = Modifier,
    viewModel: CameraPreviewViewModel = viewModel()
) {
    val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()

val surfaceRequest by viewModel.surfaceRequest.collectAsStateWithLifecycle()

LifecycleStartEffect(Unit) {
viewModel.startCamera()

Choose a reason for hiding this comment

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

We can just pass the context to the camera here instead of through the ViewModel constructor. This is to align with the advice from https://developer.android.com/topic/architecture/recommendations#viewmodel where it says not to use AndroidViewModel. Though I wonder if there is still a better way to pass the context in.

    val context = LocalContext.current
    LifecycleStartEffect(Unit) {
        viewModel.startCamera(context)
        onStopOrDispose {
            viewModel.stopCamera()
        }
    }

JolandaVerhoef marked this conversation as resolved.
Show resolved Hide resolved
onStopOrDispose {
viewModel.stopCamera()
}
}

surfaceRequest?.let { CameraPreview(modifier, it) }
}

@Composable
private fun CameraPreview(
modifier: Modifier = Modifier,
surfaceRequest: SurfaceRequest
) {
CameraXViewfinder(surfaceRequest, modifier)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example.media.camera

import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.UseCaseGroup
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlin.coroutines.CoroutineContext

/**
* Runs a camera for the duration of a coroutine.
*
* The camera selected by [cameraSelector] will run with the provided [useCases] for the
* duration that [block] is active. This means that [block] should suspend until the camera
* should be closed.
*/
suspend fun <R> ProcessCameraProvider.runWith(
cameraSelector: CameraSelector,
useCases: UseCaseGroup,
block: suspend CoroutineScope.(Camera) -> R
): R = coroutineScope {
val scopedLifecycle = CoroutineLifecycleOwner(coroutineContext)
block([email protected](scopedLifecycle, cameraSelector, useCases))
}

/**
* A [LifecycleOwner] that follows the lifecycle of a coroutine.
*
* If the coroutine is active, the owned lifecycle will jump to a
* [Lifecycle.State.RESUMED] state. When the coroutine completes, the owned lifecycle will
* transition to a [Lifecycle.State.DESTROYED] state.
*/
private class CoroutineLifecycleOwner(coroutineContext: CoroutineContext) :
LifecycleOwner {
private val lifecycleRegistry: LifecycleRegistry =
LifecycleRegistry(this).apply {
currentState = Lifecycle.State.INITIALIZED
}

override val lifecycle: Lifecycle
get() = lifecycleRegistry

init {
if (coroutineContext[Job]?.isActive == true) {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
coroutineContext[Job]?.invokeOnCompletion {
lifecycleRegistry.apply {
currentState = Lifecycle.State.STARTED
currentState = Lifecycle.State.CREATED
currentState = Lifecycle.State.DESTROYED
}
}
} else {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
}
}
11 changes: 11 additions & 0 deletions media/src/main/java/com/example/media/ui/theme/Color.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example.media.ui.theme

import androidx.compose.ui.graphics.Color

val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
Loading