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'