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