From d29497424e7513066fb2cbac355fce5f7ab1cd08 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Fri, 22 Mar 2024 17:55:26 +0900 Subject: [PATCH 01/15] Refactor navigation logic on the cameraScreen --- .../android/samples/socialite/ui/Main.kt | 38 +++--------- .../samples/socialite/ui/camera/Camera.kt | 61 +++++++++++------- .../ui/camera/navigation/CameraNavigation.kt | 62 +++++++++++++++++++ 3 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/google/android/samples/socialite/ui/camera/navigation/CameraNavigation.kt 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 d2f575b1..bc54d9c0 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 @@ -40,9 +40,8 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navDeepLink 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.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 @@ -117,41 +116,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 3381dcd5..0a2590e6 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 @@ -71,7 +72,7 @@ import kotlinx.coroutines.asExecutor @OptIn(ExperimentalPermissionsApi::class) @Composable fun Camera( - chatId: Long, + onBackPressed: () -> Unit, onMediaCaptured: (Media?) -> Unit, modifier: Modifier = Modifier, viewModel: CameraViewModel = hiltViewModel(), @@ -86,8 +87,6 @@ fun Camera( ), ) - viewModel.setChatId(chatId) - val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -178,7 +177,11 @@ fun Camera( } if (cameraAndRecordAudioPermissionState.allPermissionsGranted) { - Box(modifier = modifier.background(color = Color.Black)) { + Box( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black), + ) { Column(verticalArrangement = Arrangement.Bottom) { Row( modifier = Modifier @@ -187,9 +190,7 @@ fun Camera( .background(Color.Black) .height(50.dp), ) { - IconButton(onClick = { - onMediaCaptured(null) - }) { + IconButton(onClick = onBackPressed) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = null, @@ -310,14 +311,19 @@ fun Camera( } } } else { - CameraAndRecordAudioPermission(cameraAndRecordAudioPermissionState) { - onMediaCaptured(null) - } + CameraAndRecordAudioPermission( + permissionsState = cameraAndRecordAudioPermissionState, + onBackClicked = onBackPressed, + ) } } @Composable -fun CameraControls(captureMode: CaptureMode, onPhotoButtonClick: () -> Unit, onVideoButtonClick: () -> Unit) { +fun CameraControls( + captureMode: CaptureMode, + onPhotoButtonClick: () -> Unit, + onVideoButtonClick: () -> Unit, +) { val activeButtonColor = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) val inactiveButtonColor = @@ -341,8 +347,15 @@ fun CameraControls(captureMode: CaptureMode, onPhotoButtonClick: () -> Unit, onV } @Composable -fun ShutterButton(captureMode: CaptureMode, onPhotoCapture: () -> Unit, onVideoRecordingStart: () -> Unit, onVideoRecordingFinish: () -> Unit) { - Box(modifier = Modifier.padding(25.dp, 0.dp)) { +fun ShutterButton( + captureMode: CaptureMode, + onPhotoCapture: () -> Unit, + onVideoRecordingStart: () -> Unit, + onVideoRecordingFinish: () -> Unit, +) { + Box( + modifier = Modifier.padding(horizontal = 25.dp) + ) { if (captureMode == CaptureMode.PHOTO) { Button( onClick = onPhotoCapture, @@ -376,15 +389,21 @@ fun ShutterButton(captureMode: CaptureMode, onPhotoCapture: () -> Unit, onVideoR } @Composable -fun CameraSwitcher(captureMode: CaptureMode, cameraSelector: CameraSelector, setCameraSelector: KFunction1) { +fun CameraSwitcher( + captureMode: CaptureMode, + cameraSelector: CameraSelector, + setCameraSelector: KFunction1, +) { 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 = { + if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) { + setCameraSelector(CameraSelector.DEFAULT_FRONT_CAMERA) + } else { + setCameraSelector(CameraSelector.DEFAULT_BACK_CAMERA) + } + }, + ) { Icon( imageVector = Icons.Default.Autorenew, contentDescription = null, 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..5509f399 --- /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() + } + }, + ) + } +} From c646e8f8b924db4bbaa798943149609d37033f76 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Thu, 28 Mar 2024 22:05:02 +0900 Subject: [PATCH 02/15] Refactor camera initialization and adapt camera for foldable devices --- .../android/samples/socialite/MainActivity.kt | 38 +- .../samples/socialite/di/CameraModule.kt | 99 +++ .../samples/socialite/di/ManagerModule.kt | 32 + .../samples/socialite/di/UseCaseModule.kt | 32 + .../samples/socialite/domain/CameraUseCase.kt | 169 +++++ .../samples/socialite/ui/CompositionLocal.kt | 22 + .../samples/socialite/ui/DevicePreview.kt | 24 + .../samples/socialite/ui/camera/Camera.kt | 658 +++++++++++------- .../socialite/ui/camera/CameraViewModel.kt | 360 +++------- .../samples/socialite/ui/camera/ViewFinder.kt | 38 +- .../ui/camera/navigation/CameraNavigation.kt | 4 +- .../android/samples/socialite/ui/home/Home.kt | 1 - .../ui/videoedit/VideoEditScreenViewModel.kt | 3 +- .../socialite/util/CoroutineLifecycleOwner.kt | 55 ++ .../socialite/util/RotationStateMonitor.kt | 52 ++ app/src/main/res/values/strings.xml | 4 + 16 files changed, 1047 insertions(+), 544 deletions(-) create mode 100644 app/src/main/java/com/google/android/samples/socialite/di/CameraModule.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/di/ManagerModule.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/di/UseCaseModule.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/domain/CameraUseCase.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/ui/CompositionLocal.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/ui/DevicePreview.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/util/CoroutineLifecycleOwner.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt 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 36c70003..99fe3e98 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 @@ -21,11 +21,20 @@ import android.os.Bundle 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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.layout.DisplayFeature +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +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.ui.camera.FoldingState import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.map @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -33,10 +42,27 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + setContent { - Main( - shortcutParams = extractShortcutParams(intent), - ) + val foldingState = WindowInfoTracker.getOrCreate(this@MainActivity) + .windowLayoutInfo(this@MainActivity) + .map { layoutInfo -> + val displayFeatures = layoutInfo.displayFeatures + when { + displayFeatures.isEmpty() -> FoldingState.CLOSE + hasFlatFoldingFeature(displayFeatures) -> FoldingState.HALF_OPEN + else -> FoldingState.FLAT + } + } + .collectAsStateWithLifecycle(initialValue = FoldingState.FLAT) + + CompositionLocalProvider( + LocalFoldingState provides foldingState.value, + ) { + Main( + shortcutParams = extractShortcutParams(intent), + ) + } } } @@ -48,4 +74,10 @@ class MainActivity : ComponentActivity() { val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null return ShortcutParams(shortcutId, text) } + + private fun hasFlatFoldingFeature(displayFeatures: List): Boolean = + displayFeatures.any { feature -> + feature is FoldingFeature && + feature.state == FoldingFeature.State.HALF_OPENED + } } 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..e4c7113f --- /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.RotationStateManager +import com.google.android.samples.socialite.util.RotationStateMonitor +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class ManagerModule { + + @Binds + internal abstract fun bindsRotationStateManager(rotationStateManager: RotationStateManager): RotationStateMonitor +} 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..3c248a1d --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/domain/CameraUseCase.kt @@ -0,0 +1,169 @@ +/* + * 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.AspectRatio +import androidx.camera.core.CameraSelector +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.ViewPort +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +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 kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.suspendCancellableCoroutine + +interface CameraUseCase { + suspend fun initializeCamera() + fun createCameraUseCaseGroup(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 lateinit var previewUseCase: Preview + private lateinit var imageCaptureUseCase: ImageCapture + + private lateinit var videoCaptureUseCase: VideoCapture + private var recording: Recording? = null + private lateinit var recordingState: VideoRecordEvent + + override suspend fun initializeCamera() { + val aspectRatioStrategy = AspectRatioStrategy( + AspectRatio.RATIO_16_9, + AspectRatioStrategy.FALLBACK_RULE_NONE, + ) + + val resolutionSelector = ResolutionSelector.Builder() + .setAspectRatioStrategy(aspectRatioStrategy) + .build() + + previewUseCase = Preview.Builder() + .setResolutionSelector(resolutionSelector) + .build() + + imageCaptureUseCase = ImageCapture.Builder() + .setResolutionSelector(resolutionSelector) + .build() + + val recorder = Recorder.Builder() + .setAspectRatio(AspectRatio.RATIO_16_9) + .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .build() + + videoCaptureUseCase = VideoCapture.Builder(recorder).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 = suspendCoroutine { continuation -> + recording = videoCaptureUseCase.output + .prepareRecording(context, mediaStoreOutputOptions) + .apply { withAudioEnabled() } + .start(Dispatchers.Default.asExecutor()) { event -> + recordingState = event + + if (event is VideoRecordEvent.Finalize) { + val media = Media(event.outputResults.outputUri, MediaType.VIDEO) + continuation.resume(media) + } + } + } + + override fun stopVideoRecording() { + val recording = checkNotNull(recording) { "Recording is not started" } + + recording.stop() + this.recording = null + } + + override fun createCameraUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup { + val useCaseGroupBuilder = UseCaseGroup.Builder() + + previewUseCase.setSurfaceProvider(cameraSettings.surfaceProvider) + videoCaptureUseCase.targetRotation = previewUseCase.targetRotation + + useCaseGroupBuilder.setViewPort( + ViewPort.Builder( + Rational(9, 16), + previewUseCase.targetRotation, + ).build(), + ) + useCaseGroupBuilder.addUseCase(previewUseCase) + useCaseGroupBuilder.addUseCase(imageCaptureUseCase) + useCaseGroupBuilder.addUseCase(videoCaptureUseCase) + + return useCaseGroupBuilder.build() + } +} + +data class CameraSettings( + val cameraLensFacing: Int = CameraSelector.LENS_FACING_BACK, + val aspectRatio: Int = AspectRatio.RATIO_16_9, + val captureMode: CaptureMode = CaptureMode.PHOTO, + val surfaceProvider: Preview.SurfaceProvider? = null, + val zoomScale: Float = 1f, + val focusMetringAction: FocusMeteringAction? = null, +) 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..b75dfe54 --- /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.staticCompositionLocalOf +import com.google.android.samples.socialite.ui.camera.FoldingState + +val LocalFoldingState = staticCompositionLocalOf { FoldingState.FLAT } 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/camera/Camera.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/Camera.kt index 0a2590e6..b0c8c722 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 @@ -18,23 +18,26 @@ package com.google.android.samples.socialite.ui.camera import android.Manifest import android.annotation.SuppressLint +import android.view.Display import android.view.Surface -import androidx.camera.core.CameraSelector -import androidx.camera.core.Preview -import androidx.camera.view.RotationProvider +import androidx.camera.core.Preview.SurfaceProvider 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -47,27 +50,22 @@ 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.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoTracker 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.CameraSettings +import com.google.android.samples.socialite.ui.DevicePreview +import com.google.android.samples.socialite.ui.LocalFoldingState @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -77,9 +75,10 @@ fun Camera( 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 foldState = LocalFoldingState.current + val rotationState by viewModel.rotationState.collectAsStateWithLifecycle() + val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle() + val cameraAndRecordAudioPermissionState = rememberMultiplePermissionsState( listOf( Manifest.permission.CAMERA, @@ -87,235 +86,310 @@ fun Camera( ), ) - val lifecycleOwner = LocalLifecycleOwner.current - val context = LocalContext.current + @SuppressLint("MissingPermission") + if (cameraAndRecordAudioPermissionState.allPermissionsGranted) { + Box( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black) + .windowInsetsPadding(WindowInsets.navigationBars) + .windowInsetsPadding(WindowInsets.statusBars), + ) { + when (foldState) { + FoldingState.HALF_OPEN -> { + TwoPaneCameraLayout( + cameraSettings = cameraSettings, + rotationState = rotationState, + onCameraSelector = viewModel::toggleCameraFacing, + onCaptureMode = viewModel::setCaptureMode, + onPhotoCapture = { + viewModel.capturePhoto(onMediaCaptured) + }, + onPreviewSurfaceProviderReady = viewModel::setSurfaceProvider, + onVideoRecordingStart = { + viewModel.setCaptureMode(CaptureMode.VIDEO_RECORDING) + viewModel.startVideoCapture(onMediaCaptured) + }, + onVideoRecordingFinish = { + viewModel.setCaptureMode(CaptureMode.VIDEO_READY) + viewModel.stopVideoRecording() + }, + onTapToFocus = viewModel::tapToFocus, + onZoomChange = viewModel::setZoomScale, + ) + } - var isLayoutUnfolded by remember { mutableStateOf(null) } + FoldingState.FLAT, FoldingState.CLOSE -> { + FlatCameraLayout( + cameraSettings = cameraSettings, + onCameraSelector = viewModel::toggleCameraFacing, + onCaptureMode = viewModel::setCaptureMode, + onPhotoCapture = { + viewModel.capturePhoto(onMediaCaptured) + }, + onPreviewSurfaceProviderReady = viewModel::setSurfaceProvider, + onVideoRecordingStart = { + viewModel.setCaptureMode(CaptureMode.VIDEO_RECORDING) + viewModel.startVideoCapture(onMediaCaptured) + }, + onVideoRecordingFinish = { + viewModel.setCaptureMode(CaptureMode.VIDEO_READY) + viewModel.stopVideoRecording() + }, + onTapToFocus = viewModel::tapToFocus, + onZoomChange = viewModel::setZoomScale, + ) + } + } - 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 + 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, + ) } } + } else { + CameraAndRecordAudioPermission( + permissionsState = cameraAndRecordAudioPermissionState, + onBackClicked = onBackPressed, + ) } +} - val viewFinderState by viewModel.viewFinderState.collectAsStateWithLifecycle() - var rotation by remember { mutableStateOf(Surface.ROTATION_0) } - - DisposableEffect(lifecycleOwner, context) { - val rotationProvider = RotationProvider(context) - val rotationListener: (Int) -> Unit = { rotationValue: Int -> - if (rotationValue != rotation) { - surfaceProvider?.let { provider -> - viewModel.startPreview( - lifecycleOwner, - provider, - captureMode, - cameraSelector, - rotationValue, - ) - } - } - rotation = rotationValue +@Composable +private fun TwoPaneCameraLayout( + cameraSettings: CameraSettings, + rotationState: Int, + onCameraSelector: () -> Unit, + onCaptureMode: (CaptureMode) -> Unit, + onPhotoCapture: () -> Unit, + onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, + onVideoRecordingStart: () -> Unit, + onVideoRecordingFinish: () -> Unit, + onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, + onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + when (rotationState) { + Surface.ROTATION_0, Surface.ROTATION_180 -> { + TwoPaneVerticalCameraLayout( + cameraSettings = cameraSettings, + onCameraSelector = onCameraSelector, + onCaptureMode = onCaptureMode, + onPhotoCapture = onPhotoCapture, + onPreviewSurfaceProviderReady = onPreviewSurfaceProviderReady, + onVideoRecordingStart = onVideoRecordingStart, + onVideoRecordingFinish = onVideoRecordingFinish, + onTapToFocus = onTapToFocus, + onZoomChange = onZoomChange, + modifier = modifier, + ) } - rotationProvider.addListener(Dispatchers.Main.asExecutor(), rotationListener) - - onDispose { - rotationProvider.removeListener(rotationListener) + else -> { + TwoPaneHorizontalCameraLayout( + cameraSettings = cameraSettings, + onCameraSelector = onCameraSelector, + onCaptureMode = onCaptureMode, + onPhotoCapture = onPhotoCapture, + onPreviewSurfaceProviderReady = onPreviewSurfaceProviderReady, + onVideoRecordingStart = onVideoRecordingStart, + onVideoRecordingFinish = onVideoRecordingFinish, + onTapToFocus = onTapToFocus, + onZoomChange = onZoomChange, + modifier = modifier, + ) } } +} - val onPreviewSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = { - surfaceProvider = it - viewModel.startPreview(lifecycleOwner, it, captureMode, cameraSelector, rotation) - } +@Composable +private fun TwoPaneVerticalCameraLayout( + cameraSettings: CameraSettings, + onCameraSelector: () -> Unit, + onCaptureMode: (CaptureMode) -> Unit, + onPhotoCapture: () -> Unit, + onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, + onVideoRecordingStart: () -> Unit, + onVideoRecordingFinish: () -> Unit, + onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, + onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f), + ) { + Row( + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = 100.dp), + ) { + CameraControls( + captureMode = cameraSettings.captureMode, + onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, + onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, + ) + } - fun setCaptureMode(mode: CaptureMode) { - captureMode = mode - surfaceProvider?.let { provider -> - viewModel.startPreview( - lifecycleOwner, - provider, - captureMode, - cameraSelector, - rotation, + ShutterButton( + captureMode = cameraSettings.captureMode, + onPhotoCapture = onPhotoCapture, + onVideoRecordingStart = onVideoRecordingStart, + onVideoRecordingFinish = onVideoRecordingFinish, + modifier = Modifier + .align(Alignment.Center) + .padding(top = 50.dp), ) - } - } - fun setCameraSelector(selector: CameraSelector) { - cameraSelector = selector - surfaceProvider?.let { provider -> - viewModel.startPreview( - lifecycleOwner, - provider, - captureMode, - cameraSelector, - rotation, + CameraSwitcher( + captureMode = cameraSettings.captureMode, + onCameraSelector = onCameraSelector, + modifier = Modifier + .padding(start = 200.dp, top = 50.dp) + .align(Alignment.Center), ) } - } - @SuppressLint("MissingPermission") - fun onVideoRecordingStart() { - captureMode = CaptureMode.VIDEO_RECORDING - viewModel.startVideoCapture(onMediaCaptured) + Column( + modifier = Modifier.weight(1f), + ) { + ViewFinder( + onSurfaceProviderReady = onPreviewSurfaceProviderReady, + onTapToFocus = onTapToFocus, + onZoomChange = onZoomChange, + ) + } } +} - fun onVideoRecordingFinish() { - captureMode = CaptureMode.VIDEO_READY - viewModel.saveVideo() - } +@Composable +private fun TwoPaneHorizontalCameraLayout( + cameraSettings: CameraSettings, + onCameraSelector: () -> Unit, + onCaptureMode: (CaptureMode) -> Unit, + onPhotoCapture: () -> Unit, + onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, + onVideoRecordingStart: () -> Unit, + onVideoRecordingFinish: () -> Unit, + onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, + onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .weight(1f), + ) { + ViewFinder( + onSurfaceProviderReady = onPreviewSurfaceProviderReady, + onTapToFocus = onTapToFocus, + onZoomChange = onZoomChange, + ) + } - if (cameraAndRecordAudioPermissionState.allPermissionsGranted) { - Box( + Row( modifier = modifier - .fillMaxSize() - .background(color = Color.Black), + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - 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 = onBackPressed) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = null, - tint = Color.White, - ) - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + CameraControls( + captureMode = cameraSettings.captureMode, + onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, + onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, + ) } - 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::tapToFocus, - viewModel::setZoomScale, - ) - } - } - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) { - ViewFinder( - viewFinderState.cameraState, - onPreviewSurfaceProviderReady, - viewModel::tapToFocus, - 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, + onPhotoCapture = onPhotoCapture, + onVideoRecordingStart = onVideoRecordingStart, + onVideoRecordingFinish = onVideoRecordingFinish, + modifier = modifier.align(Alignment.Center), + ) + + CameraSwitcher( + captureMode = cameraSettings.captureMode, + onCameraSelector = onCameraSelector, + modifier = modifier + .padding(start = 200.dp) + .align(Alignment.CenterEnd), + ) } } } - } else { - CameraAndRecordAudioPermission( - permissionsState = cameraAndRecordAudioPermissionState, - onBackClicked = onBackPressed, + } +} + +@Composable +private fun BoxScope.FlatCameraLayout( + cameraSettings: CameraSettings, + onCameraSelector: () -> Unit, + onCaptureMode: (CaptureMode) -> Unit, + onPhotoCapture: () -> Unit, + onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, + onVideoRecordingStart: () -> Unit, + onVideoRecordingFinish: () -> Unit, + onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, + onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + ViewFinder( + modifier = modifier.fillMaxSize(), + onSurfaceProviderReady = onPreviewSurfaceProviderReady, + onTapToFocus = onTapToFocus, + onZoomChange = onZoomChange, + ) + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 100.dp), + ) { + CameraControls( + captureMode = cameraSettings.captureMode, + onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, + onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, ) } + + ShutterButton( + captureMode = cameraSettings.captureMode, + onPhotoCapture = onPhotoCapture, + onVideoRecordingStart = onVideoRecordingStart, + onVideoRecordingFinish = onVideoRecordingFinish, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.5.dp), + ) + + CameraSwitcher( + captureMode = cameraSettings.captureMode, + onCameraSelector = onCameraSelector, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(start = 200.dp, bottom = 30.dp), + ) } @Composable @@ -334,14 +408,14 @@ fun CameraControls( onClick = onPhotoButtonClick, colors = if (captureMode == CaptureMode.PHOTO) activeButtonColor else inactiveButtonColor, ) { - Text("Photo") + Text(stringResource(id = R.string.photo)) } Button( modifier = Modifier.padding(5.dp), onClick = onVideoButtonClick, colors = if (captureMode != CaptureMode.PHOTO) activeButtonColor else inactiveButtonColor, ) { - Text("Video") + Text(stringResource(id = R.string.video)) } } } @@ -352,38 +426,41 @@ fun ShutterButton( onPhotoCapture: () -> Unit, onVideoRecordingStart: () -> Unit, onVideoRecordingFinish: () -> Unit, + modifier: Modifier = Modifier, ) { Box( - modifier = Modifier.padding(horizontal = 25.dp) + modifier = modifier.padding(horizontal = 25.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)) + when (captureMode) { + CaptureMode.PHOTO -> { + Button( + onClick = onPhotoCapture, + shape = CircleShape, + colors = ButtonDefaults.buttonColors(containerColor = Color.White), + modifier = Modifier.size(75.dp), + content = { Unit }, + ) + } + + CaptureMode.VIDEO_READY -> { + Button( + onClick = onVideoRecordingStart, + shape = CircleShape, + colors = ButtonDefaults.buttonColors(containerColor = Color.Red), + modifier = Modifier.size(75.dp), + content = { Unit }, + ) + } + + CaptureMode.VIDEO_RECORDING -> { + Button( + onClick = onVideoRecordingFinish, + shape = RoundedCornerShape(10), + colors = ButtonDefaults.buttonColors(containerColor = Color.Red), + modifier = Modifier.size(50.dp), + content = { Unit }, + ) + } } } } @@ -391,27 +468,82 @@ fun ShutterButton( @Composable fun CameraSwitcher( captureMode: CaptureMode, - cameraSelector: CameraSelector, - setCameraSelector: KFunction1, + 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) - } - }, + 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(device = Devices.PIXEL_FOLD) +@Composable +private fun HalfOpenHorizontalCameraLayoutPreView() { + Column( + modifier = Modifier.fillMaxSize(), + ) { + TwoPaneCameraLayout( + cameraSettings = CameraSettings(), + rotationState = Surface.ROTATION_270, + onCameraSelector = {}, + onCaptureMode = {}, + onPhotoCapture = {}, + onPreviewSurfaceProviderReady = {}, + onVideoRecordingStart = {}, + onVideoRecordingFinish = {}, + onTapToFocus = { _, _, _, _, _ -> }, + onZoomChange = {}, + ) + } +} + +@Preview(device = Devices.PIXEL_FOLD) +@Composable +private fun HalfOpenVerticalCameraLayoutPreView() { + Column( + modifier = Modifier.fillMaxSize(), + ) { + TwoPaneCameraLayout( + cameraSettings = CameraSettings(), + rotationState = Surface.ROTATION_0, + onCameraSelector = {}, + onCaptureMode = {}, + onPhotoCapture = {}, + onPreviewSurfaceProviderReady = {}, + onVideoRecordingStart = {}, + onVideoRecordingFinish = {}, + onTapToFocus = { _, _, _, _, _ -> }, + onZoomChange = {}, + ) + } +} + +@DevicePreview +@Composable +private fun FlatCameraLayoutPreView() { + Box( + modifier = Modifier.fillMaxSize(), + ) { + FlatCameraLayout( + cameraSettings = CameraSettings(captureMode = CaptureMode.VIDEO_READY), + onCameraSelector = {}, + onCaptureMode = {}, + onPhotoCapture = {}, + onPreviewSurfaceProviderReady = {}, + onVideoRecordingStart = {}, + onVideoRecordingFinish = {}, + onTapToFocus = { _, _, _, _, _ -> }, + onZoomChange = {}, + ) + } +} 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 7036fe3e..d8dc1d5d 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 @@ -17,258 +17,110 @@ 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 android.view.Surface 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.CameraSettings +import com.google.android.samples.socialite.domain.CameraUseCase import com.google.android.samples.socialite.repository.ChatRepository +import com.google.android.samples.socialite.util.CoroutineLifecycleOwner +import com.google.android.samples.socialite.util.RotationStateMonitor 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.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter +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 cameraXUseCase: CameraUseCase, private val repository: ChatRepository, - private val savedStateHandle: SavedStateHandle, + savedStateHandle: SavedStateHandle, + rotationStateMonitor: RotationStateMonitor, ) : 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) - .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() - - previewUseCase.setSurfaceProvider(surfaceProvider) - useCaseGroupBuilder.addUseCase(previewUseCase) - if (captureMode == CaptureMode.PHOTO) { - try { - extensionManagerJob.join() + val chatId: StateFlow = savedStateHandle.getStateFlow("chatId", 0L) - // 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. - } + private lateinit var cameraProvider: ProcessCameraProvider + private lateinit var camera: Camera - 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 + .filter { cameraSettings -> + cameraSettings.surfaceProvider != null + } + .onStart { + cameraProvider = cameraProviderManager.getCameraProvider() + cameraXUseCase.initializeCamera() + } + .onEach { cameraSettings -> + val useCaseGroup = cameraXUseCase.createCameraUseCaseGroup(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 } - } - - 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") - } + .catch { + Log.e("CameraViewModel", "Error camera", it) } - - 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() - } - - override fun onImageSaved(output: ImageCapture.OutputFileResults) { - val savedUri = output.savedUri - if (savedUri != null) { - sendPhotoMessage(savedUri.toString()) - onMediaCaptured(Media(savedUri, MediaType.PHOTO)) - } else { - val msg = "Photo capture failed." - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() - } - } - }, + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CameraSettings(), ) - } - @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") - } - } - val context: Context = application - val mediaStoreOutput = MediaStoreOutputOptions.Builder( - context.contentResolver, - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + val rotationState: StateFlow = rotationStateMonitor.currentRotation + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Surface.ROTATION_0, ) - .setContentValues(contentValues) - .build() - val captureListener = Consumer { event -> - recordingState = event - if (event is VideoRecordEvent.Finalize) { - onMediaCaptured(Media(event.outputResults.outputUri, MediaType.VIDEO)) - } + fun toggleCameraFacing() { + _cameraSettings.update { settings -> + settings.copy( + cameraLensFacing = if (settings.cameraLensFacing == CameraSelector.LENS_FACING_BACK) { + CameraSelector.LENS_FACING_FRONT + } else { + CameraSelector.LENS_FACING_BACK + }, + ) } - - // 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", - ) - } + fun setCaptureMode(captureMode: CaptureMode) { + _cameraSettings.update { settings -> + settings.copy(captureMode = captureMode) } } - fun saveVideo() { - if (currentRecording == null || recordingState is VideoRecordEvent.Finalize) { - return + fun setSurfaceProvider(surfaceProvider: Preview.SurfaceProvider) { + _cameraSettings.update { settings -> + settings.copy(surfaceProvider = surfaceProvider) } - - val recording = currentRecording - if (recording != null) { - recording.stop() - currentRecording = null - } - } - - 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()) } fun tapToFocus( @@ -278,56 +130,54 @@ class CameraViewModel @Inject constructor( 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) + val zoomState = camera.cameraInfo.zoomState.value ?: return + val finalScale = (zoomState.zoomRatio * scale).coerceIn( + zoomState.minZoomRatio, + zoomState.maxZoomRatio, + ) + + camera.cameraControl.setZoomRatio(finalScale) } -} -data class ViewFinderState( - var cameraState: CameraState = CameraState.NOT_READY, - val lensFacing: Int = CameraSelector.LENS_FACING_BACK, -) + fun capturePhoto(onMediaCaptured: (Media) -> Unit) { + viewModelScope.launch { + val uri = cameraXUseCase.capturePhoto() ?: return@launch -/** - * Defines the current state of the camera. - */ -enum class CameraState { - /** - * Camera hasn't been initialized. - */ - NOT_READY, + repository.sendMessage( + chatId = chatId.value, + text = "", + mediaUri = uri.toString(), + mediaMimeType = "image/jpeg", + ) + onMediaCaptured(Media(uri, MediaType.PHOTO)) + } + } - /** - * Camera is open and presenting a preview stream. - */ - READY, + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun startVideoCapture(onMediaCaptured: (Media) -> Unit) { + viewModelScope.launch { + val media = cameraXUseCase.startVideoRecording() + onMediaCaptured(media) + } + } - /** - * Camera is initialized but the preview has been stopped. - */ - PREVIEW_STOPPED, + fun stopVideoRecording() { + cameraXUseCase.stopVideoRecording() + } } enum class CaptureMode { @@ -335,3 +185,9 @@ enum class CaptureMode { VIDEO_READY, VIDEO_RECORDING, } + +enum class FoldingState { + CLOSE, + HALF_OPEN, + FLAT, +} 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 ddcedeb1..8188c1f1 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 @@ -39,10 +39,10 @@ import com.google.android.samples.socialite.ui.camera.viewfinder.CameraPreview @Composable fun ViewFinder( - cameraState: CameraState, - onSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = {}, onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, onZoomChange: (Float) -> Unit, + modifier: Modifier = Modifier, + onSurfaceProviderReady: (Preview.SurfaceProvider) -> Unit = {}, ) { var viewInfo: View? by remember { mutableStateOf(null) } @@ -52,9 +52,9 @@ fun ViewFinder( }, ) Box( - Modifier - .background(Color.Black) + modifier .fillMaxSize() + .background(Color.Black) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -69,24 +69,20 @@ fun ViewFinder( } }, ) - }, + } + .transformable(state = transformableState), contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .transformable(state = transformableState), - ) { - CameraPreview( - modifier = Modifier.fillMaxSize(), - implementationMode = PreviewView.ImplementationMode.COMPATIBLE, - onSurfaceProviderReady = onSurfaceProviderReady, - onRequestBitmapReady = { - val bitmap = it.invoke() - }, - setSurfaceView = { s: View -> - viewInfo = s - }, - ) - } + CameraPreview( + modifier = Modifier.fillMaxSize(), + implementationMode = PreviewView.ImplementationMode.COMPATIBLE, + onSurfaceProviderReady = onSurfaceProviderReady, + onRequestBitmapReady = { + val bitmap = it.invoke() + }, + setSurfaceView = { s: View -> + viewInfo = s + }, + ) } } 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 index 5509f399..3b6e9e81 100644 --- 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 @@ -32,13 +32,13 @@ 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 + navOptions = navOptions, ) } fun NavGraphBuilder.cameraScreen( onBackPressed: () -> Unit, - onVideoEditClick: (String) -> Unit + onVideoEditClick: (String) -> Unit, ) { composable( route = CAMERA_ROUTE, diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/home/Home.kt b/app/src/main/java/com/google/android/samples/socialite/ui/home/Home.kt index 42a04c10..a1fd8b83 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/home/Home.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/home/Home.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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/RotationStateMonitor.kt b/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt new file mode 100644 index 00000000..4d7d07aa --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt @@ -0,0 +1,52 @@ +/* + * 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.camera.view.RotationProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +interface RotationStateMonitor { + val currentRotation: Flow +} + +@Singleton +class RotationStateManager @Inject constructor( + @ApplicationContext context: Context, +) : RotationStateMonitor { + + override val currentRotation: Flow = callbackFlow { + val rotationProvider = RotationProvider(context) + + val rotationListener = RotationProvider.Listener { rotation: Int -> + trySend(rotation) + } + + rotationProvider.addListener(Dispatchers.Main.asExecutor(), rotationListener) + + awaitClose { + rotationProvider.removeListener(rotationListener) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 616eb6ed..472b0e33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,4 +65,8 @@ Fast forward Rewind + + Photo + Video + From 93b33e0e2eeade5e2bb13093c6fb4d027fcf0f12 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Tue, 2 Apr 2024 23:46:44 +0900 Subject: [PATCH 03/15] Refactoring Camera Ratio and CameraEvent Based on Device Display Status --- .../android/samples/socialite/MainActivity.kt | 34 +- .../samples/socialite/di/ManagerModule.kt | 13 +- .../domain/CameraOrientationUseCase.kt | 43 +++ .../samples/socialite/domain/CameraUseCase.kt | 85 ++-- .../samples/socialite/ui/CompositionLocal.kt | 4 +- .../samples/socialite/ui/camera/Camera.kt | 364 ++++++++---------- .../socialite/ui/camera/CameraViewModel.kt | 133 +++++-- .../socialite/util/DisplayFeaturesMonitor.kt | 57 +++ .../socialite/util/RotationStateMonitor.kt | 12 +- 9 files changed, 419 insertions(+), 326 deletions(-) create mode 100644 app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt create mode 100644 app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt 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 99fe3e98..15af4ca5 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 @@ -26,38 +26,28 @@ import androidx.compose.runtime.getValue import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.layout.DisplayFeature -import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoTracker -import com.google.android.samples.socialite.ui.LocalFoldingState +import com.google.android.samples.socialite.domain.CameraOrientationUseCase +import com.google.android.samples.socialite.domain.CameraSettings +import com.google.android.samples.socialite.ui.LocalCameraOrientation import com.google.android.samples.socialite.ui.Main import com.google.android.samples.socialite.ui.ShortcutParams -import com.google.android.samples.socialite.ui.camera.FoldingState import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.map +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject lateinit var cameraOrientationUseCase: CameraOrientationUseCase + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { - val foldingState = WindowInfoTracker.getOrCreate(this@MainActivity) - .windowLayoutInfo(this@MainActivity) - .map { layoutInfo -> - val displayFeatures = layoutInfo.displayFeatures - when { - displayFeatures.isEmpty() -> FoldingState.CLOSE - hasFlatFoldingFeature(displayFeatures) -> FoldingState.HALF_OPEN - else -> FoldingState.FLAT - } - } - .collectAsStateWithLifecycle(initialValue = FoldingState.FLAT) + val cameraOrientation by cameraOrientationUseCase().collectAsStateWithLifecycle(initialValue = CameraSettings()) CompositionLocalProvider( - LocalFoldingState provides foldingState.value, + LocalCameraOrientation provides cameraOrientation, ) { Main( shortcutParams = extractShortcutParams(intent), @@ -74,10 +64,4 @@ class MainActivity : ComponentActivity() { val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null return ShortcutParams(shortcutId, text) } - - private fun hasFlatFoldingFeature(displayFeatures: List): Boolean = - displayFeatures.any { feature -> - feature is FoldingFeature && - feature.state == FoldingFeature.State.HALF_OPENED - } } 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 index e4c7113f..8f4323bd 100644 --- 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 @@ -16,17 +16,22 @@ package com.google.android.samples.socialite.di -import com.google.android.samples.socialite.util.RotationStateManager +import com.google.android.samples.socialite.util.DisplayFeaturesMonitor +import com.google.android.samples.socialite.util.DisplayFeaturesMonitorImpl +import com.google.android.samples.socialite.util.RotationStateMonitorImpl import com.google.android.samples.socialite.util.RotationStateMonitor import dagger.Binds import dagger.Module import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent +import dagger.hilt.android.components.ActivityComponent @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ActivityComponent::class) abstract class ManagerModule { @Binds - internal abstract fun bindsRotationStateManager(rotationStateManager: RotationStateManager): RotationStateMonitor + abstract fun bindsRotationStateManager(rotationStateMonitor: RotationStateMonitorImpl): RotationStateMonitor + + @Binds + abstract fun bindDisplayFeaturesMonitor(displayFeaturesMonitor: DisplayFeaturesMonitorImpl): DisplayFeaturesMonitor } diff --git a/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt b/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt new file mode 100644 index 00000000..5f2028c4 --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt @@ -0,0 +1,43 @@ +/* + * 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 com.google.android.samples.socialite.util.DisplayFeaturesMonitor +import com.google.android.samples.socialite.util.RotationStateMonitor +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +@ActivityScoped +class CameraOrientationUseCase @Inject constructor( + private val displayFeaturesMonitor: DisplayFeaturesMonitor, + private val rotationStateMonitor: RotationStateMonitor, +) { + + operator fun invoke(): Flow { + return combine( + displayFeaturesMonitor.foldingState, + rotationStateMonitor.currentRotation, + ) { foldingState, rotation -> + CameraSettings( + foldingState = foldingState, + rotation = rotation, + ) + } + } +} 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 index 3c248a1d..b3b498f8 100644 --- 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 @@ -19,17 +19,14 @@ package com.google.android.samples.socialite.domain import android.content.Context import android.net.Uri import android.util.Rational +import android.view.Surface import androidx.annotation.RequiresPermission -import androidx.camera.core.AspectRatio import androidx.camera.core.CameraSelector -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.ViewPort -import androidx.camera.core.resolutionselector.AspectRatioStrategy -import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.Quality import androidx.camera.video.QualitySelector @@ -39,6 +36,7 @@ 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.FoldingState import com.google.android.samples.socialite.ui.camera.Media import com.google.android.samples.socialite.ui.camera.MediaType import dagger.hilt.android.qualifiers.ApplicationContext @@ -46,7 +44,6 @@ import dagger.hilt.android.scopes.ViewModelScoped import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.suspendCancellableCoroutine @@ -71,28 +68,12 @@ class CameraXUseCase @Inject constructor( private lateinit var videoCaptureUseCase: VideoCapture private var recording: Recording? = null - private lateinit var recordingState: VideoRecordEvent override suspend fun initializeCamera() { - val aspectRatioStrategy = AspectRatioStrategy( - AspectRatio.RATIO_16_9, - AspectRatioStrategy.FALLBACK_RULE_NONE, - ) - - val resolutionSelector = ResolutionSelector.Builder() - .setAspectRatioStrategy(aspectRatioStrategy) - .build() - - previewUseCase = Preview.Builder() - .setResolutionSelector(resolutionSelector) - .build() - - imageCaptureUseCase = ImageCapture.Builder() - .setResolutionSelector(resolutionSelector) - .build() + previewUseCase = Preview.Builder().build() + imageCaptureUseCase = ImageCapture.Builder().build() val recorder = Recorder.Builder() - .setAspectRatio(AspectRatio.RATIO_16_9) .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) .build() @@ -118,24 +99,25 @@ class CameraXUseCase @Inject constructor( @UnstableApi @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) - override suspend fun startVideoRecording(): Media = suspendCoroutine { continuation -> - recording = videoCaptureUseCase.output - .prepareRecording(context, mediaStoreOutputOptions) - .apply { withAudioEnabled() } - .start(Dispatchers.Default.asExecutor()) { event -> - recordingState = event - - if (event is VideoRecordEvent.Finalize) { - val media = Media(event.outputResults.outputUri, MediaType.VIDEO) - continuation.resume(media) + 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() { - val recording = checkNotNull(recording) { "Recording is not started" } - - recording.stop() + recording?.stop() ?: return this.recording = null } @@ -143,14 +125,19 @@ class CameraXUseCase @Inject constructor( val useCaseGroupBuilder = UseCaseGroup.Builder() previewUseCase.setSurfaceProvider(cameraSettings.surfaceProvider) - videoCaptureUseCase.targetRotation = previewUseCase.targetRotation useCaseGroupBuilder.setViewPort( ViewPort.Builder( - Rational(9, 16), - previewUseCase.targetRotation, - ).build(), + cameraSettings.aspectRatioType.ratio, + cameraSettings.rotation, + ) + .build(), ) + + previewUseCase.targetRotation = cameraSettings.rotation + imageCaptureUseCase.targetRotation = cameraSettings.rotation + videoCaptureUseCase.targetRotation = cameraSettings.rotation + useCaseGroupBuilder.addUseCase(previewUseCase) useCaseGroupBuilder.addUseCase(imageCaptureUseCase) useCaseGroupBuilder.addUseCase(videoCaptureUseCase) @@ -161,9 +148,17 @@ class CameraXUseCase @Inject constructor( data class CameraSettings( val cameraLensFacing: Int = CameraSelector.LENS_FACING_BACK, - val aspectRatio: Int = AspectRatio.RATIO_16_9, val captureMode: CaptureMode = CaptureMode.PHOTO, - val surfaceProvider: Preview.SurfaceProvider? = null, val zoomScale: Float = 1f, - val focusMetringAction: FocusMeteringAction? = null, + val aspectRatioType: AspectRatioType = AspectRatioType.RATIO_9_16, + val rotation: Int = Surface.ROTATION_0, + val foldingState: FoldingState = FoldingState.CLOSE, + val surfaceProvider: Preview.SurfaceProvider? = null, ) + +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 index b75dfe54..cbde3326 100644 --- 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 @@ -17,6 +17,6 @@ package com.google.android.samples.socialite.ui import androidx.compose.runtime.staticCompositionLocalOf -import com.google.android.samples.socialite.ui.camera.FoldingState +import com.google.android.samples.socialite.domain.CameraSettings -val LocalFoldingState = staticCompositionLocalOf { FoldingState.FLAT } +val LocalCameraOrientation = staticCompositionLocalOf { CameraSettings() } 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 b0c8c722..46b8867c 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,10 +17,7 @@ package com.google.android.samples.socialite.ui.camera import android.Manifest -import android.annotation.SuppressLint -import android.view.Display import android.view.Surface -import androidx.camera.core.Preview.SurfaceProvider import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +26,7 @@ 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 @@ -59,15 +57,18 @@ import androidx.compose.ui.tooling.preview.Devices 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.lifecycle.viewModelScope import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.samples.socialite.R import com.google.android.samples.socialite.domain.CameraSettings import com.google.android.samples.socialite.ui.DevicePreview -import com.google.android.samples.socialite.ui.LocalFoldingState +import com.google.android.samples.socialite.ui.LocalCameraOrientation +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach -@OptIn(ExperimentalPermissionsApi::class) @Composable fun Camera( onBackPressed: () -> Unit, @@ -75,10 +76,41 @@ fun Camera( modifier: Modifier = Modifier, viewModel: CameraViewModel = hiltViewModel(), ) { - val foldState = LocalFoldingState.current - val rotationState by viewModel.rotationState.collectAsStateWithLifecycle() + val localCameraSettings = LocalCameraOrientation.current + viewModel.setCameraOrientation(localCameraSettings) + val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle() + LifecycleResumeEffect { + 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,110 +118,74 @@ fun Camera( ), ) - @SuppressLint("MissingPermission") if (cameraAndRecordAudioPermissionState.allPermissionsGranted) { - Box( - modifier = modifier - .fillMaxSize() - .background(color = Color.Black) - .windowInsetsPadding(WindowInsets.navigationBars) - .windowInsetsPadding(WindowInsets.statusBars), - ) { - when (foldState) { - FoldingState.HALF_OPEN -> { - TwoPaneCameraLayout( - cameraSettings = cameraSettings, - rotationState = rotationState, - onCameraSelector = viewModel::toggleCameraFacing, - onCaptureMode = viewModel::setCaptureMode, - onPhotoCapture = { - viewModel.capturePhoto(onMediaCaptured) - }, - onPreviewSurfaceProviderReady = viewModel::setSurfaceProvider, - onVideoRecordingStart = { - viewModel.setCaptureMode(CaptureMode.VIDEO_RECORDING) - viewModel.startVideoCapture(onMediaCaptured) - }, - onVideoRecordingFinish = { - viewModel.setCaptureMode(CaptureMode.VIDEO_READY) - viewModel.stopVideoRecording() - }, - onTapToFocus = viewModel::tapToFocus, - onZoomChange = viewModel::setZoomScale, - ) - } + content() + } else { + CameraAndRecordAudioPermission( + permissionsState = cameraAndRecordAudioPermissionState, + onBackClicked = onBackPressed, + modifier = modifier, + ) + } +} - FoldingState.FLAT, FoldingState.CLOSE -> { - FlatCameraLayout( - cameraSettings = cameraSettings, - onCameraSelector = viewModel::toggleCameraFacing, - onCaptureMode = viewModel::setCaptureMode, - onPhotoCapture = { - viewModel.capturePhoto(onMediaCaptured) - }, - onPreviewSurfaceProviderReady = viewModel::setSurfaceProvider, - onVideoRecordingStart = { - viewModel.setCaptureMode(CaptureMode.VIDEO_RECORDING) - viewModel.startVideoCapture(onMediaCaptured) - }, - onVideoRecordingFinish = { - viewModel.setCaptureMode(CaptureMode.VIDEO_READY) - viewModel.stopVideoRecording() - }, - onTapToFocus = viewModel::tapToFocus, - onZoomChange = viewModel::setZoomScale, - ) - } +@Composable +private fun CameraContent( + cameraSettings: CameraSettings, + onCameraEvent: (CameraEvent) -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black) + .windowInsetsPadding(WindowInsets.navigationBars) + .windowInsetsPadding(WindowInsets.statusBars), + ) { + when (cameraSettings.foldingState) { + FoldingState.HALF_OPEN -> { + TwoPaneCameraLayout( + cameraSettings = cameraSettings, + onCameraEvent = onCameraEvent, + ) } - 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, + FoldingState.FLAT, FoldingState.CLOSE -> { + FlatCameraLayout( + cameraSettings = cameraSettings, + onCameraEvent = onCameraEvent, ) } } - } else { - CameraAndRecordAudioPermission( - permissionsState = cameraAndRecordAudioPermissionState, - onBackClicked = onBackPressed, - ) + + 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, + ) + } } } @Composable private fun TwoPaneCameraLayout( cameraSettings: CameraSettings, - rotationState: Int, - onCameraSelector: () -> Unit, - onCaptureMode: (CaptureMode) -> Unit, - onPhotoCapture: () -> Unit, - onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, - onVideoRecordingStart: () -> Unit, - onVideoRecordingFinish: () -> Unit, - onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, - onZoomChange: (Float) -> Unit, + onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { - when (rotationState) { + when (cameraSettings.rotation) { Surface.ROTATION_0, Surface.ROTATION_180 -> { TwoPaneVerticalCameraLayout( cameraSettings = cameraSettings, - onCameraSelector = onCameraSelector, - onCaptureMode = onCaptureMode, - onPhotoCapture = onPhotoCapture, - onPreviewSurfaceProviderReady = onPreviewSurfaceProviderReady, - onVideoRecordingStart = onVideoRecordingStart, - onVideoRecordingFinish = onVideoRecordingFinish, - onTapToFocus = onTapToFocus, - onZoomChange = onZoomChange, + onCameraEvent = onCameraEvent, modifier = modifier, ) } @@ -197,14 +193,7 @@ private fun TwoPaneCameraLayout( else -> { TwoPaneHorizontalCameraLayout( cameraSettings = cameraSettings, - onCameraSelector = onCameraSelector, - onCaptureMode = onCaptureMode, - onPhotoCapture = onPhotoCapture, - onPreviewSurfaceProviderReady = onPreviewSurfaceProviderReady, - onVideoRecordingStart = onVideoRecordingStart, - onVideoRecordingFinish = onVideoRecordingFinish, - onTapToFocus = onTapToFocus, - onZoomChange = onZoomChange, + onCameraEvent = onCameraEvent, modifier = modifier, ) } @@ -214,19 +203,12 @@ private fun TwoPaneCameraLayout( @Composable private fun TwoPaneVerticalCameraLayout( cameraSettings: CameraSettings, - onCameraSelector: () -> Unit, - onCaptureMode: (CaptureMode) -> Unit, - onPhotoCapture: () -> Unit, - onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, - onVideoRecordingStart: () -> Unit, - onVideoRecordingFinish: () -> Unit, - onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, - onZoomChange: (Float) -> Unit, + onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier = modifier) { + Row { Box( - modifier = Modifier + modifier = modifier .fillMaxHeight() .weight(1f), ) { @@ -237,16 +219,13 @@ private fun TwoPaneVerticalCameraLayout( ) { CameraControls( captureMode = cameraSettings.captureMode, - onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, - onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, + onCameraEvent = onCameraEvent, ) } ShutterButton( captureMode = cameraSettings.captureMode, - onPhotoCapture = onPhotoCapture, - onVideoRecordingStart = onVideoRecordingStart, - onVideoRecordingFinish = onVideoRecordingFinish, + onCameraEvent = onCameraEvent, modifier = Modifier .align(Alignment.Center) .padding(top = 50.dp), @@ -254,50 +233,44 @@ private fun TwoPaneVerticalCameraLayout( CameraSwitcher( captureMode = cameraSettings.captureMode, - onCameraSelector = onCameraSelector, + onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) }, modifier = Modifier .padding(start = 200.dp, top = 50.dp) .align(Alignment.Center), ) } - Column( - modifier = Modifier.weight(1f), - ) { - ViewFinder( - onSurfaceProviderReady = onPreviewSurfaceProviderReady, - onTapToFocus = onTapToFocus, - onZoomChange = onZoomChange, - ) - } + ViewFinder( + onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, + onTapToFocus = { display, width, height, x, y -> + onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) + }, + onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, + modifier = modifier + .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) + .weight(1f), + ) } } @Composable private fun TwoPaneHorizontalCameraLayout( cameraSettings: CameraSettings, - onCameraSelector: () -> Unit, - onCaptureMode: (CaptureMode) -> Unit, - onPhotoCapture: () -> Unit, - onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, - onVideoRecordingStart: () -> Unit, - onVideoRecordingFinish: () -> Unit, - onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, - onZoomChange: (Float) -> Unit, + onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { - Row( + Column { + ViewFinder( + onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, + onTapToFocus = { display, width, height, x, y -> + onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) + }, + onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, modifier = modifier - .fillMaxWidth() - .weight(1f), - ) { - ViewFinder( - onSurfaceProviderReady = onPreviewSurfaceProviderReady, - onTapToFocus = onTapToFocus, - onZoomChange = onZoomChange, - ) - } + .weight(1f) + .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) + .align(Alignment.CenterHorizontally), + ) Row( modifier = modifier @@ -312,8 +285,7 @@ private fun TwoPaneHorizontalCameraLayout( Row { CameraControls( captureMode = cameraSettings.captureMode, - onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, - onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, + onCameraEvent = onCameraEvent, ) } @@ -322,15 +294,13 @@ private fun TwoPaneHorizontalCameraLayout( Box { ShutterButton( captureMode = cameraSettings.captureMode, - onPhotoCapture = onPhotoCapture, - onVideoRecordingStart = onVideoRecordingStart, - onVideoRecordingFinish = onVideoRecordingFinish, + onCameraEvent = onCameraEvent, modifier = modifier.align(Alignment.Center), ) CameraSwitcher( captureMode = cameraSettings.captureMode, - onCameraSelector = onCameraSelector, + onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) }, modifier = modifier .padding(start = 200.dp) .align(Alignment.CenterEnd), @@ -344,21 +314,18 @@ private fun TwoPaneHorizontalCameraLayout( @Composable private fun BoxScope.FlatCameraLayout( cameraSettings: CameraSettings, - onCameraSelector: () -> Unit, - onCaptureMode: (CaptureMode) -> Unit, - onPhotoCapture: () -> Unit, - onPreviewSurfaceProviderReady: (SurfaceProvider) -> Unit, - onVideoRecordingStart: () -> Unit, - onVideoRecordingFinish: () -> Unit, - onTapToFocus: (Display, Int, Int, Float, Float) -> Unit, - onZoomChange: (Float) -> Unit, + onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { ViewFinder( - modifier = modifier.fillMaxSize(), - onSurfaceProviderReady = onPreviewSurfaceProviderReady, - onTapToFocus = onTapToFocus, - onZoomChange = onZoomChange, + modifier = modifier + .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) + .align(Alignment.Center), + onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, + onTapToFocus = { display, width, height, x, y -> + onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) + }, + onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, ) Row( @@ -368,16 +335,13 @@ private fun BoxScope.FlatCameraLayout( ) { CameraControls( captureMode = cameraSettings.captureMode, - onPhotoButtonClick = { onCaptureMode(CaptureMode.PHOTO) }, - onVideoButtonClick = { onCaptureMode(CaptureMode.VIDEO_READY) }, + onCameraEvent = onCameraEvent, ) } ShutterButton( captureMode = cameraSettings.captureMode, - onPhotoCapture = onPhotoCapture, - onVideoRecordingStart = onVideoRecordingStart, - onVideoRecordingFinish = onVideoRecordingFinish, + onCameraEvent = onCameraEvent, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 12.5.dp), @@ -385,7 +349,7 @@ private fun BoxScope.FlatCameraLayout( CameraSwitcher( captureMode = cameraSettings.captureMode, - onCameraSelector = onCameraSelector, + onCameraSelector = { onCameraEvent(CameraEvent.ToggleCameraFacing) }, modifier = Modifier .align(Alignment.BottomCenter) .padding(start = 200.dp, bottom = 30.dp), @@ -393,10 +357,9 @@ private fun BoxScope.FlatCameraLayout( } @Composable -fun CameraControls( +private fun CameraControls( captureMode: CaptureMode, - onPhotoButtonClick: () -> Unit, - onVideoButtonClick: () -> Unit, + onCameraEvent: (CameraEvent) -> Unit, ) { val activeButtonColor = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) @@ -405,14 +368,14 @@ fun CameraControls( 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(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(stringResource(id = R.string.video)) @@ -421,11 +384,9 @@ fun CameraControls( } @Composable -fun ShutterButton( +private fun ShutterButton( captureMode: CaptureMode, - onPhotoCapture: () -> Unit, - onVideoRecordingStart: () -> Unit, - onVideoRecordingFinish: () -> Unit, + onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -434,7 +395,7 @@ fun ShutterButton( when (captureMode) { CaptureMode.PHOTO -> { Button( - onClick = onPhotoCapture, + onClick = { onCameraEvent(CameraEvent.CapturePhoto) }, shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = Color.White), modifier = Modifier.size(75.dp), @@ -444,7 +405,10 @@ fun ShutterButton( CaptureMode.VIDEO_READY -> { Button( - onClick = onVideoRecordingStart, + onClick = { + onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.VIDEO_RECORDING)) + onCameraEvent(CameraEvent.StartVideoRecording) + }, shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = Color.Red), modifier = Modifier.size(75.dp), @@ -454,7 +418,10 @@ fun ShutterButton( CaptureMode.VIDEO_RECORDING -> { Button( - onClick = onVideoRecordingFinish, + onClick = { + onCameraEvent(CameraEvent.CaptureModeChange(CaptureMode.VIDEO_READY)) + onCameraEvent(CameraEvent.StopVideoRecording) + }, shape = RoundedCornerShape(10), colors = ButtonDefaults.buttonColors(containerColor = Color.Red), modifier = Modifier.size(50.dp), @@ -466,7 +433,7 @@ fun ShutterButton( } @Composable -fun CameraSwitcher( +private fun CameraSwitcher( captureMode: CaptureMode, onCameraSelector: () -> Unit, modifier: Modifier = Modifier, @@ -493,16 +460,8 @@ private fun HalfOpenHorizontalCameraLayoutPreView() { modifier = Modifier.fillMaxSize(), ) { TwoPaneCameraLayout( - cameraSettings = CameraSettings(), - rotationState = Surface.ROTATION_270, - onCameraSelector = {}, - onCaptureMode = {}, - onPhotoCapture = {}, - onPreviewSurfaceProviderReady = {}, - onVideoRecordingStart = {}, - onVideoRecordingFinish = {}, - onTapToFocus = { _, _, _, _, _ -> }, - onZoomChange = {}, + cameraSettings = CameraSettings(rotation = Surface.ROTATION_270), + onCameraEvent = {}, ) } } @@ -514,16 +473,8 @@ private fun HalfOpenVerticalCameraLayoutPreView() { modifier = Modifier.fillMaxSize(), ) { TwoPaneCameraLayout( - cameraSettings = CameraSettings(), - rotationState = Surface.ROTATION_0, - onCameraSelector = {}, - onCaptureMode = {}, - onPhotoCapture = {}, - onPreviewSurfaceProviderReady = {}, - onVideoRecordingStart = {}, - onVideoRecordingFinish = {}, - onTapToFocus = { _, _, _, _, _ -> }, - onZoomChange = {}, + cameraSettings = CameraSettings(rotation = Surface.ROTATION_0), + onCameraEvent = {}, ) } } @@ -536,14 +487,7 @@ private fun FlatCameraLayoutPreView() { ) { FlatCameraLayout( cameraSettings = CameraSettings(captureMode = CaptureMode.VIDEO_READY), - onCameraSelector = {}, - onCaptureMode = {}, - onPhotoCapture = {}, - onPreviewSurfaceProviderReady = {}, - onVideoRecordingStart = {}, - onVideoRecordingFinish = {}, - onTapToFocus = { _, _, _, _, _ -> }, - onZoomChange = {}, + onCameraEvent = {}, ) } } 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 d8dc1d5d..4b02fd9a 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,11 +16,9 @@ package com.google.android.samples.socialite.ui.camera -import android.Manifest import android.util.Log import android.view.Display import android.view.Surface -import androidx.annotation.RequiresPermission import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.DisplayOrientedMeteringPointFactory @@ -30,18 +28,19 @@ 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.repository.ChatRepository import com.google.android.samples.socialite.util.CoroutineLifecycleOwner -import com.google.android.samples.socialite.util.RotationStateMonitor import dagger.hilt.android.lifecycle.HiltViewModel 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.filter import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -51,28 +50,27 @@ import kotlinx.coroutines.launch @HiltViewModel class CameraViewModel @Inject constructor( private val cameraProviderManager: CameraProviderManager, - private val cameraXUseCase: CameraUseCase, + private val cameraUseCase: CameraUseCase, private val repository: ChatRepository, savedStateHandle: SavedStateHandle, - rotationStateMonitor: RotationStateMonitor, ) : ViewModel() { - val chatId: StateFlow = savedStateHandle.getStateFlow("chatId", 0L) + val chatId: StateFlow = savedStateHandle.getStateFlow("chatId", null) private lateinit var cameraProvider: ProcessCameraProvider private lateinit var camera: Camera + private val _mediaCapture = MutableSharedFlow(replay = 0) + val mediaCapture: SharedFlow = _mediaCapture + private val _cameraSettings = MutableStateFlow(CameraSettings()) val cameraSettings: StateFlow = _cameraSettings - .filter { cameraSettings -> - cameraSettings.surfaceProvider != null - } .onStart { cameraProvider = cameraProviderManager.getCameraProvider() - cameraXUseCase.initializeCamera() + cameraUseCase.initializeCamera() } .onEach { cameraSettings -> - val useCaseGroup = cameraXUseCase.createCameraUseCaseGroup(cameraSettings) + val useCaseGroup = cameraUseCase.createCameraUseCaseGroup(cameraSettings) cameraProvider.unbindAll() camera = cameraProvider.bindToLifecycle( @@ -92,17 +90,67 @@ class CameraViewModel @Inject constructor( initialValue = CameraSettings(), ) - val rotationState: StateFlow = rotationStateMonitor.currentRotation - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = Surface.ROTATION_0, - ) + 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, + ) + + is CameraEvent.ZoomChange -> setZoomScale(cameraEvent.scale) + is CameraEvent.SurfaceProviderReady -> setSurfaceProvider(cameraEvent.surfaceProvider) + } + } + + fun setCameraOrientation(cameraSettings: CameraSettings) { + _cameraSettings.update { settings -> + val isVerticalRotation = + cameraSettings.rotation == Surface.ROTATION_0 || + cameraSettings.rotation == Surface.ROTATION_180 + + val aspectRatio = when (cameraSettings.foldingState) { + FoldingState.CLOSE -> { + AspectRatioType.RATIO_9_16 + } + + FoldingState.HALF_OPEN -> { + if (isVerticalRotation) { + AspectRatioType.RATIO_9_16 + } else { + AspectRatioType.RATIO_16_9 + } + } - fun toggleCameraFacing() { + FoldingState.FLAT -> { + if (isVerticalRotation) { + AspectRatioType.RATIO_4_3 + } else { + AspectRatioType.RATIO_1_1 + } + } + } + + settings.copy( + foldingState = cameraSettings.foldingState, + rotation = cameraSettings.rotation, + aspectRatioType = aspectRatio, + ) + } + } + + private fun toggleCameraFacing() { _cameraSettings.update { settings -> settings.copy( - cameraLensFacing = if (settings.cameraLensFacing == CameraSelector.LENS_FACING_BACK) { + cameraLensFacing = + if (settings.cameraLensFacing == CameraSelector.LENS_FACING_BACK) { CameraSelector.LENS_FACING_FRONT } else { CameraSelector.LENS_FACING_BACK @@ -111,19 +159,19 @@ class CameraViewModel @Inject constructor( } } - fun setCaptureMode(captureMode: CaptureMode) { + private fun setCaptureMode(captureMode: CaptureMode) { _cameraSettings.update { settings -> settings.copy(captureMode = captureMode) } } - fun setSurfaceProvider(surfaceProvider: Preview.SurfaceProvider) { + private fun setSurfaceProvider(surfaceProvider: Preview.SurfaceProvider) { _cameraSettings.update { settings -> settings.copy(surfaceProvider = surfaceProvider) } } - fun tapToFocus( + private fun tapToFocus( display: Display, surfaceWidth: Int, surfaceHeight: Int, @@ -143,7 +191,7 @@ class CameraViewModel @Inject constructor( camera.cameraControl.startFocusAndMetering(action) } - fun setZoomScale(scale: Float) { + private fun setZoomScale(scale: Float) { val zoomState = camera.cameraInfo.zoomState.value ?: return val finalScale = (zoomState.zoomRatio * scale).coerceIn( zoomState.minZoomRatio, @@ -153,30 +201,30 @@ class CameraViewModel @Inject constructor( camera.cameraControl.setZoomRatio(finalScale) } - fun capturePhoto(onMediaCaptured: (Media) -> Unit) { + private fun capturePhoto() { viewModelScope.launch { - val uri = cameraXUseCase.capturePhoto() ?: return@launch + val uri = cameraUseCase.capturePhoto() ?: return@launch + val chaId = chatId.value ?: return@launch repository.sendMessage( - chatId = chatId.value, + chatId = chaId, text = "", mediaUri = uri.toString(), mediaMimeType = "image/jpeg", ) - onMediaCaptured(Media(uri, MediaType.PHOTO)) + _mediaCapture.emit(Media(uri, MediaType.PHOTO)) } } - @RequiresPermission(Manifest.permission.RECORD_AUDIO) - fun startVideoCapture(onMediaCaptured: (Media) -> Unit) { + private fun startVideoCapture() { viewModelScope.launch { - val media = cameraXUseCase.startVideoRecording() - onMediaCaptured(media) + val media = cameraUseCase.startVideoRecording() + _mediaCapture.emit(media) } } - fun stopVideoRecording() { - cameraXUseCase.stopVideoRecording() + private fun stopVideoRecording() { + cameraUseCase.stopVideoRecording() } } @@ -191,3 +239,20 @@ enum class FoldingState { HALF_OPEN, FLAT, } + +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/util/DisplayFeaturesMonitor.kt b/app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt new file mode 100644 index 00000000..865bf15c --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/util/DisplayFeaturesMonitor.kt @@ -0,0 +1,57 @@ +/* + * 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.DisplayFeature +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import com.google.android.samples.socialite.ui.camera.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.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 + when { + displayFeatures.isEmpty() -> FoldingState.CLOSE + hasHalfOpenedFoldingFeature(displayFeatures) -> FoldingState.HALF_OPEN + else -> FoldingState.FLAT + } + } + + private fun hasHalfOpenedFoldingFeature(displayFeatures: List): Boolean { + return displayFeatures.any { feature -> + feature is FoldingFeature && + feature.state == FoldingFeature.State.HALF_OPENED + } + } +} diff --git a/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt b/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt index 4d7d07aa..55a94b85 100644 --- a/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt +++ b/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt @@ -18,9 +18,9 @@ package com.google.android.samples.socialite.util import android.content.Context import androidx.camera.view.RotationProvider -import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.channels.awaitClose @@ -31,9 +31,9 @@ interface RotationStateMonitor { val currentRotation: Flow } -@Singleton -class RotationStateManager @Inject constructor( - @ApplicationContext context: Context, +@ActivityScoped +class RotationStateMonitorImpl @Inject constructor( + @ActivityContext context: Context, ) : RotationStateMonitor { override val currentRotation: Flow = callbackFlow { @@ -43,7 +43,7 @@ class RotationStateManager @Inject constructor( trySend(rotation) } - rotationProvider.addListener(Dispatchers.Main.asExecutor(), rotationListener) + rotationProvider.addListener(Dispatchers.Default.asExecutor(), rotationListener) awaitClose { rotationProvider.removeListener(rotationListener) From 3fd7617ec8c1529a4c3dd3e10e900256119c3f24 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Tue, 2 Apr 2024 23:46:54 +0900 Subject: [PATCH 04/15] Fixed Spotless --- .../ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt | 2 +- .../samples/socialite/ui/camera/viewfinder/surface/Texture.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt index 375240cb..3e86ad11 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt @@ -192,4 +192,4 @@ object SurfaceTransformationUtil { surfaceToViewFinder.mapRect(rect) return rect } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt index 312a0691..9a34c280 100644 --- a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt +++ b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt @@ -189,4 +189,4 @@ sealed interface SurfaceTextureEvent { data class SurfaceTextureUpdated( val surface: SurfaceTexture ) : SurfaceTextureEvent -} \ No newline at end of file +} From e4a5f4bb1e96e1c900d84284ebcd98a3c3d2d08b Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Mon, 8 Apr 2024 14:42:06 +0900 Subject: [PATCH 05/15] Remove code that locks screen orientation in camera composable --- .../samples/socialite/di/ManagerModule.kt | 2 +- .../samples/socialite/domain/CameraUseCase.kt | 7 +++- .../android/samples/socialite/ui/Main.kt | 18 --------- .../samples/socialite/ui/camera/Camera.kt | 1 + .../socialite/ui/camera/CameraViewModel.kt | 38 ++++++++----------- .../socialite/util/DisplayFeaturesMonitor.kt | 13 +++---- 6 files changed, 28 insertions(+), 51 deletions(-) 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 index 8f4323bd..c3807bcd 100644 --- 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 @@ -18,8 +18,8 @@ package com.google.android.samples.socialite.di import com.google.android.samples.socialite.util.DisplayFeaturesMonitor import com.google.android.samples.socialite.util.DisplayFeaturesMonitorImpl -import com.google.android.samples.socialite.util.RotationStateMonitorImpl import com.google.android.samples.socialite.util.RotationStateMonitor +import com.google.android.samples.socialite.util.RotationStateMonitorImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn 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 index b3b498f8..fb55f5a6 100644 --- 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 @@ -36,7 +36,6 @@ 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.FoldingState import com.google.android.samples.socialite.ui.camera.Media import com.google.android.samples.socialite.ui.camera.MediaType import dagger.hilt.android.qualifiers.ApplicationContext @@ -156,6 +155,12 @@ data class CameraSettings( 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)), 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 0b027363..3db11c7c 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 @@ -16,10 +16,7 @@ package com.google.android.samples.socialite.ui -import android.app.Activity import android.content.Intent -import android.content.pm.ActivityInfo -import android.os.Bundle import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -30,9 +27,6 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavController -import androidx.navigation.NavDestination import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -64,20 +58,8 @@ fun MainNavigation( modifier: Modifier, shortcutParams: ShortcutParams?, ) { - val activity = LocalContext.current as Activity 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) { - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR - } else { - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - } - NavHost( navController = navController, startDestination = "home", 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 46b8867c..648b81f8 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 @@ -64,6 +64,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.samples.socialite.R 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.LocalCameraOrientation import kotlinx.coroutines.flow.launchIn 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 4b02fd9a..a80239c8 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 @@ -31,6 +31,7 @@ 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 @@ -116,27 +117,24 @@ class CameraViewModel @Inject constructor( cameraSettings.rotation == Surface.ROTATION_0 || cameraSettings.rotation == Surface.ROTATION_180 - val aspectRatio = when (cameraSettings.foldingState) { - FoldingState.CLOSE -> { - AspectRatioType.RATIO_9_16 - } - - FoldingState.HALF_OPEN -> { - if (isVerticalRotation) { - AspectRatioType.RATIO_9_16 - } else { - AspectRatioType.RATIO_16_9 + val aspectRatio = + when (cameraSettings.foldingState) { + FoldingState.CLOSE, FoldingState.HALF_OPEN -> { + if (isVerticalRotation) { + AspectRatioType.RATIO_9_16 + } else { + AspectRatioType.RATIO_16_9 + } } - } - FoldingState.FLAT -> { - if (isVerticalRotation) { - AspectRatioType.RATIO_4_3 - } else { - AspectRatioType.RATIO_1_1 + FoldingState.FLAT -> { + if (isVerticalRotation) { + AspectRatioType.RATIO_4_3 + } else { + AspectRatioType.RATIO_1_1 + } } } - } settings.copy( foldingState = cameraSettings.foldingState, @@ -234,12 +232,6 @@ enum class CaptureMode { VIDEO_RECORDING, } -enum class FoldingState { - CLOSE, - HALF_OPEN, - FLAT, -} - sealed interface CameraEvent { data object ToggleCameraFacing : CameraEvent data object CapturePhoto : CameraEvent 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 index 865bf15c..c3ba02c4 100644 --- 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 @@ -17,10 +17,9 @@ package com.google.android.samples.socialite.util import android.content.Context -import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker -import com.google.android.samples.socialite.ui.camera.FoldingState +import com.google.android.samples.socialite.domain.FoldingState import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject @@ -40,7 +39,7 @@ class DisplayFeaturesMonitorImpl @Inject constructor( WindowInfoTracker.getOrCreate(context) .windowLayoutInfo(context) .map { layoutInfo -> - val displayFeatures = layoutInfo.displayFeatures + val displayFeatures = layoutInfo.displayFeatures.filterIsInstance() when { displayFeatures.isEmpty() -> FoldingState.CLOSE hasHalfOpenedFoldingFeature(displayFeatures) -> FoldingState.HALF_OPEN @@ -48,10 +47,8 @@ class DisplayFeaturesMonitorImpl @Inject constructor( } } - private fun hasHalfOpenedFoldingFeature(displayFeatures: List): Boolean { - return displayFeatures.any { feature -> - feature is FoldingFeature && - feature.state == FoldingFeature.State.HALF_OPENED + private fun hasHalfOpenedFoldingFeature(displayFeatures: List): Boolean = + displayFeatures.any { feature -> + feature.state == FoldingFeature.State.HALF_OPENED } - } } From 9daf0dc46ae2ba1addc37407e786f4f9c03273e7 Mon Sep 17 00:00:00 2001 From: Francesco Romano Date: Mon, 8 Apr 2024 11:07:02 +0200 Subject: [PATCH 06/15] Added ROTATION_ANIMATION_SEAMLESS Changed the animation on Activity rotation --- .../com/google/android/samples/socialite/MainActivity.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 15af4ca5..b1f355d4 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 @@ -43,6 +43,11 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + + val windowParams: WindowManager.LayoutParams = window.attributes + windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS + window.attributes = windowParams + setContent { val cameraOrientation by cameraOrientationUseCase().collectAsStateWithLifecycle(initialValue = CameraSettings()) From be4b51ff6cf3db24740ac84e1abfe73837d42ad0 Mon Sep 17 00:00:00 2001 From: Francesco Romano Date: Mon, 8 Apr 2024 11:33:53 +0200 Subject: [PATCH 07/15] Changed rotation to JUMPCUT --- .../java/com/google/android/samples/socialite/MainActivity.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 b1f355d4..1e88da2a 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 @@ -43,11 +43,9 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) - val windowParams: WindowManager.LayoutParams = window.attributes - windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_SEAMLESS + windowParams.rotationAnimation = WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT window.attributes = windowParams - setContent { val cameraOrientation by cameraOrientationUseCase().collectAsStateWithLifecycle(initialValue = CameraSettings()) From dea1ac9b1f44e5645f25659c13ca4213b09faf71 Mon Sep 17 00:00:00 2001 From: Francesco Romano Date: Mon, 8 Apr 2024 11:54:22 +0200 Subject: [PATCH 08/15] Add WindowManager missing dependency --- .../java/com/google/android/samples/socialite/MainActivity.kt | 1 + 1 file changed, 1 insertion(+) 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 1e88da2a..c376f364 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 @@ -18,6 +18,7 @@ package com.google.android.samples.socialite import android.content.Intent import android.os.Bundle +import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge From 89539aef91c8ddfe83586a854530a18a6fe3fbb6 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Mon, 8 Apr 2024 22:09:14 +0900 Subject: [PATCH 09/15] Modify support four orientation --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fffc9253..b3dd0ad5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,7 @@ android:theme="@style/Theme.Social.SplashScreen" android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize" + android:screenOrientation="fullUser" android:supportsPictureInPicture="true"> From 4d793511ff4218cb0df31c4b28853a1142d9069a Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Thu, 11 Apr 2024 18:38:11 +0900 Subject: [PATCH 10/15] Optimize ReComposition for CameraSettings --- .../google/android/samples/socialite/ui/CompositionLocal.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cbde3326..7a64179d 100644 --- 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 @@ -16,7 +16,7 @@ package com.google.android.samples.socialite.ui -import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.compositionLocalOf import com.google.android.samples.socialite.domain.CameraSettings -val LocalCameraOrientation = staticCompositionLocalOf { CameraSettings() } +val LocalCameraOrientation = compositionLocalOf { CameraSettings() } From 5af11c16497928dfb780a66c8511ca97e411b96c Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Fri, 10 May 2024 10:46:53 +0900 Subject: [PATCH 11/15] Move screenOrientation from Manifest to MainActivity --- app/src/main/AndroidManifest.xml | 1 - .../google/android/samples/socialite/MainActivity.kt | 4 +++- .../android/samples/socialite/domain/CameraUseCase.kt | 4 +++- .../android/samples/socialite/ui/camera/Camera.kt | 11 +---------- .../android/samples/socialite/ui/camera/ViewFinder.kt | 4 ++-- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12c9c75f..409c12af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,7 +63,6 @@ android:theme="@style/Theme.Social.SplashScreen" android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize" - android:screenOrientation="fullUser" android:supportsPictureInPicture="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 1ba65ccb..76e43ca4 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 @@ -17,6 +17,7 @@ package com.google.android.samples.socialite import android.content.Intent +import android.content.pm.ActivityInfo import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity @@ -26,11 +27,11 @@ 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.CameraOrientationUseCase import com.google.android.samples.socialite.domain.CameraSettings import com.google.android.samples.socialite.ui.LocalCameraOrientation -import androidx.glance.appwidget.updateAll import com.google.android.samples.socialite.ui.Main import com.google.android.samples.socialite.ui.ShortcutParams import com.google.android.samples.socialite.widget.SociaLiteAppWidget @@ -46,6 +47,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER super.onCreate(savedInstanceState) runBlocking { SociaLiteAppWidget().updateAll(this@MainActivity) } val windowParams: WindowManager.LayoutParams = window.attributes 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 index fb55f5a6..5df21749 100644 --- 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 @@ -70,7 +70,9 @@ class CameraXUseCase @Inject constructor( override suspend fun initializeCamera() { previewUseCase = Preview.Builder().build() - imageCaptureUseCase = ImageCapture.Builder().build() + imageCaptureUseCase = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() val recorder = Recorder.Builder() .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) 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 648b81f8..fec7bd44 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 @@ -82,7 +82,7 @@ fun Camera( val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle() - LifecycleResumeEffect { + LifecycleResumeEffect(key1 = Unit) { val job = viewModel.mediaCapture .onEach { onMediaCaptured(it) } .launchIn(viewModel.viewModelScope) @@ -243,9 +243,6 @@ private fun TwoPaneVerticalCameraLayout( ViewFinder( onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, - onTapToFocus = { display, width, height, x, y -> - onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) - }, onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, modifier = modifier .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) @@ -263,9 +260,6 @@ private fun TwoPaneHorizontalCameraLayout( Column { ViewFinder( onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, - onTapToFocus = { display, width, height, x, y -> - onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) - }, onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, modifier = modifier .weight(1f) @@ -323,9 +317,6 @@ private fun BoxScope.FlatCameraLayout( .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) .align(Alignment.Center), onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, - onTapToFocus = { display, width, height, x, y -> - onCameraEvent(CameraEvent.TapToFocus(display, width, height, x, y)) - }, onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, ) 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, From 005fc7f408977deeeb776880aab82badd8446e5f Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sat, 18 May 2024 16:50:48 +0900 Subject: [PATCH 12/15] Strong skipping mode enable --- app/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a062f250..dd0c9334 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,7 +53,11 @@ android { } kotlinOptions { jvmTarget = "17" - freeCompilerArgs = listOf("-Xcontext-receivers") + freeCompilerArgs += listOf("-Xcontext-receivers") + freeCompilerArgs += listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", + ) } buildFeatures { compose = true From a18b2d3ebec8291bb75a94dfc6200552b821fd51 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sun, 19 May 2024 17:09:43 +0900 Subject: [PATCH 13/15] Refactor Camera logic --- .idea/codeStyles/Project.xml | 2 +- .idea/codeStyles/codeStyleConfig.xml | 2 +- .../android/samples/socialite/MainActivity.kt | 15 ++- .../samples/socialite/di/ManagerModule.kt | 5 - .../domain/CameraOrientationUseCase.kt | 43 -------- .../samples/socialite/domain/CameraUseCase.kt | 12 +- .../samples/socialite/ui/CompositionLocal.kt | 4 +- .../android/samples/socialite/ui/Main.kt | 21 ++++ .../samples/socialite/ui/camera/Camera.kt | 104 ++++++++++-------- .../socialite/ui/camera/CameraViewModel.kt | 48 ++++---- .../socialite/util/DisplayFeaturesMonitor.kt | 2 + .../socialite/util/RotationStateMonitor.kt | 52 --------- 12 files changed, 121 insertions(+), 189 deletions(-) delete mode 100644 app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt delete mode 100644 app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt 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/src/main/java/com/google/android/samples/socialite/MainActivity.kt b/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt index 8f470522..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 @@ -18,7 +18,6 @@ package com.google.android.samples.socialite import android.content.Intent import android.os.Build -import android.content.pm.ActivityInfo import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity @@ -30,11 +29,11 @@ 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.CameraOrientationUseCase -import com.google.android.samples.socialite.domain.CameraSettings -import com.google.android.samples.socialite.ui.LocalCameraOrientation +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 @@ -43,7 +42,8 @@ import kotlinx.coroutines.runBlocking @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject lateinit var cameraOrientationUseCase: CameraOrientationUseCase + @Inject + lateinit var displayFeaturesMonitor: DisplayFeaturesMonitor override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -51,17 +51,16 @@ class MainActivity : ComponentActivity() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isNavigationBarContrastEnforced = false } - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER 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 { - val cameraOrientation by cameraOrientationUseCase().collectAsStateWithLifecycle(initialValue = CameraSettings()) + val foldingState by displayFeaturesMonitor.foldingState.collectAsStateWithLifecycle(initialValue = FoldingState.CLOSE) CompositionLocalProvider( - LocalCameraOrientation provides cameraOrientation, + LocalFoldingState provides foldingState, ) { Main( shortcutParams = extractShortcutParams(intent), 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 index c3807bcd..177120c6 100644 --- 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 @@ -18,8 +18,6 @@ package com.google.android.samples.socialite.di import com.google.android.samples.socialite.util.DisplayFeaturesMonitor import com.google.android.samples.socialite.util.DisplayFeaturesMonitorImpl -import com.google.android.samples.socialite.util.RotationStateMonitor -import com.google.android.samples.socialite.util.RotationStateMonitorImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -29,9 +27,6 @@ import dagger.hilt.android.components.ActivityComponent @InstallIn(ActivityComponent::class) abstract class ManagerModule { - @Binds - abstract fun bindsRotationStateManager(rotationStateMonitor: RotationStateMonitorImpl): RotationStateMonitor - @Binds abstract fun bindDisplayFeaturesMonitor(displayFeaturesMonitor: DisplayFeaturesMonitorImpl): DisplayFeaturesMonitor } diff --git a/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt b/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt deleted file mode 100644 index 5f2028c4..00000000 --- a/app/src/main/java/com/google/android/samples/socialite/domain/CameraOrientationUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 com.google.android.samples.socialite.util.DisplayFeaturesMonitor -import com.google.android.samples.socialite.util.RotationStateMonitor -import dagger.hilt.android.scopes.ActivityScoped -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine - -@ActivityScoped -class CameraOrientationUseCase @Inject constructor( - private val displayFeaturesMonitor: DisplayFeaturesMonitor, - private val rotationStateMonitor: RotationStateMonitor, -) { - - operator fun invoke(): Flow { - return combine( - displayFeaturesMonitor.foldingState, - rotationStateMonitor.currentRotation, - ) { foldingState, rotation -> - CameraSettings( - foldingState = foldingState, - rotation = rotation, - ) - } - } -} 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 index 5df21749..35c9e944 100644 --- 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 @@ -19,7 +19,6 @@ package com.google.android.samples.socialite.domain import android.content.Context import android.net.Uri import android.util.Rational -import android.view.Surface import androidx.annotation.RequiresPermission import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture @@ -49,7 +48,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine interface CameraUseCase { suspend fun initializeCamera() - fun createCameraUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup + fun createUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup suspend fun capturePhoto(): Uri? suspend fun startVideoRecording(): Media fun stopVideoRecording() @@ -122,7 +121,7 @@ class CameraXUseCase @Inject constructor( this.recording = null } - override fun createCameraUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup { + override fun createUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup { val useCaseGroupBuilder = UseCaseGroup.Builder() previewUseCase.setSurfaceProvider(cameraSettings.surfaceProvider) @@ -130,15 +129,11 @@ class CameraXUseCase @Inject constructor( useCaseGroupBuilder.setViewPort( ViewPort.Builder( cameraSettings.aspectRatioType.ratio, - cameraSettings.rotation, + previewUseCase.targetRotation, ) .build(), ) - previewUseCase.targetRotation = cameraSettings.rotation - imageCaptureUseCase.targetRotation = cameraSettings.rotation - videoCaptureUseCase.targetRotation = cameraSettings.rotation - useCaseGroupBuilder.addUseCase(previewUseCase) useCaseGroupBuilder.addUseCase(imageCaptureUseCase) useCaseGroupBuilder.addUseCase(videoCaptureUseCase) @@ -152,7 +147,6 @@ data class CameraSettings( val captureMode: CaptureMode = CaptureMode.PHOTO, val zoomScale: Float = 1f, val aspectRatioType: AspectRatioType = AspectRatioType.RATIO_9_16, - val rotation: Int = Surface.ROTATION_0, val foldingState: FoldingState = FoldingState.CLOSE, val surfaceProvider: Preview.SurfaceProvider? = null, ) 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 index 7a64179d..16a485e4 100644 --- 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 @@ -17,6 +17,6 @@ package com.google.android.samples.socialite.ui import androidx.compose.runtime.compositionLocalOf -import com.google.android.samples.socialite.domain.CameraSettings +import com.google.android.samples.socialite.domain.FoldingState -val LocalCameraOrientation = compositionLocalOf { CameraSettings() } +val LocalFoldingState = compositionLocalOf { FoldingState.CLOSE } 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 3db11c7c..af2b08e1 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 @@ -16,7 +16,10 @@ package com.google.android.samples.socialite.ui +import android.app.Activity import android.content.Intent +import android.content.pm.ActivityInfo +import android.os.Bundle import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -27,13 +30,18 @@ import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController +import androidx.navigation.NavDestination import androidx.navigation.NavType import androidx.navigation.compose.NavHost 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.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 @@ -58,8 +66,21 @@ fun MainNavigation( modifier: Modifier, 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 == CAMERA_ROUTE && foldingState == FoldingState.CLOSE) { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR + } else { + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER + } + } + NavHost( navController = navController, startDestination = "home", 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 fec7bd44..c82a571c 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,7 +17,8 @@ package com.google.android.samples.socialite.ui.camera import android.Manifest -import android.view.Surface +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 @@ -31,10 +32,9 @@ 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.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -52,8 +52,8 @@ 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.platform.LocalConfiguration import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -63,10 +63,12 @@ import androidx.lifecycle.viewModelScope import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState 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.LocalCameraOrientation +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 @@ -77,8 +79,12 @@ fun Camera( modifier: Modifier = Modifier, viewModel: CameraViewModel = hiltViewModel(), ) { - val localCameraSettings = LocalCameraOrientation.current - viewModel.setCameraOrientation(localCameraSettings) + val foldingState = LocalFoldingState.current + + viewModel.setCameraOrientation( + foldingState, + LocalConfiguration.current.orientation == ORIENTATION_PORTRAIT, + ) val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle() @@ -141,8 +147,7 @@ private fun CameraContent( modifier = modifier .fillMaxSize() .background(color = Color.Black) - .windowInsetsPadding(WindowInsets.navigationBars) - .windowInsetsPadding(WindowInsets.statusBars), + .windowInsetsPadding(WindowInsets.systemBars), ) { when (cameraSettings.foldingState) { FoldingState.HALF_OPEN -> { @@ -162,7 +167,7 @@ private fun CameraContent( IconButton( onClick = onBackPressed, - modifier = modifier + modifier = Modifier .size(50.dp) .align(Alignment.TopStart) .padding(start = 12.dp), @@ -182,34 +187,33 @@ private fun TwoPaneCameraLayout( onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { - when (cameraSettings.rotation) { - Surface.ROTATION_0, Surface.ROTATION_180 -> { - TwoPaneVerticalCameraLayout( + val configuration = LocalConfiguration.current + when (configuration.orientation) { + ORIENTATION_LANDSCAPE -> { + TwoPaneLandScapeCameraLayout( cameraSettings = cameraSettings, onCameraEvent = onCameraEvent, - modifier = modifier, ) } else -> { - TwoPaneHorizontalCameraLayout( + TwoPanePortraitCameraLayout( cameraSettings = cameraSettings, onCameraEvent = onCameraEvent, - modifier = modifier, ) } } } @Composable -private fun TwoPaneVerticalCameraLayout( +private fun TwoPaneLandScapeCameraLayout( cameraSettings: CameraSettings, onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, ) { Row { Box( - modifier = modifier + modifier = Modifier .fillMaxHeight() .weight(1f), ) { @@ -244,15 +248,15 @@ private fun TwoPaneVerticalCameraLayout( ViewFinder( onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, - modifier = modifier - .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) - .weight(1f), + modifier = Modifier + .weight(1f) + .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()), ) } } @Composable -private fun TwoPaneHorizontalCameraLayout( +private fun TwoPanePortraitCameraLayout( cameraSettings: CameraSettings, onCameraEvent: (CameraEvent) -> Unit, modifier: Modifier = Modifier, @@ -261,14 +265,14 @@ private fun TwoPaneHorizontalCameraLayout( ViewFinder( onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, onZoomChange = { onCameraEvent(CameraEvent.ZoomChange(it)) }, - modifier = modifier + modifier = Modifier .weight(1f) .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) .align(Alignment.CenterHorizontally), ) Row( - modifier = modifier + modifier = Modifier .fillMaxWidth() .weight(1f), horizontalArrangement = Arrangement.Center, @@ -313,7 +317,7 @@ private fun BoxScope.FlatCameraLayout( modifier: Modifier = Modifier, ) { ViewFinder( - modifier = modifier + modifier = Modifier .aspectRatio(cameraSettings.aspectRatioType.ratio.toFloat()) .align(Alignment.Center), onSurfaceProviderReady = { onCameraEvent(CameraEvent.SurfaceProviderReady(it)) }, @@ -445,28 +449,38 @@ private fun CameraSwitcher( } } -@Preview(device = Devices.PIXEL_FOLD) +@Preview( + showSystemUi = true, + device = "spec:width=673dp,height=841dp,dpi=420,isRound=false,chinSize=0dp,orientation=landscape", +) @Composable -private fun HalfOpenHorizontalCameraLayoutPreView() { - Column( - modifier = Modifier.fillMaxSize(), - ) { - TwoPaneCameraLayout( - cameraSettings = CameraSettings(rotation = Surface.ROTATION_270), +private fun HalfLandScapeCameraLayoutPreView() { + SocialTheme { + CameraContent( + cameraSettings = CameraSettings( + foldingState = FoldingState.HALF_OPEN, + aspectRatioType = AspectRatioType.RATIO_16_9, + ), onCameraEvent = {}, + onBackPressed = {}, ) } } -@Preview(device = Devices.PIXEL_FOLD) +@Preview( + showSystemUi = true, + device = "spec:width=673dp,height=841dp,dpi=420,isRound=false,chinSize=0dp,orientation=portrait", +) @Composable -private fun HalfOpenVerticalCameraLayoutPreView() { - Column( - modifier = Modifier.fillMaxSize(), - ) { - TwoPaneCameraLayout( - cameraSettings = CameraSettings(rotation = Surface.ROTATION_0), +private fun HalfPortraitCameraLayoutPreView() { + SocialTheme { + CameraContent( + cameraSettings = CameraSettings( + foldingState = FoldingState.HALF_OPEN, + aspectRatioType = AspectRatioType.RATIO_9_16, + ), onCameraEvent = {}, + onBackPressed = {}, ) } } @@ -474,12 +488,14 @@ private fun HalfOpenVerticalCameraLayoutPreView() { @DevicePreview @Composable private fun FlatCameraLayoutPreView() { - Box( - modifier = Modifier.fillMaxSize(), - ) { - FlatCameraLayout( - cameraSettings = CameraSettings(captureMode = CaptureMode.VIDEO_READY), + 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 a80239c8..9a6aff1b 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 @@ -18,7 +18,6 @@ package com.google.android.samples.socialite.ui.camera import android.util.Log import android.view.Display -import android.view.Surface import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.DisplayOrientedMeteringPointFactory @@ -71,7 +70,7 @@ class CameraViewModel @Inject constructor( cameraUseCase.initializeCamera() } .onEach { cameraSettings -> - val useCaseGroup = cameraUseCase.createCameraUseCaseGroup(cameraSettings) + val useCaseGroup = cameraUseCase.createUseCaseGroup(cameraSettings) cameraProvider.unbindAll() camera = cameraProvider.bindToLifecycle( @@ -111,35 +110,36 @@ class CameraViewModel @Inject constructor( } } - fun setCameraOrientation(cameraSettings: CameraSettings) { + fun setCameraOrientation( + foldingState: FoldingState, + isPortrait: Boolean, + ) { _cameraSettings.update { settings -> - val isVerticalRotation = - cameraSettings.rotation == Surface.ROTATION_0 || - cameraSettings.rotation == Surface.ROTATION_180 - - val aspectRatio = - when (cameraSettings.foldingState) { - FoldingState.CLOSE, FoldingState.HALF_OPEN -> { - if (isVerticalRotation) { - AspectRatioType.RATIO_9_16 - } else { - AspectRatioType.RATIO_16_9 - } + val ratio = when (foldingState) { + FoldingState.CLOSE -> { + AspectRatioType.RATIO_9_16 + } + + FoldingState.HALF_OPEN -> { + if (isPortrait) { + AspectRatioType.RATIO_16_9 + } else { + AspectRatioType.RATIO_9_16 } + } - FoldingState.FLAT -> { - if (isVerticalRotation) { - AspectRatioType.RATIO_4_3 - } else { - AspectRatioType.RATIO_1_1 - } + FoldingState.FLAT -> { + if (isPortrait) { + AspectRatioType.RATIO_1_1 + } else { + AspectRatioType.RATIO_4_3 } } + } settings.copy( - foldingState = cameraSettings.foldingState, - rotation = cameraSettings.rotation, - aspectRatioType = aspectRatio, + foldingState = foldingState, + aspectRatioType = ratio, ) } } 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 index c3ba02c4..52f6df5b 100644 --- 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 @@ -24,6 +24,7 @@ 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 { @@ -46,6 +47,7 @@ class DisplayFeaturesMonitorImpl @Inject constructor( else -> FoldingState.FLAT } } + .distinctUntilChanged() private fun hasHalfOpenedFoldingFeature(displayFeatures: List): Boolean = displayFeatures.any { feature -> diff --git a/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt b/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt deleted file mode 100644 index 55a94b85..00000000 --- a/app/src/main/java/com/google/android/samples/socialite/util/RotationStateMonitor.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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.camera.view.RotationProvider -import dagger.hilt.android.qualifiers.ActivityContext -import dagger.hilt.android.scopes.ActivityScoped -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -interface RotationStateMonitor { - val currentRotation: Flow -} - -@ActivityScoped -class RotationStateMonitorImpl @Inject constructor( - @ActivityContext context: Context, -) : RotationStateMonitor { - - override val currentRotation: Flow = callbackFlow { - val rotationProvider = RotationProvider(context) - - val rotationListener = RotationProvider.Listener { rotation: Int -> - trySend(rotation) - } - - rotationProvider.addListener(Dispatchers.Default.asExecutor(), rotationListener) - - awaitClose { - rotationProvider.removeListener(rotationListener) - } - } -} From c522fafb65c183eeca0bf6dccac8f7484ce73535 Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sun, 19 May 2024 23:13:02 +0900 Subject: [PATCH 14/15] Refactor CameraUseCase init logic --- .../samples/socialite/domain/CameraUseCase.kt | 60 +++++++++---------- .../android/samples/socialite/ui/Main.kt | 2 +- .../samples/socialite/ui/camera/Camera.kt | 3 +- .../socialite/ui/camera/CameraViewModel.kt | 1 - 4 files changed, 30 insertions(+), 36 deletions(-) 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 index 35c9e944..fe658dfa 100644 --- 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 @@ -47,7 +47,6 @@ import kotlinx.coroutines.asExecutor import kotlinx.coroutines.suspendCancellableCoroutine interface CameraUseCase { - suspend fun initializeCamera() fun createUseCaseGroup(cameraSettings: CameraSettings): UseCaseGroup suspend fun capturePhoto(): Uri? suspend fun startVideoRecording(): Media @@ -61,23 +60,38 @@ class CameraXUseCase @Inject constructor( private val mediaStoreOutputOptions: MediaStoreOutputOptions, ) : CameraUseCase { - private lateinit var previewUseCase: Preview - private lateinit var imageCaptureUseCase: ImageCapture - - private lateinit var videoCaptureUseCase: VideoCapture - private var recording: Recording? = null - - override suspend fun initializeCamera() { - previewUseCase = Preview.Builder().build() - imageCaptureUseCase = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() + 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() - videoCaptureUseCase = VideoCapture.Builder(recorder).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 -> @@ -120,26 +134,6 @@ class CameraXUseCase @Inject constructor( recording?.stop() ?: return this.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() - } } data class CameraSettings( 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 af2b08e1..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 @@ -77,7 +77,7 @@ fun MainNavigation( if (navDestination.route == CAMERA_ROUTE && foldingState == FoldingState.CLOSE) { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR } else { - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } } 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 c82a571c..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 @@ -80,10 +80,11 @@ fun Camera( viewModel: CameraViewModel = hiltViewModel(), ) { val foldingState = LocalFoldingState.current + val orientation = LocalConfiguration.current.orientation viewModel.setCameraOrientation( foldingState, - LocalConfiguration.current.orientation == ORIENTATION_PORTRAIT, + orientation == ORIENTATION_PORTRAIT, ) val cameraSettings by viewModel.cameraSettings.collectAsStateWithLifecycle() 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 9a6aff1b..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 @@ -67,7 +67,6 @@ class CameraViewModel @Inject constructor( val cameraSettings: StateFlow = _cameraSettings .onStart { cameraProvider = cameraProviderManager.getCameraProvider() - cameraUseCase.initializeCamera() } .onEach { cameraSettings -> val useCaseGroup = cameraUseCase.createUseCaseGroup(cameraSettings) From 4c3d1eadf7b6d159334efb71795018d3639d504b Mon Sep 17 00:00:00 2001 From: yongsuk44 Date: Sat, 22 Jun 2024 12:06:47 +0900 Subject: [PATCH 15/15] Remove empty files and compiler modes --- app/build.gradle.kts | 4 ---- .../ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt | 0 .../samples/socialite/ui/camera/viewfinder/surface/Texture.kt | 0 3 files changed, 4 deletions(-) delete mode 100644 app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt delete mode 100644 app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd0c9334..71a43462 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,10 +54,6 @@ android { kotlinOptions { jvmTarget = "17" freeCompilerArgs += listOf("-Xcontext-receivers") - freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", - ) } buildFeatures { compose = true diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/SurfaceTransformationUtil.kt deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt b/app/src/main/java/com/google/android/samples/socialite/ui/camera/viewfinder/surface/Texture.kt deleted file mode 100644 index e69de29b..00000000