diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 4f15139d..4021e1c3 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -130,4 +130,4 @@
-
\ No newline at end of file
+
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index 97cc9ab3..76d199e2 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -18,4 +18,4 @@
-
\ No newline at end of file
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a062f250..71a43462 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -53,7 +53,7 @@ android {
}
kotlinOptions {
jvmTarget = "17"
- freeCompilerArgs = listOf("-Xcontext-receivers")
+ freeCompilerArgs += listOf("-Xcontext-receivers")
}
buildFeatures {
compose = true
diff --git a/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt b/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt
index ea489cfa..8fbb87f2 100644
--- a/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt
@@ -19,20 +19,32 @@ package com.google.android.samples.socialite
import android.content.Intent
import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.glance.appwidget.updateAll
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.samples.socialite.domain.FoldingState
+import com.google.android.samples.socialite.ui.LocalFoldingState
import com.google.android.samples.socialite.ui.Main
import com.google.android.samples.socialite.ui.ShortcutParams
+import com.google.android.samples.socialite.util.DisplayFeaturesMonitor
import com.google.android.samples.socialite.widget.SociaLiteAppWidget
import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import kotlinx.coroutines.runBlocking
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var displayFeaturesMonitor: DisplayFeaturesMonitor
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
@@ -41,10 +53,19 @@ class MainActivity : ComponentActivity() {
}
super.onCreate(savedInstanceState)
runBlocking { SociaLiteAppWidget().updateAll(this@MainActivity) }
+ val windowParams: WindowManager.LayoutParams = window.attributes
+ windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
+ window.attributes = windowParams
setContent {
- Main(
- shortcutParams = extractShortcutParams(intent),
- )
+ val foldingState by displayFeaturesMonitor.foldingState.collectAsStateWithLifecycle(initialValue = FoldingState.CLOSE)
+
+ CompositionLocalProvider(
+ LocalFoldingState provides foldingState,
+ ) {
+ Main(
+ shortcutParams = extractShortcutParams(intent),
+ )
+ }
}
}
diff --git a/app/src/main/java/com/google/android/samples/socialite/di/CameraModule.kt b/app/src/main/java/com/google/android/samples/socialite/di/CameraModule.kt
new file mode 100644
index 00000000..bc931829
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/di/CameraModule.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.di
+
+import android.content.ContentValues
+import android.content.Context
+import android.os.Build
+import android.provider.MediaStore
+import androidx.camera.core.ImageCapture.OutputFileOptions
+import androidx.camera.video.MediaStoreOutputOptions
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import java.text.SimpleDateFormat
+import java.util.Locale
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class ImageContentValues
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class VideoContentValues
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CameraModule {
+
+ @Provides
+ @ImageContentValues
+ fun providesImageContentValues(): ContentValues = ContentValues().apply {
+ val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
+ .format(System.currentTimeMillis())
+
+ put(MediaStore.Images.Media.DISPLAY_NAME, name)
+ put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
+ put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/SociaLite")
+ }
+ }
+
+ @Provides
+ @VideoContentValues
+ fun providesVideoContentValues(): ContentValues = ContentValues().apply {
+ val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
+ .format(System.currentTimeMillis()) + ".mp4"
+
+ put(MediaStore.Video.Media.DISPLAY_NAME, name)
+ put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
+
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
+ put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/SociaLite")
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun providesImageCaptureOutputFileOptions(
+ @ApplicationContext context: Context,
+ @ImageContentValues contentValues: ContentValues,
+ ): OutputFileOptions =
+ OutputFileOptions.Builder(
+ context.contentResolver,
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ contentValues,
+ ).build()
+
+ @Provides
+ @Singleton
+ fun providesMediaStoreOutputOptions(
+ @ApplicationContext context: Context,
+ @VideoContentValues contentValues: ContentValues,
+ ): MediaStoreOutputOptions =
+ MediaStoreOutputOptions.Builder(
+ context.contentResolver,
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ )
+ .setContentValues(contentValues)
+ .build()
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/di/ManagerModule.kt b/app/src/main/java/com/google/android/samples/socialite/di/ManagerModule.kt
new file mode 100644
index 00000000..177120c6
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/di/ManagerModule.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.di
+
+import com.google.android.samples.socialite.util.DisplayFeaturesMonitor
+import com.google.android.samples.socialite.util.DisplayFeaturesMonitorImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ActivityComponent
+
+@Module
+@InstallIn(ActivityComponent::class)
+abstract class ManagerModule {
+
+ @Binds
+ abstract fun bindDisplayFeaturesMonitor(displayFeaturesMonitor: DisplayFeaturesMonitorImpl): DisplayFeaturesMonitor
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/di/UseCaseModule.kt b/app/src/main/java/com/google/android/samples/socialite/di/UseCaseModule.kt
new file mode 100644
index 00000000..2aa383f7
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/di/UseCaseModule.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.di
+
+import com.google.android.samples.socialite.domain.CameraUseCase
+import com.google.android.samples.socialite.domain.CameraXUseCase
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+interface UseCaseModule {
+
+ @Binds
+ fun bindsCameraUseCase(cameraXUseCase: CameraXUseCase): CameraUseCase
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/domain/CameraUseCase.kt b/app/src/main/java/com/google/android/samples/socialite/domain/CameraUseCase.kt
new file mode 100644
index 00000000..fe658dfa
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/domain/CameraUseCase.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.domain
+
+import android.content.Context
+import android.net.Uri
+import android.util.Rational
+import androidx.annotation.RequiresPermission
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.Preview
+import androidx.camera.core.UseCaseGroup
+import androidx.camera.core.ViewPort
+import androidx.camera.video.MediaStoreOutputOptions
+import androidx.camera.video.Quality
+import androidx.camera.video.QualitySelector
+import androidx.camera.video.Recorder
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoRecordEvent
+import androidx.media3.common.util.UnstableApi
+import com.google.android.samples.socialite.ui.camera.CaptureMode
+import com.google.android.samples.socialite.ui.camera.Media
+import com.google.android.samples.socialite.ui.camera.MediaType
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.scopes.ViewModelScoped
+import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.asExecutor
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+interface CameraUseCase {
+ fun createUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup
+ suspend fun capturePhoto(): Uri?
+ suspend fun startVideoRecording(): Media
+ fun stopVideoRecording()
+}
+
+@ViewModelScoped
+class CameraXUseCase @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val imageOutputFileOptions: ImageCapture.OutputFileOptions,
+ private val mediaStoreOutputOptions: MediaStoreOutputOptions,
+) : CameraUseCase {
+
+ private val previewUseCase: Preview = Preview.Builder().build()
+ private val imageCaptureUseCase: ImageCapture = ImageCapture.Builder()
+ .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
+ .build()
+
+ private val videoCaptureUseCase: VideoCapture = run {
+ val recorder = Recorder.Builder()
+ .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
+ .build()
+
+ VideoCapture.Builder(recorder).build()
+ }
+ private var recording: Recording? = null
+
+ override fun createUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup {
+ val useCaseGroupBuilder = UseCaseGroup.Builder()
+
+ previewUseCase.setSurfaceProvider(cameraSettings.surfaceProvider)
+
+ useCaseGroupBuilder.setViewPort(
+ ViewPort.Builder(
+ cameraSettings.aspectRatioType.ratio,
+ previewUseCase.targetRotation,
+ )
+ .build(),
+ )
+
+ useCaseGroupBuilder.addUseCase(previewUseCase)
+ useCaseGroupBuilder.addUseCase(imageCaptureUseCase)
+ useCaseGroupBuilder.addUseCase(videoCaptureUseCase)
+
+ return useCaseGroupBuilder.build()
+ }
+
+ override suspend fun capturePhoto(): Uri? = suspendCancellableCoroutine { continuation ->
+ imageCaptureUseCase.takePicture(
+ imageOutputFileOptions,
+ Dispatchers.Default.asExecutor(),
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
+ val saveUri = outputFileResults.savedUri
+ continuation.resume(saveUri)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ continuation.resumeWithException(exception)
+ }
+ },
+ )
+ }
+
+ @UnstableApi
+ @RequiresPermission(android.Manifest.permission.RECORD_AUDIO)
+ override suspend fun startVideoRecording(): Media =
+ suspendCancellableCoroutine { continuation ->
+ recording = videoCaptureUseCase.output
+ .prepareRecording(context, mediaStoreOutputOptions)
+ .apply { withAudioEnabled() }
+ .start(Dispatchers.Default.asExecutor()) { event ->
+ if (event is VideoRecordEvent.Finalize) {
+ if (event.outputResults.outputUri.toString().isBlank()) {
+ continuation.resumeWithException(IllegalStateException("Video recording failed"))
+ }
+
+ val media = Media(event.outputResults.outputUri, MediaType.VIDEO)
+ continuation.resume(media)
+ }
+ }
+ }
+
+ override fun stopVideoRecording() {
+ recording?.stop() ?: return
+ this.recording = null
+ }
+}
+
+data class CameraSettings(
+ val cameraLensFacing: Int = CameraSelector.LENS_FACING_BACK,
+ val captureMode: CaptureMode = CaptureMode.PHOTO,
+ val zoomScale: Float = 1f,
+ val aspectRatioType: AspectRatioType = AspectRatioType.RATIO_9_16,
+ val foldingState: FoldingState = FoldingState.CLOSE,
+ val surfaceProvider: Preview.SurfaceProvider? = null,
+)
+
+enum class FoldingState {
+ CLOSE,
+ HALF_OPEN,
+ FLAT,
+}
+
+enum class AspectRatioType(val ratio: Rational) {
+ RATIO_4_3(Rational(4, 3)),
+ RATIO_9_16(Rational(9, 16)),
+ RATIO_16_9(Rational(16, 9)),
+ RATIO_1_1(Rational(1, 1)),
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/CompositionLocal.kt b/app/src/main/java/com/google/android/samples/socialite/ui/CompositionLocal.kt
new file mode 100644
index 00000000..16a485e4
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/CompositionLocal.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.ui
+
+import androidx.compose.runtime.compositionLocalOf
+import com.google.android.samples.socialite.domain.FoldingState
+
+val LocalFoldingState = compositionLocalOf { FoldingState.CLOSE }
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/DevicePreview.kt b/app/src/main/java/com/google/android/samples/socialite/ui/DevicePreview.kt
new file mode 100644
index 00000000..906ef927
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/DevicePreview.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.ui
+
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(name = "Fold", device = Devices.PIXEL_FOLD)
+@Preview(name = "Normal", device = Devices.PIXEL_7_PRO)
+annotation class DevicePreview
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/Main.kt b/app/src/main/java/com/google/android/samples/socialite/ui/Main.kt
index 6f923a8c..e9be22f5 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/Main.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/Main.kt
@@ -39,10 +39,11 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
+import com.google.android.samples.socialite.domain.FoldingState
import com.google.android.samples.socialite.model.extractChatId
-import com.google.android.samples.socialite.ui.camera.Camera
-import com.google.android.samples.socialite.ui.camera.Media
-import com.google.android.samples.socialite.ui.camera.MediaType
+import com.google.android.samples.socialite.ui.camera.navigation.CAMERA_ROUTE
+import com.google.android.samples.socialite.ui.camera.navigation.cameraScreen
+import com.google.android.samples.socialite.ui.camera.navigation.navigateToCamera
import com.google.android.samples.socialite.ui.chat.ChatScreen
import com.google.android.samples.socialite.ui.home.Home
import com.google.android.samples.socialite.ui.photopicker.navigation.navigateToPhotoPicker
@@ -66,13 +67,14 @@ fun MainNavigation(
shortcutParams: ShortcutParams?,
) {
val activity = LocalContext.current as Activity
+ val foldingState = LocalFoldingState.current
val navController = rememberNavController()
navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, _: Bundle? ->
// Lock the layout of the Camera screen to portrait so that the UI layout remains
// constant, even on orientation changes. Note that the camera is still aware of
// orientation, and will assign the correct edge as the bottom of the photo or video.
- if (navDestination.route?.contains("camera") == true) {
+ if (navDestination.route == CAMERA_ROUTE && foldingState == FoldingState.CLOSE) {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
} else {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -117,41 +119,18 @@ fun MainNavigation(
chatId = chatId,
foreground = true,
onBackPressed = { navController.popBackStack() },
- onCameraClick = { navController.navigate("chat/$chatId/camera") },
+ onCameraClick = { navController.navigateToCamera(chatId) },
onPhotoPickerClick = { navController.navigateToPhotoPicker(chatId) },
onVideoClick = { uri -> navController.navigate("videoPlayer?uri=$uri") },
prefilledText = text,
modifier = Modifier.fillMaxSize(),
)
}
- composable(
- route = "chat/{chatId}/camera",
- arguments = listOf(
- navArgument("chatId") { type = NavType.LongType },
- ),
- ) { backStackEntry ->
- val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
- Camera(
- onMediaCaptured = { capturedMedia: Media? ->
- when (capturedMedia?.mediaType) {
- MediaType.PHOTO -> {
- navController.popBackStack()
- }
-
- MediaType.VIDEO -> {
- navController.navigate("videoEdit?uri=${capturedMedia.uri}&chatId=$chatId")
- }
- else -> {
- // No media to use.
- navController.popBackStack()
- }
- }
- },
- chatId = chatId,
- modifier = Modifier.fillMaxSize(),
- )
- }
+ cameraScreen(
+ onBackPressed = navController::popBackStack,
+ onVideoEditClick = { uri -> navController.navigate(uri) },
+ )
// Invoke PhotoPicker to select photo or video from device gallery
photoPickerScreen(
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/Camera.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/Camera.kt
index fd81f344..da1941d5 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/camera/Camera.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/Camera.kt
@@ -17,23 +17,25 @@
package com.google.android.samples.socialite.ui.camera
import android.Manifest
-import android.annotation.SuppressLint
-import android.view.Surface
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.Preview
-import androidx.camera.view.RotationProvider
+import android.content.res.Configuration.ORIENTATION_LANDSCAPE
+import android.content.res.Configuration.ORIENTATION_PORTRAIT
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -46,39 +48,77 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.window.layout.FoldingFeature
-import androidx.window.layout.WindowInfoTracker
+import androidx.lifecycle.viewModelScope
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
-import kotlin.reflect.KFunction1
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.asExecutor
+import com.google.android.samples.socialite.R
+import com.google.android.samples.socialite.domain.AspectRatioType
+import com.google.android.samples.socialite.domain.CameraSettings
+import com.google.android.samples.socialite.domain.FoldingState
+import com.google.android.samples.socialite.ui.DevicePreview
+import com.google.android.samples.socialite.ui.LocalFoldingState
+import com.google.android.samples.socialite.ui.SocialTheme
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
-@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Camera(
- chatId: Long,
+ onBackPressed: () -> Unit,
onMediaCaptured: (Media?) -> Unit,
modifier: Modifier = Modifier,
viewModel: CameraViewModel = hiltViewModel(),
) {
- var surfaceProvider by remember { mutableStateOf(null) }
- var cameraSelector by remember { mutableStateOf(CameraSelector.DEFAULT_BACK_CAMERA) }
- var captureMode by remember { mutableStateOf(CaptureMode.PHOTO) }
+ val foldingState = LocalFoldingState.current
+ val orientation = LocalConfiguration.current.orientation
+
+ viewModel.setCameraOrientation(
+ foldingState,
+ orientation == ORIENTATION_PORTRAIT,
+ )
+
+ val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle()
+
+ LifecycleResumeEffect(key1 = Unit) {
+ val job = viewModel.mediaCapture
+ .onEach { onMediaCaptured(it) }
+ .launchIn(viewModel.viewModelScope)
+
+ onPauseOrDispose {
+ job.cancel()
+ }
+ }
+
+ CameraPermissionHandle(
+ onBackPressed = onBackPressed,
+ modifier = modifier,
+ ) {
+ CameraContent(
+ cameraSettings = cameraSettings,
+ onCameraEvent = viewModel::setUserEvent,
+ onBackPressed = onBackPressed,
+ modifier = modifier,
+ )
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+private fun CameraPermissionHandle(
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
val cameraAndRecordAudioPermissionState = rememberMultiplePermissionsState(
listOf(
Manifest.permission.CAMERA,
@@ -86,232 +126,238 @@ fun Camera(
),
)
- viewModel.setChatId(chatId)
-
- val lifecycleOwner = LocalLifecycleOwner.current
- val context = LocalContext.current
-
- var isLayoutUnfolded by remember { mutableStateOf(null) }
-
- LaunchedEffect(lifecycleOwner, context) {
- val windowInfoTracker = WindowInfoTracker.getOrCreate(context)
- windowInfoTracker.windowLayoutInfo(context).collect { newLayoutInfo ->
- try {
- val foldingFeature = newLayoutInfo?.displayFeatures
- ?.firstOrNull { it is FoldingFeature } as FoldingFeature
- isLayoutUnfolded = (foldingFeature != null)
- } catch (e: Exception) {
- // If there was an issue detecting a foldable in the open position, default
- // to isLayoutUnfolded being false.
- isLayoutUnfolded = false
- }
- }
+ if (cameraAndRecordAudioPermissionState.allPermissionsGranted) {
+ content()
+ } else {
+ CameraAndRecordAudioPermission(
+ permissionsState = cameraAndRecordAudioPermissionState,
+ onBackClicked = onBackPressed,
+ modifier = modifier,
+ )
}
+}
- val viewFinderState by viewModel.viewFinderState.collectAsStateWithLifecycle()
- var rotation by remember { mutableStateOf(Surface.ROTATION_0) }
+@Composable
+private fun CameraContent(
+ cameraSettings: CameraSettings,
+ onCameraEvent: (CameraEvent) -> Unit,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(color = Color.Black)
+ .windowInsetsPadding(WindowInsets.systemBars),
+ ) {
+ when (cameraSettings.foldingState) {
+ FoldingState.HALF_OPEN -> {
+ TwoPaneCameraLayout(
+ cameraSettings = cameraSettings,
+ onCameraEvent = onCameraEvent,
+ )
+ }
- DisposableEffect(lifecycleOwner, context) {
- val rotationProvider = RotationProvider(context)
- val rotationListener: (Int) -> Unit = { rotationValue: Int ->
- if (rotationValue != rotation) {
- surfaceProvider?.let { provider ->
- viewModel.setTargetRotation(
- rotationValue,
- )
- }
+ FoldingState.FLAT, FoldingState.CLOSE -> {
+ FlatCameraLayout(
+ cameraSettings = cameraSettings,
+ onCameraEvent = onCameraEvent,
+ )
}
- rotation = rotationValue
}
- rotationProvider.addListener(Dispatchers.Main.asExecutor(), rotationListener)
-
- onDispose {
- rotationProvider.removeListener(rotationListener)
+ IconButton(
+ onClick = onBackPressed,
+ modifier = Modifier
+ .size(50.dp)
+ .align(Alignment.TopStart)
+ .padding(start = 12.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = null,
+ tint = Color.White,
+ )
}
}
+}
- val onPreviewSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = {
- surfaceProvider = it
- viewModel.startPreview(lifecycleOwner, it, captureMode, cameraSelector, rotation)
- }
+@Composable
+private fun TwoPaneCameraLayout(
+ cameraSettings: CameraSettings,
+ onCameraEvent: (CameraEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val configuration = LocalConfiguration.current
+ when (configuration.orientation) {
+ ORIENTATION_LANDSCAPE -> {
+ TwoPaneLandScapeCameraLayout(
+ cameraSettings = cameraSettings,
+ onCameraEvent = onCameraEvent,
+ )
+ }
- fun setCaptureMode(mode: CaptureMode) {
- captureMode = mode
- surfaceProvider?.let { provider ->
- viewModel.startPreview(
- lifecycleOwner,
- provider,
- captureMode,
- cameraSelector,
- rotation,
+ else -> {
+ TwoPanePortraitCameraLayout(
+ cameraSettings = cameraSettings,
+ onCameraEvent = onCameraEvent,
)
}
}
+}
+
+@Composable
+private fun TwoPaneLandScapeCameraLayout(
+ cameraSettings: CameraSettings,
+ onCameraEvent: (CameraEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .weight(1f),
+ ) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(bottom = 100.dp),
+ ) {
+ CameraControls(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ )
+ }
- fun setCameraSelector(selector: CameraSelector) {
- cameraSelector = selector
- surfaceProvider?.let { provider ->
- viewModel.startPreview(
- lifecycleOwner,
- provider,
- captureMode,
- cameraSelector,
- rotation,
+ ShutterButton(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(top = 50.dp),
+ )
+
+ CameraSwitcher(
+ captureMode = cameraSettings.captureMode,
+ onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) },
+ modifier = Modifier
+ .padding(start = 200.dp, top = 50.dp)
+ .align(Alignment.Center),
)
}
- }
- @SuppressLint("MissingPermission")
- fun onVideoRecordingStart() {
- captureMode = CaptureMode.VIDEO_RECORDING
- viewModel.startVideoCapture(onMediaCaptured)
+ ViewFinder(
+ onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) },
+ onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) },
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()),
+ )
}
+}
- fun onVideoRecordingFinish() {
- captureMode = CaptureMode.VIDEO_READY
- viewModel.saveVideo()
- }
+@Composable
+private fun TwoPanePortraitCameraLayout(
+ cameraSettings: CameraSettings,
+ onCameraEvent: (CameraEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column {
+ ViewFinder(
+ onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) },
+ onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) },
+ modifier = Modifier
+ .weight(1f)
+ .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat())
+ .align(Alignment.CenterHorizontally),
+ )
- if (cameraAndRecordAudioPermissionState.allPermissionsGranted) {
- Box(modifier = modifier.background(color = Color.Black)) {
- Column(verticalArrangement = Arrangement.Bottom) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(0.dp, 25.dp, 0.dp, 0.dp)
- .background(Color.Black)
- .height(50.dp),
- ) {
- IconButton(onClick = {
- onMediaCaptured(null)
- }) {
- Icon(
- imageVector = Icons.Default.ArrowBack,
- contentDescription = null,
- tint = Color.White,
- )
- }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row {
+ CameraControls(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ )
}
- if (isLayoutUnfolded != null) {
- if (isLayoutUnfolded as Boolean) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- ) {
- Column(
- modifier = Modifier
- .fillMaxHeight()
- .weight(1f),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- ) {
- CameraControls(
- captureMode,
- { setCaptureMode(CaptureMode.PHOTO) },
- { setCaptureMode(CaptureMode.VIDEO_READY) },
- )
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(0.dp, 5.dp, 0.dp, 50.dp)
- .background(Color.Black)
- .height(100.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- ) {
- ShutterButton(
- captureMode,
- { viewModel.capturePhoto(onMediaCaptured) },
- { onVideoRecordingStart() },
- { onVideoRecordingFinish() },
- )
- }
- Row(
- modifier = Modifier,
- verticalAlignment = Alignment.Bottom,
- horizontalArrangement = Arrangement.Center,
- ) {
- CameraSwitcher(captureMode, cameraSelector, ::setCameraSelector)
- }
- }
- Column(
- modifier = Modifier
- .fillMaxHeight(0.9F)
- .weight(1f),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- ViewFinder(
- viewFinderState.cameraState,
- onPreviewSurfaceProviderReady,
- viewModel::setZoomScale,
- )
- }
- }
- } else {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f),
- ) {
- ViewFinder(
- viewFinderState.cameraState,
- onPreviewSurfaceProviderReady,
- viewModel::setZoomScale,
- )
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(0.dp, 5.dp, 0.dp, 5.dp)
- .background(Color.Black)
- .height(50.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- ) {
- CameraControls(
- captureMode,
- { setCaptureMode(CaptureMode.PHOTO) },
- { setCaptureMode(CaptureMode.VIDEO_READY) },
- )
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(0.dp, 5.dp, 0.dp, 50.dp)
- .background(Color.Black)
- .height(100.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
- ) {
- Spacer(modifier = Modifier.size(50.dp))
- ShutterButton(
- captureMode,
- { viewModel.capturePhoto(onMediaCaptured) },
- { onVideoRecordingStart() },
- { onVideoRecordingFinish() },
- )
- CameraSwitcher(captureMode, cameraSelector, ::setCameraSelector)
- }
- }
+
+ Spacer(modifier = Modifier.height(15.dp))
+
+ Box {
+ ShutterButton(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ modifier = modifier.align(Alignment.Center),
+ )
+
+ CameraSwitcher(
+ captureMode = cameraSettings.captureMode,
+ onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) },
+ modifier = modifier
+ .padding(start = 200.dp)
+ .align(Alignment.CenterEnd),
+ )
}
}
}
- } else {
- CameraAndRecordAudioPermission(cameraAndRecordAudioPermissionState) {
- onMediaCaptured(null)
- }
}
}
@Composable
-fun CameraControls(captureMode: CaptureMode, onPhotoButtonClick: () -> Unit, onVideoButtonClick: () -> Unit) {
+private fun BoxScope.FlatCameraLayout(
+ cameraSettings: CameraSettings,
+ onCameraEvent: (CameraEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ViewFinder(
+ modifier = Modifier
+ .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat())
+ .align(Alignment.Center),
+ onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) },
+ onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) },
+ )
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 100.dp),
+ ) {
+ CameraControls(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ )
+ }
+
+ ShutterButton(
+ captureMode = cameraSettings.captureMode,
+ onCameraEvent = onCameraEvent,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 12.5.dp),
+ )
+
+ CameraSwitcher(
+ captureMode = cameraSettings.captureMode,
+ onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(start = 200.dp, bottom = 30.dp),
+ )
+}
+
+@Composable
+private fun CameraControls(
+ captureMode: CaptureMode,
+ onCameraEvent: (CameraEvent) -> Unit,
+) {
val activeButtonColor =
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
val inactiveButtonColor =
@@ -319,74 +365,138 @@ fun CameraControls(captureMode: CaptureMode, onPhotoButtonClick: () -> Unit, onV
if (captureMode != CaptureMode.VIDEO_RECORDING) {
Button(
modifier = Modifier.padding(5.dp),
- onClick = onPhotoButtonClick,
+ onClick = { onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.PHOTO)) },
colors = if (captureMode == CaptureMode.PHOTO) activeButtonColor else inactiveButtonColor,
) {
- Text("Photo")
+ Text(stringResource(id = R.string.photo))
}
Button(
modifier = Modifier.padding(5.dp),
- onClick = onVideoButtonClick,
+ onClick = { onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.VIDEO_READY)) },
colors = if (captureMode != CaptureMode.PHOTO) activeButtonColor else inactiveButtonColor,
) {
- Text("Video")
+ Text(stringResource(id = R.string.video))
}
}
}
@Composable
-fun ShutterButton(captureMode: CaptureMode, onPhotoCapture: () -> Unit, onVideoRecordingStart: () -> Unit, onVideoRecordingFinish: () -> Unit) {
- Box(modifier = Modifier.padding(25.dp, 0.dp)) {
- if (captureMode == CaptureMode.PHOTO) {
- Button(
- onClick = onPhotoCapture,
- shape = CircleShape,
- colors = ButtonDefaults.buttonColors(containerColor = Color.White),
- modifier = Modifier
- .height(75.dp)
- .width(75.dp),
- ) {}
- } else if (captureMode == CaptureMode.VIDEO_READY) {
- Button(
- onClick = onVideoRecordingStart,
- shape = CircleShape,
- colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
- modifier = Modifier
- .height(75.dp)
- .width(75.dp),
- ) {}
- } else if (captureMode == CaptureMode.VIDEO_RECORDING) {
- Button(
- onClick = onVideoRecordingFinish,
- shape = RoundedCornerShape(10),
- colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
- modifier = Modifier
- .height(50.dp)
- .width(50.dp),
- ) {}
- Spacer(modifier = Modifier.width(100.dp))
+private fun ShutterButton(
+ captureMode: CaptureMode,
+ onCameraEvent: (CameraEvent) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier.padding(horizontal = 25.dp),
+ ) {
+ when (captureMode) {
+ CaptureMode.PHOTO -> {
+ Button(
+ onClick = { onCameraEvent(CameraEvent.CapturePhoto) },
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = Color.White),
+ modifier = Modifier.size(75.dp),
+ content = { Unit },
+ )
+ }
+
+ CaptureMode.VIDEO_READY -> {
+ Button(
+ onClick = {
+ onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.VIDEO_RECORDING))
+ onCameraEvent(CameraEvent.StartVideoRecording)
+ },
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
+ modifier = Modifier.size(75.dp),
+ content = { Unit },
+ )
+ }
+
+ CaptureMode.VIDEO_RECORDING -> {
+ Button(
+ onClick = {
+ onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.VIDEO_READY))
+ onCameraEvent(CameraEvent.StopVideoRecording)
+ },
+ shape = RoundedCornerShape(10),
+ colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
+ modifier = Modifier.size(50.dp),
+ content = { Unit },
+ )
+ }
}
}
}
@Composable
-fun CameraSwitcher(captureMode: CaptureMode, cameraSelector: CameraSelector, setCameraSelector: KFunction1) {
+private fun CameraSwitcher(
+ captureMode: CaptureMode,
+ onCameraSelector: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
if (captureMode != CaptureMode.VIDEO_RECORDING) {
- IconButton(onClick = {
- if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) {
- setCameraSelector(CameraSelector.DEFAULT_FRONT_CAMERA)
- } else {
- setCameraSelector(CameraSelector.DEFAULT_BACK_CAMERA)
- }
- }) {
+ IconButton(
+ onClick = onCameraSelector,
+ modifier = modifier,
+ ) {
Icon(
imageVector = Icons.Default.Autorenew,
contentDescription = null,
tint = Color.White,
- modifier = Modifier
- .height(75.dp)
- .width(75.dp),
+ modifier = Modifier.size(75.dp),
)
}
}
}
+
+@Preview(
+ showSystemUi = true,
+ device = "spec:width=673dp,height=841dp,dpi=420,isRound=false,chinSize=0dp,orientation=landscape",
+)
+@Composable
+private fun HalfLandScapeCameraLayoutPreView() {
+ SocialTheme {
+ CameraContent(
+ cameraSettings = CameraSettings(
+ foldingState = FoldingState.HALF_OPEN,
+ aspectRatioType = AspectRatioType.RATIO_16_9,
+ ),
+ onCameraEvent = {},
+ onBackPressed = {},
+ )
+ }
+}
+
+@Preview(
+ showSystemUi = true,
+ device = "spec:width=673dp,height=841dp,dpi=420,isRound=false,chinSize=0dp,orientation=portrait",
+)
+@Composable
+private fun HalfPortraitCameraLayoutPreView() {
+ SocialTheme {
+ CameraContent(
+ cameraSettings = CameraSettings(
+ foldingState = FoldingState.HALF_OPEN,
+ aspectRatioType = AspectRatioType.RATIO_9_16,
+ ),
+ onCameraEvent = {},
+ onBackPressed = {},
+ )
+ }
+}
+
+@DevicePreview
+@Composable
+private fun FlatCameraLayoutPreView() {
+ SocialTheme {
+ CameraContent(
+ cameraSettings = CameraSettings(
+ foldingState = FoldingState.FLAT,
+ aspectRatioType = AspectRatioType.RATIO_4_3,
+ ),
+ onCameraEvent = {},
+ onBackPressed = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/CameraViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/CameraViewModel.kt
index 871957cf..d9e2c9c1 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/camera/CameraViewModel.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/CameraViewModel.kt
@@ -16,324 +16,213 @@
package com.google.android.samples.socialite.ui.camera
-import android.Manifest
-import android.content.ContentValues
-import android.content.Context
-import android.os.Build
-import android.provider.MediaStore
+import android.util.Log
import android.view.Display
-import android.widget.Toast
-import androidx.annotation.RequiresPermission
-import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.DisplayOrientedMeteringPointFactory
import androidx.camera.core.FocusMeteringAction
-import androidx.camera.core.ImageCapture
-import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
-import androidx.camera.core.UseCaseGroup
-import androidx.camera.core.resolutionselector.AspectRatioStrategy
-import androidx.camera.core.resolutionselector.ResolutionSelector
-import androidx.camera.extensions.ExtensionMode
-import androidx.camera.extensions.ExtensionsManager
-import androidx.camera.video.MediaStoreOutputOptions
-import androidx.camera.video.Quality
-import androidx.camera.video.QualitySelector
-import androidx.camera.video.Recorder
-import androidx.camera.video.Recording
-import androidx.camera.video.VideoCapture
-import androidx.camera.video.VideoRecordEvent
-import androidx.concurrent.futures.await
-import androidx.core.content.ContextCompat
-import androidx.core.util.Consumer
-import androidx.lifecycle.LifecycleOwner
+import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.android.samples.socialite.domain.AspectRatioType
+import com.google.android.samples.socialite.domain.CameraSettings
+import com.google.android.samples.socialite.domain.CameraUseCase
+import com.google.android.samples.socialite.domain.FoldingState
import com.google.android.samples.socialite.repository.ChatRepository
+import com.google.android.samples.socialite.util.CoroutineLifecycleOwner
import dagger.hilt.android.lifecycle.HiltViewModel
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.text.SimpleDateFormat
-import java.util.Locale
import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
class CameraViewModel @Inject constructor(
- @ApplicationContext private val application: Context,
- private val cameraProviderManager: CameraXProcessCameraProviderManager,
+ private val cameraProviderManager: CameraProviderManager,
+ private val cameraUseCase: CameraUseCase,
private val repository: ChatRepository,
- private val savedStateHandle: SavedStateHandle,
+ savedStateHandle: SavedStateHandle,
) : ViewModel() {
- private lateinit var camera: Camera
- private lateinit var extensionsManager: ExtensionsManager
-
- val chatId: Long? = savedStateHandle.get("chatId")
- var viewFinderState = MutableStateFlow(ViewFinderState())
-
- val aspectRatioStrategy =
- AspectRatioStrategy(AspectRatio.RATIO_16_9, AspectRatioStrategy.FALLBACK_RULE_NONE)
- var resolutionSelector = ResolutionSelector.Builder()
- .setAspectRatioStrategy(aspectRatioStrategy)
- .build()
-
- private val previewUseCase = Preview.Builder()
- .setResolutionSelector(resolutionSelector)
- .build()
-
- private val imageCaptureUseCase = ImageCapture.Builder()
- .setResolutionSelector(resolutionSelector)
- .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
- .build()
-
- private val recorder = Recorder.Builder()
- .setAspectRatio(AspectRatio.RATIO_16_9)
- .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
- .build()
-
- private val videoCaptureUseCase = VideoCapture.Builder(recorder)
- .build()
-
- private var currentRecording: Recording? = null
- private lateinit var recordingState: VideoRecordEvent
- fun setChatId(chatId: Long) {
- savedStateHandle.set("chatId", chatId)
- }
-
- fun startPreview(
- lifecycleOwner: LifecycleOwner,
- surfaceProvider: Preview.SurfaceProvider,
- captureMode: CaptureMode,
- cameraSelector: CameraSelector,
- rotation: Int,
- ) {
- viewModelScope.launch {
- val cameraProvider = cameraProviderManager.getCameraProvider()
- val extensionManagerJob = viewModelScope.launch {
- extensionsManager = ExtensionsManager.getInstanceAsync(
- application,
- cameraProvider,
- ).await()
- }
- var extensionsCameraSelector: CameraSelector? = null
- val useCaseGroupBuilder = UseCaseGroup.Builder()
+ val chatId: StateFlow = savedStateHandle.getStateFlow("chatId", null)
- previewUseCase.setSurfaceProvider(surfaceProvider)
- useCaseGroupBuilder.addUseCase(previewUseCase)
+ private lateinit var cameraProvider: ProcessCameraProvider
+ private lateinit var camera: Camera
- if (captureMode == CaptureMode.PHOTO) {
- try {
- extensionManagerJob.join()
+ private val _mediaCapture = MutableSharedFlow(replay = 0)
+ val mediaCapture: SharedFlow = _mediaCapture
- // Query if extension is available.
- if (extensionsManager.isExtensionAvailable(
- cameraSelector,
- ExtensionMode.NIGHT,
- )
- ) {
- // Retrieve extension enabled camera selector
- extensionsCameraSelector =
- extensionsManager.getExtensionEnabledCameraSelector(
- cameraSelector,
- ExtensionMode.NIGHT,
- )
- }
- } catch (e: InterruptedException) {
- // This should not happen unless the future is cancelled or the thread is
- // interrupted by applications.
- }
-
- imageCaptureUseCase.targetRotation = rotation
- useCaseGroupBuilder.addUseCase(imageCaptureUseCase)
- } else if (captureMode == CaptureMode.VIDEO_READY || captureMode == CaptureMode.VIDEO_RECORDING) {
- videoCaptureUseCase.targetRotation = rotation
- useCaseGroupBuilder.addUseCase(videoCaptureUseCase)
- }
+ private val _cameraSettings = MutableStateFlow(CameraSettings())
+ val cameraSettings: StateFlow = _cameraSettings
+ .onStart {
+ cameraProvider = cameraProviderManager.getCameraProvider()
+ }
+ .onEach { cameraSettings ->
+ val useCaseGroup = cameraUseCase.createUseCaseGroup(cameraSettings)
cameraProvider.unbindAll()
- val activeCameraSelector = extensionsCameraSelector ?: cameraSelector
camera = cameraProvider.bindToLifecycle(
- lifecycleOwner,
- activeCameraSelector,
- useCaseGroupBuilder.build(),
+ CoroutineLifecycleOwner(viewModelScope.coroutineContext),
+ CameraSelector.Builder()
+ .requireLensFacing(cameraSettings.cameraLensFacing)
+ .build(),
+ useCaseGroup,
)
- viewFinderState.value.cameraState = CameraState.READY
}
- }
+ .catch {
+ Log.e("CameraViewModel", "Error camera", it)
+ }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = CameraSettings(),
+ )
- fun setTargetRotation(rotation: Int) {
- imageCaptureUseCase.targetRotation = rotation
- videoCaptureUseCase.targetRotation = rotation
- }
+ fun setUserEvent(cameraEvent: CameraEvent) {
+ when (cameraEvent) {
+ CameraEvent.CapturePhoto -> capturePhoto()
+ CameraEvent.StartVideoRecording -> startVideoCapture()
+ CameraEvent.StopVideoRecording -> stopVideoRecording()
+ CameraEvent.ToggleCameraFacing -> toggleCameraFacing()
+ is CameraEvent.CaptureModeChange -> setCaptureMode(cameraEvent.mode)
+ is CameraEvent.TapToFocus -> tapToFocus(
+ cameraEvent.display,
+ cameraEvent.surfaceWidth,
+ cameraEvent.surfaceHeight,
+ cameraEvent.x,
+ cameraEvent.y,
+ )
- fun capturePhoto(onMediaCaptured: (Media) -> Unit) {
- // Create time stamped name and MediaStore entry.
- val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
- .format(System.currentTimeMillis())
- val contentValues = ContentValues().apply {
- put(MediaStore.MediaColumns.DISPLAY_NAME, name)
- put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
- put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/SociaLite")
- }
+ is CameraEvent.ZoomChange -> setZoomScale(cameraEvent.scale)
+ is CameraEvent.SurfaceProviderReady -> setSurfaceProvider(cameraEvent.surfaceProvider)
}
+ }
- val context: Context = application
- // Create output options object which contains file + metadata
- val outputOptions = ImageCapture.OutputFileOptions
- .Builder(
- context.contentResolver,
- MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
- contentValues,
- )
- .build()
- imageCaptureUseCase.takePicture(
- outputOptions,
- ContextCompat.getMainExecutor(context),
- object : ImageCapture.OnImageSavedCallback {
- override fun onError(exc: ImageCaptureException) {
- val msg = "Photo capture failed."
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ fun setCameraOrientation(
+ foldingState: FoldingState,
+ isPortrait: Boolean,
+ ) {
+ _cameraSettings.update { settings ->
+ val ratio = when (foldingState) {
+ FoldingState.CLOSE -> {
+ AspectRatioType.RATIO_9_16
}
- override fun onImageSaved(output: ImageCapture.OutputFileResults) {
- val savedUri = output.savedUri
- if (savedUri != null) {
- sendPhotoMessage(savedUri.toString())
- onMediaCaptured(Media(savedUri, MediaType.PHOTO))
+ FoldingState.HALF_OPEN -> {
+ if (isPortrait) {
+ AspectRatioType.RATIO_16_9
} else {
- val msg = "Photo capture failed."
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
+ AspectRatioType.RATIO_9_16
}
}
- },
- )
- }
- @RequiresPermission(Manifest.permission.RECORD_AUDIO)
- fun startVideoCapture(onMediaCaptured: (Media) -> Unit) {
- val name = "Socialite-recording-" +
- SimpleDateFormat(FILENAME_FORMAT, Locale.US)
- .format(System.currentTimeMillis()) + ".mp4"
- val contentValues = ContentValues().apply {
- put(MediaStore.Video.Media.DISPLAY_NAME, name)
- put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
- put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/SociaLite")
+ FoldingState.FLAT -> {
+ if (isPortrait) {
+ AspectRatioType.RATIO_1_1
+ } else {
+ AspectRatioType.RATIO_4_3
+ }
+ }
}
- }
- val context: Context = application
- val mediaStoreOutput = MediaStoreOutputOptions.Builder(
- context.contentResolver,
- MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
- )
- .setContentValues(contentValues)
- .build()
- val captureListener = Consumer { event ->
- recordingState = event
- if (event is VideoRecordEvent.Finalize) {
- onMediaCaptured(Media(event.outputResults.outputUri, MediaType.VIDEO))
- }
+ settings.copy(
+ foldingState = foldingState,
+ aspectRatioType = ratio,
+ )
}
-
- // configure Recorder and Start recording to the mediaStoreOutput.
- currentRecording = videoCaptureUseCase.output
- .prepareRecording(context, mediaStoreOutput)
- .apply { withAudioEnabled() }
- .start(ContextCompat.getMainExecutor(context), captureListener)
}
- fun sendPhotoMessage(photoUri: String) {
- viewModelScope.launch {
- if (chatId != null) {
- repository.sendMessage(
- chatId = chatId,
- text = "",
- mediaUri = photoUri,
- mediaMimeType = "image/jpeg",
- )
- }
+ private fun toggleCameraFacing() {
+ _cameraSettings.update { settings ->
+ settings.copy(
+ cameraLensFacing =
+ if (settings.cameraLensFacing == CameraSelector.LENS_FACING_BACK) {
+ CameraSelector.LENS_FACING_FRONT
+ } else {
+ CameraSelector.LENS_FACING_BACK
+ },
+ )
}
}
- fun saveVideo() {
- if (currentRecording == null || recordingState is VideoRecordEvent.Finalize) {
- return
- }
-
- val recording = currentRecording
- if (recording != null) {
- recording.stop()
- currentRecording = null
+ private fun setCaptureMode(captureMode: CaptureMode) {
+ _cameraSettings.update { settings ->
+ settings.copy(captureMode = captureMode)
}
}
- companion object {
- const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
- val aspectRatios = mapOf(AspectRatio.RATIO_16_9 to (9.0 / 16.0).toFloat())
+ private fun setSurfaceProvider(surfaceProvider: Preview.SurfaceProvider) {
+ _cameraSettings.update { settings ->
+ settings.copy(surfaceProvider = surfaceProvider)
+ }
}
- fun tapToFocus(
+ private fun tapToFocus(
display: Display,
surfaceWidth: Int,
surfaceHeight: Int,
x: Float,
y: Float,
) {
- camera?.let { camera ->
- val meteringPoint =
- DisplayOrientedMeteringPointFactory(
- display,
- camera.cameraInfo,
- surfaceWidth.toFloat(),
- surfaceHeight.toFloat(),
- ).createPoint(x, y)
+ val meteringPoint =
+ DisplayOrientedMeteringPointFactory(
+ display,
+ camera.cameraInfo,
+ surfaceWidth.toFloat(),
+ surfaceHeight.toFloat(),
+ ).createPoint(x, y)
- val action = FocusMeteringAction.Builder(meteringPoint).build()
+ val action = FocusMeteringAction.Builder(meteringPoint).build()
- camera.cameraControl.startFocusAndMetering(action)
- }
+ camera.cameraControl.startFocusAndMetering(action)
}
- fun setZoomScale(scale: Float) {
- val zoomState = camera?.cameraInfo?.zoomState?.value
- if (zoomState == null) return
- val finalScale =
- (zoomState.zoomRatio * scale).coerceIn(
- zoomState.minZoomRatio,
- zoomState.maxZoomRatio,
- )
- camera?.cameraControl?.setZoomRatio(finalScale)
- }
-}
+ private fun setZoomScale(scale: Float) {
+ val zoomState = camera.cameraInfo.zoomState.value ?: return
+ val finalScale = (zoomState.zoomRatio * scale).coerceIn(
+ zoomState.minZoomRatio,
+ zoomState.maxZoomRatio,
+ )
-data class ViewFinderState(
- var cameraState: CameraState = CameraState.NOT_READY,
- val lensFacing: Int = CameraSelector.LENS_FACING_BACK,
-)
+ camera.cameraControl.setZoomRatio(finalScale)
+ }
-/**
- * Defines the current state of the camera.
- */
-enum class CameraState {
- /**
- * Camera hasn't been initialized.
- */
- NOT_READY,
+ private fun capturePhoto() {
+ viewModelScope.launch {
+ val uri = cameraUseCase.capturePhoto() ?: return@launch
+ val chaId = chatId.value ?: return@launch
+
+ repository.sendMessage(
+ chatId = chaId,
+ text = "",
+ mediaUri = uri.toString(),
+ mediaMimeType = "image/jpeg",
+ )
+ _mediaCapture.emit(Media(uri, MediaType.PHOTO))
+ }
+ }
- /**
- * Camera is open and presenting a preview stream.
- */
- READY,
+ private fun startVideoCapture() {
+ viewModelScope.launch {
+ val media = cameraUseCase.startVideoRecording()
+ _mediaCapture.emit(media)
+ }
+ }
- /**
- * Camera is initialized but the preview has been stopped.
- */
- PREVIEW_STOPPED,
+ private fun stopVideoRecording() {
+ cameraUseCase.stopVideoRecording()
+ }
}
enum class CaptureMode {
@@ -341,3 +230,20 @@ enum class CaptureMode {
VIDEO_READY,
VIDEO_RECORDING,
}
+
+sealed interface CameraEvent {
+ data object ToggleCameraFacing : CameraEvent
+ data object CapturePhoto : CameraEvent
+ data object StartVideoRecording : CameraEvent
+ data object StopVideoRecording : CameraEvent
+ data class ZoomChange(val scale: Float) : CameraEvent
+ data class CaptureModeChange(val mode: CaptureMode) : CameraEvent
+ data class SurfaceProviderReady(val surfaceProvider: Preview.SurfaceProvider) : CameraEvent
+ data class TapToFocus(
+ val display: Display,
+ val surfaceWidth: Int,
+ val surfaceHeight: Int,
+ val x: Float,
+ val y: Float,
+ ) : CameraEvent
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/ViewFinder.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/ViewFinder.kt
index e9e4a3a9..b88de21f 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/camera/ViewFinder.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/ViewFinder.kt
@@ -30,9 +30,9 @@ import androidx.compose.ui.graphics.Color
@Composable
fun ViewFinder(
- cameraState: CameraState,
onSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = {},
onZoomChange: (Float) -> Unit,
+ modifier: Modifier = Modifier,
) {
val transformableState = rememberTransformableState(
onTransformation = { zoomChange, _, _ ->
@@ -40,7 +40,7 @@ fun ViewFinder(
},
)
Box(
- Modifier
+ modifier = modifier
.background(Color.Black)
.fillMaxSize(),
contentAlignment = Alignment.Center,
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/navigation/CameraNavigation.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/navigation/CameraNavigation.kt
new file mode 100644
index 00000000..3b6e9e81
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/navigation/CameraNavigation.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.ui.camera.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.google.android.samples.socialite.ui.camera.Camera
+import com.google.android.samples.socialite.ui.camera.Media
+import com.google.android.samples.socialite.ui.camera.MediaType
+
+const val CHAT_ID_ARG = "chatId"
+const val CAMERA_ROUTE = "chat/{$CHAT_ID_ARG}/camera"
+
+fun NavController.navigateToCamera(chatId: Long, navOptions: NavOptions? = null) {
+ navigate(
+ route = CAMERA_ROUTE.replace("{$CHAT_ID_ARG}", chatId.toString()),
+ navOptions = navOptions,
+ )
+}
+
+fun NavGraphBuilder.cameraScreen(
+ onBackPressed: () -> Unit,
+ onVideoEditClick: (String) -> Unit,
+) {
+ composable(
+ route = CAMERA_ROUTE,
+ arguments = listOf(
+ navArgument(CHAT_ID_ARG) { type = NavType.LongType },
+ ),
+ ) { backStackEntry ->
+ val chatId = backStackEntry.arguments?.getLong(CHAT_ID_ARG) ?: 0
+
+ Camera(
+ onBackPressed = onBackPressed,
+ onMediaCaptured = { capturedMedia: Media? ->
+ if (capturedMedia?.mediaType == MediaType.VIDEO) {
+ onVideoEditClick("videoEdit?uri=${capturedMedia.uri}&chatId=$chatId")
+ } else {
+ onBackPressed()
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
index 8b9d52d8..044196f4 100644
--- a/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
+++ b/app/src/main/java/com/google/android/samples/socialite/ui/videoedit/VideoEditScreenViewModel.kt
@@ -39,7 +39,6 @@ import androidx.media3.transformer.ExportException
import androidx.media3.transformer.ExportResult
import androidx.media3.transformer.Transformer
import com.google.android.samples.socialite.repository.ChatRepository
-import com.google.android.samples.socialite.ui.camera.CameraViewModel
import com.google.common.collect.ImmutableList
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -150,7 +149,7 @@ class VideoEditScreenViewModel @Inject constructor(
.build()
val editedVideoFileName = "Socialite-edited-recording-" +
- SimpleDateFormat(CameraViewModel.FILENAME_FORMAT, Locale.US)
+ SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
.format(System.currentTimeMillis()) + ".mp4"
transformedVideoFilePath = createNewVideoFilePath(context, editedVideoFileName)
diff --git a/app/src/main/java/com/google/android/samples/socialite/util/CoroutineLifecycleOwner.kt b/app/src/main/java/com/google/android/samples/socialite/util/CoroutineLifecycleOwner.kt
new file mode 100644
index 00000000..9a3f12cc
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/util/CoroutineLifecycleOwner.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.util
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.Job
+
+/**
+ * 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.
+ */
+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
+ }
+ }
+}
diff --git a/app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt b/app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt
new file mode 100644
index 00000000..52f6df5b
--- /dev/null
+++ b/app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2024 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.android.samples.socialite.util
+
+import android.content.Context
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowInfoTracker
+import com.google.android.samples.socialite.domain.FoldingState
+import dagger.hilt.android.qualifiers.ActivityContext
+import dagger.hilt.android.scopes.ActivityScoped
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+interface DisplayFeaturesMonitor {
+ val foldingState: Flow
+}
+
+@ActivityScoped
+class DisplayFeaturesMonitorImpl @Inject constructor(
+ @ActivityContext context: Context,
+) : DisplayFeaturesMonitor {
+
+ override val foldingState: Flow =
+ WindowInfoTracker.getOrCreate(context)
+ .windowLayoutInfo(context)
+ .map { layoutInfo ->
+ val displayFeatures = layoutInfo.displayFeatures.filterIsInstance()
+ when {
+ displayFeatures.isEmpty() -> FoldingState.CLOSE
+ hasHalfOpenedFoldingFeature(displayFeatures) -> FoldingState.HALF_OPEN
+ else -> FoldingState.FLAT
+ }
+ }
+ .distinctUntilChanged()
+
+ private fun hasHalfOpenedFoldingFeature(displayFeatures: List): Boolean =
+ displayFeatures.any { feature ->
+ feature.state == FoldingFeature.State.HALF_OPENED
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e9fded71..c9354129 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -66,4 +66,8 @@
Rewind
Favorite Contact
+
+ Photo
+ Video
+