Integrate image capture use case into PreviewScreen
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index c3a8621..db3e200 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,11 +7,14 @@
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
+ <option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/camera-viewfinder-compose" />
+ <option value="$PROJECT_DIR$/core" />
+ <option value="$PROJECT_DIR$/core/common" />
<option value="$PROJECT_DIR$/data" />
<option value="$PROJECT_DIR$/data/settings" />
<option value="$PROJECT_DIR$/domain" />
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 9f71c83..0ad17cb 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
- <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
+ <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
diff --git a/core/common/.gitignore b/core/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common/build.gradle b/core/common/build.gradle
new file mode 100644
index 0000000..65128bd
--- /dev/null
+++ b/core/common/build.gradle
@@ -0,0 +1,51 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ id 'kotlin-kapt'
+ id 'com.google.dagger.hilt.android'
+}
+
+android {
+ namespace 'com.google.jetpackcamera.core.common'
+ compileSdk 33
+
+ defaultConfig {
+ minSdk 21
+ targetSdk 33
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlin {
+ jvmToolchain(17)
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.core:core-ktx:1.8.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.8.0'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+
+ // Hilt
+ implementation "com.google.dagger:hilt-android:2.44"
+ kapt "com.google.dagger:hilt-compiler:2.44"
+}
+
+kapt {
+ correctErrorTypes true
+}
diff --git a/core/common/consumer-rules.pro b/core/common/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/core/common/consumer-rules.pro
diff --git a/core/common/proguard-rules.pro b/core/common/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/common/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/core/common/src/main/AndroidManifest.xml b/core/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d81265f
--- /dev/null
+++ b/core/common/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+<?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>
\ No newline at end of file
diff --git a/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
new file mode 100644
index 0000000..95c0f82
--- /dev/null
+++ b/core/common/src/main/java/com/google/jetpackcamera/core/common/CommonModule.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+package com.google.jetpackcamera.core.common
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+
+
+/**
+ * Dagger [Module] for Common dependencies.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+class CommonModule {
+
+ @Provides
+ fun provideDefaultDispatcher() : CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default
+}
\ No newline at end of file
diff --git a/domain/camera/build.gradle b/domain/camera/build.gradle
index b3fbf63..8bf17a1 100644
--- a/domain/camera/build.gradle
+++ b/domain/camera/build.gradle
@@ -54,6 +54,8 @@
// Hilt
implementation "com.google.dagger:hilt-android:2.44"
kapt "com.google.dagger:hilt-compiler:2.44"
+
+ implementation(project(":core:common"))
}
// Allow references to generated code
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
index f6cc80c..5779669 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraUseCase.kt
@@ -41,4 +41,6 @@
surfaceProvider: Preview.SurfaceProvider,
@CameraSelector.LensFacing lensFacing: Int
)
+
+ suspend fun takePicture()
}
\ No newline at end of file
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
index ae45237..d060dd7 100644
--- a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/CameraXCameraUseCase.kt
@@ -22,23 +22,29 @@
import androidx.camera.core.CameraSelector
import androidx.camera.core.CameraSelector.LensFacing
import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.UseCaseGroup
import androidx.camera.core.ViewPort
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.concurrent.futures.await
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject
-private const val TAG = "CameraXCameraRepository"
+private const val TAG = "CameraXCameraUseCase"
private val ASPECT_RATIO_16_9 = Rational(16, 9)
/**
* CameraX based implementation for [CameraUseCase]
*/
class CameraXCameraUseCase @Inject constructor(
- private val application: Application
+ private val application: Application,
+ private val defaultDispatcher: CoroutineDispatcher
) : CameraUseCase {
private lateinit var cameraProvider: ProcessCameraProvider
@@ -83,6 +89,24 @@
}
}
+ override suspend fun takePicture() {
+ val imageDeferred = CompletableDeferred<ImageProxy>()
+
+ imageCaptureUseCase.takePicture(
+ defaultDispatcher.asExecutor(),
+ object : ImageCapture.OnImageCapturedCallback() {
+ override fun onCaptureSuccess(imageProxy: ImageProxy) {
+ Log.d(TAG, "onCaptureSuccess")
+ imageDeferred.complete(imageProxy)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ super.onError(exception)
+ Log.d(TAG, "takePicture onError: $exception")
+ }
+ })
+ }
+
private fun cameraLensToSelector(@LensFacing lensFacing: Int): CameraSelector =
when (lensFacing) {
CameraSelector.LENS_FACING_FRONT -> CameraSelector.DEFAULT_FRONT_CAMERA
diff --git a/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
new file mode 100644
index 0000000..d0d63c0
--- /dev/null
+++ b/domain/camera/src/main/java/com/google/jetpackcamera/domain/camera/test/FakeCameraUseCase.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+package com.google.jetpackcamera.domain.camera.test
+
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.lifecycle.LifecycleOwner
+import com.google.jetpackcamera.domain.camera.CameraUseCase
+import kotlin.IllegalStateException
+
+class FakeCameraUseCase : CameraUseCase {
+
+ private val availableLenses = listOf(CameraSelector.LENS_FACING_FRONT, CameraSelector.LENS_FACING_BACK)
+ private var initialized = false
+ private var useCasesBinded = false
+
+ var previewStarted = false
+ var numPicturesTaken = 0
+
+ override suspend fun initialize(): List<Int> {
+ initialized = true
+ return availableLenses
+ }
+
+ override suspend fun runCamera(
+ surfaceProvider: Preview.SurfaceProvider,
+ lensFacing: Int
+ ) {
+ if (!initialized) {
+ throw IllegalStateException("CameraProvider not initialized")
+ }
+ if (!availableLenses.contains(lensFacing)) {
+ throw IllegalStateException("Requested lens not available")
+ }
+ useCasesBinded = true
+ previewStarted = true
+ }
+
+ override suspend fun takePicture() {
+ if(!useCasesBinded) {
+ throw IllegalStateException("Usecases not binded")
+ }
+ numPicturesTaken += 1
+ }
+}
\ No newline at end of file
diff --git a/feature/preview/build.gradle b/feature/preview/build.gradle
index 01268e9..8cf1775 100644
--- a/feature/preview/build.gradle
+++ b/feature/preview/build.gradle
@@ -36,6 +36,9 @@
composeOptions {
kotlinCompilerExtensionVersion '1.4.0'
}
+ testOptions {
+ unitTests.returnDefaultValues = true
+ }
}
dependencies {
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
index d07061a..cfc7b60 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
@@ -18,18 +18,29 @@
import android.util.Log
import androidx.camera.core.Preview.SurfaceProvider
+import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.google.jetpackcamera.viewfinder.CameraPreview
import androidx.hilt.navigation.compose.hiltViewModel
@@ -45,7 +56,7 @@
*/
@Composable
fun PreviewScreen(
- onNavigateToSettings : () -> Unit,
+ onNavigateToSettings: () -> Unit,
viewModel: PreviewViewModel = hiltViewModel()
) {
Log.d(TAG, "ViewFinder")
@@ -75,7 +86,9 @@
if (previewUiState.cameraState == CameraState.NOT_READY) {
Text(text = stringResource(R.string.camera_not_ready))
} else if (previewUiState.cameraState == CameraState.READY) {
- Box {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
CameraPreview(
modifier = Modifier
.fillMaxSize()
@@ -93,9 +106,38 @@
}
)
- Button(onClick = onNavigateToSettings) {
- Text(text = "Settings")
+ IconButton(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(12.dp),
+ onClick = onNavigateToSettings,
+ ) {
+ Icon(
+ Icons.Filled.Settings,
+ contentDescription = stringResource(R.string.settings_content_description),
+ modifier = Modifier.size(72.dp)
+ )
+ }
+
+ Box(
+ modifier = Modifier.align(Alignment.BottomCenter)
+ ) {
+ CaptureButton(
+ onClick = { viewModel.captureImage() }
+ )
}
}
}
-}
\ No newline at end of file
+}
+
+@Composable
+fun CaptureButton(onClick: () -> Unit) {
+ Button(
+ onClick = onClick,
+ shape = CircleShape,
+ modifier = Modifier
+ .size(120.dp)
+ .padding(18.dp)
+ .border(4.dp, Color.White, CircleShape)
+ ) {}
+}
diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
index e0dc2ab..c7d8b7f 100644
--- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
+++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewViewModel.kt
@@ -16,6 +16,8 @@
package com.google.jetpackcamera.feature.preview
+import android.util.Log
+import androidx.camera.core.ImageCaptureException
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -29,6 +31,8 @@
import kotlinx.coroutines.launch
import javax.inject.Inject
+private const val TAG = "PreviewViewModel"
+
/**
* [ViewModel] for [PreviewScreen].
*/
@@ -80,4 +84,17 @@
fun flipCamera() {
// TODO(yasith)
}
+
+ fun captureImage() {
+ Log.d(TAG, "captureImage")
+ viewModelScope.launch {
+ try {
+ cameraUseCase.takePicture()
+ Log.d(TAG, "cameraUseCase.takePicture success")
+ } catch (exception: ImageCaptureException) {
+ Log.d(TAG, "cameraUseCase.takePicture error")
+ Log.d(TAG, exception.toString())
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/feature/preview/src/main/res/values/strings.xml b/feature/preview/src/main/res/values/strings.xml
index 45e1b96..1bf9d00 100644
--- a/feature/preview/src/main/res/values/strings.xml
+++ b/feature/preview/src/main/res/values/strings.xml
@@ -16,4 +16,5 @@
<resources>
<string name="camera_not_ready">Camera Not Ready</string>
+ <string name="settings_content_description">Settings</string>
</resources>
\ No newline at end of file
diff --git a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
index 80327e8..5480757 100644
--- a/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
+++ b/feature/preview/src/test/java/com/google/jetpackcamera/feature/preview/PreviewViewModelTest.kt
@@ -17,8 +17,8 @@
package com.google.jetpackcamera.feature.preview
+import com.google.jetpackcamera.domain.camera.test.FakeCameraUseCase
import androidx.camera.core.Preview.SurfaceProvider
-import com.google.jetpackcamera.domain.camera.CameraUseCase
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -28,44 +28,44 @@
import kotlinx.coroutines.test.setMain
import org.junit.Before
import org.junit.Test
-import org.mockito.Mock
import org.mockito.Mockito.mock
-import org.mockito.Mockito.verify
-import org.mockito.MockitoAnnotations
@OptIn(ExperimentalCoroutinesApi::class)
class PreviewViewModelTest {
- @Mock private lateinit var mockCameraUseCase : CameraUseCase
+ private val cameraUseCase = FakeCameraUseCase()
private lateinit var previewViewModel : PreviewViewModel
@Before
- fun setup() {
- MockitoAnnotations.initMocks(this)
+ fun setup() = runTest(StandardTestDispatcher()) {
Dispatchers.setMain(StandardTestDispatcher())
- previewViewModel = PreviewViewModel(mockCameraUseCase)
+ previewViewModel = PreviewViewModel(cameraUseCase)
+ advanceUntilIdle()
}
@Test
fun getPreviewUiState() = runTest(StandardTestDispatcher()) {
- var uiState = previewViewModel.previewUiState.value
- assertEquals(CameraState.NOT_READY, uiState.cameraState)
advanceUntilIdle()
- uiState = previewViewModel.previewUiState.value
+ val uiState = previewViewModel.previewUiState.value
assertEquals(CameraState.READY, uiState.cameraState)
}
@Test
- fun runCamera() = runTest(StandardTestDispatcher()) {
+ fun runCamera() = runTest(StandardTestDispatcher()){
val surfaceProvider : SurfaceProvider = mock()
previewViewModel.runCamera(surfaceProvider)
advanceUntilIdle()
- val previewUiState = previewViewModel.previewUiState.value
- verify(mockCameraUseCase).runCamera(
- surfaceProvider,
- previewUiState.lensFacing
- )
+ assertEquals(cameraUseCase.previewStarted, true)
+ }
+
+ @Test
+ fun captureImage() = runTest(StandardTestDispatcher()){
+ val surfaceProvider : SurfaceProvider = mock()
+ previewViewModel.runCamera(surfaceProvider)
+ previewViewModel.captureImage()
+ advanceUntilIdle()
+ assertEquals(cameraUseCase.numPicturesTaken, 1)
}
@Test
diff --git a/settings.gradle b/settings.gradle
index 8cc5cfd..66cca44 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -19,3 +19,4 @@
include ':camera-viewfinder-compose'
include ':feature:settings'
include ':data:settings'
+include ':core:common'