diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 218e671..09b17cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,13 @@ + + + + + = - appPreferencesFlow.map { - it.internalCameraAspectRatio - } - - override suspend fun setInterenalCameraAspectRatio( - @IntRange(from = -1, to = 1) aspectRatio: Int - ) { - appPreferences.updateData { - it.toBuilder() - .setInternalCameraAspectRatio(aspectRatio) - .build() - } - } override suspend fun clearAll() { appPreferences.updateData { diff --git a/domain/src/main/java/com/foke/together/domain/interactor/CaptureWithInternalCameraUseCase.kt b/domain/src/main/java/com/foke/together/domain/interactor/CaptureWithInternalCameraUseCase.kt deleted file mode 100644 index 4f6d691..0000000 --- a/domain/src/main/java/com/foke/together/domain/interactor/CaptureWithInternalCameraUseCase.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.foke.together.domain.interactor - -import android.content.Context -import com.foke.together.domain.output.ImageRepositoryInterface -import com.foke.together.domain.output.InternalCameraRepositoryInterface -import com.foke.together.util.AppLog -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject - -class CaptureWithInternalCameraUseCase @Inject constructor( - private val internalCameraRepository: InternalCameraRepositoryInterface, - private val imageRepository: ImageRepositoryInterface -) { - suspend operator fun invoke( - context: Context, - fileName: String - ): Result{ - internalCameraRepository.capture(context) - .onSuccess { - AppLog.i(TAG, "invoke", "success: $it") - imageRepository.cachingImage(it, fileName) - return Result.success(Unit) - } - .onFailure { - AppLog.e(TAG, "invoke", "failure: $it") - return Result.failure(it) - } - return Result.failure(Exception("Unknown error")) - } - - companion object { - private val TAG = CaptureWithInternalCameraUseCase::class.java.simpleName - } -} \ No newline at end of file diff --git a/domain/src/main/java/com/foke/together/domain/interactor/GeneratePhotoFrameUseCaseV1.kt b/domain/src/main/java/com/foke/together/domain/interactor/GeneratePhotoFrameUseCaseV1.kt index 6b3a1c4..4b7683b 100644 --- a/domain/src/main/java/com/foke/together/domain/interactor/GeneratePhotoFrameUseCaseV1.kt +++ b/domain/src/main/java/com/foke/together/domain/interactor/GeneratePhotoFrameUseCaseV1.kt @@ -3,6 +3,7 @@ package com.foke.together.domain.interactor import android.content.Context import android.graphics.Bitmap import android.net.Uri +import com.foke.together.domain.interactor.entity.CameraSourceType import com.foke.together.domain.output.ImageRepositoryInterface import com.foke.together.util.AppPolicy import dagger.hilt.android.qualifiers.ApplicationContext @@ -16,7 +17,7 @@ class GeneratePhotoFrameUseCaseV1 @Inject constructor( // 촬영한 이미지 리스트 관리 // TODO: 추후 세션 관리와 엮어서 처리하기 @Deprecated("Not in use") - fun getCapturedImageListUri(): List = imageRepositoryInterface.getCachedImageUriList() + fun getCapturedImageListUri(sourceType: CameraSourceType): List = imageRepositoryInterface.getCachedImageUriList(sourceType) @Deprecated("Not in use") suspend fun clearCapturedImageList() = imageRepositoryInterface.clearCacheDir() diff --git a/domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraAspectRatioUseCase.kt b/domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraAspectRatioUseCase.kt deleted file mode 100644 index 24b02f1..0000000 --- a/domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraAspectRatioUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.foke.together.domain.interactor - -import com.foke.together.domain.output.AppPreferenceInterface -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetInternalCameraAspectRatioUseCase @Inject constructor( - private val appPreferenceRepository: AppPreferenceInterface -) { - operator fun invoke() : Flow = appPreferenceRepository.getInternalCameraAspectRatio() -} \ No newline at end of file diff --git a/domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraPreviewUseCase.kt b/domain/src/main/java/com/foke/together/domain/interactor/InternalCameraUseCase.kt similarity index 62% rename from domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraPreviewUseCase.kt rename to domain/src/main/java/com/foke/together/domain/interactor/InternalCameraUseCase.kt index be40908..c705c4c 100644 --- a/domain/src/main/java/com/foke/together/domain/interactor/GetInternalCameraPreviewUseCase.kt +++ b/domain/src/main/java/com/foke/together/domain/interactor/InternalCameraUseCase.kt @@ -9,26 +9,35 @@ import androidx.lifecycle.LifecycleOwner import com.foke.together.domain.output.InternalCameraRepositoryInterface import javax.inject.Inject -class GetInternalCameraPreviewUseCase @Inject constructor( +class InternalCameraUseCase @Inject constructor( private val internalCameraRepository: InternalCameraRepositoryInterface, ) { - suspend operator fun invoke( + suspend fun initial( context: Context, - previewView: PreviewView, lifecycleOwner: LifecycleOwner, + previewView : PreviewView, cameraSelector: CameraSelector, - imageAnalysis: ImageAnalysis?, + imageAnalyzer: ImageAnalysis.Analyzer?, @IntRange(from = 0, to = 2) captureMode: Int, @IntRange(from = 0, to = 3) flashMode: Int, - @IntRange(from = -1, to = 1) aspectRatio: Int - ) = internalCameraRepository.showCameraPreview( + ) = internalCameraRepository.initial( context = context, lifecycleOwner = lifecycleOwner, - previewView = previewView, - selector = cameraSelector, - imageAnalysis = imageAnalysis, captureMode = captureMode, flashMode = flashMode, - aspectRatio = aspectRatio + selector = cameraSelector, + imageAnalyzer = imageAnalyzer, + previewView = previewView, + ) + + suspend fun capture( + context: Context, + fileName : String, + ) = internalCameraRepository.capture( + context = context, + fileName = fileName ) + suspend fun release( + context: Context + ) = internalCameraRepository.release(context) } \ No newline at end of file diff --git a/domain/src/main/java/com/foke/together/domain/output/AppPreferenceInterface.kt b/domain/src/main/java/com/foke/together/domain/output/AppPreferenceInterface.kt index 3b4f21e..5e600ab 100644 --- a/domain/src/main/java/com/foke/together/domain/output/AppPreferenceInterface.kt +++ b/domain/src/main/java/com/foke/together/domain/output/AppPreferenceInterface.kt @@ -24,14 +24,9 @@ interface AppPreferenceInterface { ) fun getInternalCameraCaptureMode(): Flow - suspend fun setInterenalCameraCaptureMode( + suspend fun setInternalCameraCaptureMode( @IntRange(from = 0, to = 2) captureMode: Int ) - fun getInternalCameraAspectRatio(): Flow - suspend fun setInterenalCameraAspectRatio( - @IntRange(from = -1, to = 1) aspectRatio: Int - ) - suspend fun clearAll() } \ No newline at end of file diff --git a/domain/src/main/java/com/foke/together/domain/output/ImageRepositoryInterface.kt b/domain/src/main/java/com/foke/together/domain/output/ImageRepositoryInterface.kt index cd037c1..78be19f 100644 --- a/domain/src/main/java/com/foke/together/domain/output/ImageRepositoryInterface.kt +++ b/domain/src/main/java/com/foke/together/domain/output/ImageRepositoryInterface.kt @@ -2,6 +2,7 @@ package com.foke.together.domain.output import android.graphics.Bitmap import android.net.Uri +import com.foke.together.domain.interactor.entity.CameraSourceType import com.foke.together.domain.interactor.entity.CutFrameTypeV1 interface ImageRepositoryInterface { @@ -9,7 +10,8 @@ interface ImageRepositoryInterface { suspend fun setCutFrameType(type: Int) // 촬영한 사진들 모음 suspend fun cachingImage(image: Bitmap, fileName: String) : Uri - fun getCachedImageUriList() : List + + fun getCachedImageUriList(sourceType: CameraSourceType) : List suspend fun clearCacheDir() // 완성된 프레임 모음 diff --git a/domain/src/main/java/com/foke/together/domain/output/InternalCameraRepositoryInterface.kt b/domain/src/main/java/com/foke/together/domain/output/InternalCameraRepositoryInterface.kt index 3e3866e..11053ce 100644 --- a/domain/src/main/java/com/foke/together/domain/output/InternalCameraRepositoryInterface.kt +++ b/domain/src/main/java/com/foke/together/domain/output/InternalCameraRepositoryInterface.kt @@ -5,20 +5,22 @@ import android.graphics.Bitmap import androidx.annotation.IntRange import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageCapture import androidx.camera.view.PreviewView import androidx.lifecycle.LifecycleOwner interface InternalCameraRepositoryInterface { - suspend fun capture(context: Context): Result - suspend fun showCameraPreview( + suspend fun capture( + context: Context, + fileName : String, + ) + suspend fun initial( context: Context, lifecycleOwner: LifecycleOwner, - previewView: PreviewView, + previewView : PreviewView, selector : CameraSelector, - imageAnalysis: ImageAnalysis?, @IntRange(from = 0, to = 2) captureMode: Int, @IntRange(from = 0, to = 3) flashMode: Int, - @IntRange(from = -1, to = 1) aspectRatio: Int + imageAnalyzer: ImageAnalysis.Analyzer?, ) + suspend fun release(context: Context) } \ No newline at end of file diff --git a/external/src/main/java/com/foke/together/external/repository/ImageRepository.kt b/external/src/main/java/com/foke/together/external/repository/ImageRepository.kt index 7e86701..fb62d23 100644 --- a/external/src/main/java/com/foke/together/external/repository/ImageRepository.kt +++ b/external/src/main/java/com/foke/together/external/repository/ImageRepository.kt @@ -4,11 +4,15 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.ImageDecoder import android.net.Uri +import android.provider.MediaStore +import com.foke.together.domain.interactor.GetCameraSourceTypeUseCase +import com.foke.together.domain.interactor.entity.CameraSourceType import com.foke.together.domain.interactor.entity.CutFrameTypeV1 import com.foke.together.domain.output.ImageRepositoryInterface import com.foke.together.util.AppPolicy import com.foke.together.util.ImageFileUtil import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.map import javax.inject.Inject class ImageRepository @Inject constructor( @@ -25,12 +29,53 @@ class ImageRepository @Inject constructor( return ImageFileUtil.cacheBitmap(context, image, fileName) } - override fun getCachedImageUriList(): List { + override fun getCachedImageUriList(sourceType: CameraSourceType): List { var uriList = mutableListOf() - context.cacheDir.listFiles().forEach { - if(it.name.contains(AppPolicy.CAPTURED_FOUR_CUT_IMAGE_NAME)){ - // capture로 시작하는 파일만 반환 - uriList.add(Uri.fromFile(it)) + when(sourceType){ + CameraSourceType.EXTERNAL -> { + context.cacheDir.listFiles().forEach { + if(it.name.contains(AppPolicy.CAPTURED_FOUR_CUT_IMAGE_NAME)){ + // capture로 시작하는 파일만 반환 + uriList.add(Uri.fromFile(it)) + } + } + } + CameraSourceType.INTERNAL -> { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATE_ADDED + ) + val selection = "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?" + val selectionArgs = arrayOf("%Pictures/4cuts/backup%") + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC" + + val cursor = context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + ) + + cursor?.use { + val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val latestUris = mutableListOf() + + var count = 0 + while (it.moveToNext() && count < AppPolicy.CAPTURE_COUNT) { + val id = it.getLong(idColumn) + val contentUri = Uri.withAppendedPath( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id.toString() + ) + latestUris.add(contentUri) + count++ + } + + latestUris.reverse() + uriList.addAll(latestUris) + } } } return uriList diff --git a/external/src/main/java/com/foke/together/external/repository/InternalCameraRepository.kt b/external/src/main/java/com/foke/together/external/repository/InternalCameraRepository.kt index 832068e..b38c179 100644 --- a/external/src/main/java/com/foke/together/external/repository/InternalCameraRepository.kt +++ b/external/src/main/java/com/foke/together/external/repository/InternalCameraRepository.kt @@ -1,7 +1,10 @@ package com.foke.together.external.repository +import android.content.ContentValues import android.content.Context import android.graphics.Bitmap +import android.os.Build +import android.provider.MediaStore import androidx.annotation.IntRange import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis @@ -11,85 +14,101 @@ import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner import com.foke.together.domain.output.InternalCameraRepositoryInterface import com.foke.together.util.AppLog import javax.inject.Inject +import javax.inject.Singleton +@Singleton class InternalCameraRepository @Inject constructor( ): InternalCameraRepositoryInterface{ + private lateinit var previewView: PreviewView + private lateinit var cameraController: LifecycleCameraController private lateinit var imageCapture: ImageCapture - private lateinit var cameraProvider : ProcessCameraProvider + + private var imageBitmap : Bitmap? = null override suspend fun capture( context: Context, - ): Result { - var imageBitmap : Bitmap? = null - imageCapture.takePicture( - ContextCompat.getMainExecutor(context), - object : ImageCapture.OnImageCapturedCallback() { - override fun onCaptureSuccess(imageProxy: ImageProxy) { - imageBitmap = imageProxy.toBitmap() - } - - override fun onError(exception: ImageCaptureException) { - imageBitmap = null - } + fileName : String, + ) { + if(::cameraController.isInitialized) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "${fileName}.jpg") + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/4cuts/backup") } - ) - return if(imageBitmap != null){ - Result.success(imageBitmap!!) - } else{ - Result.failure(Exception("Unknown error")) + val outputOptions = ImageCapture.OutputFileOptions.Builder( + context.contentResolver, + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues + ).build() + + cameraController.takePicture( + outputOptions, ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + if (outputFileResults.savedUri != null) { + imageBitmap = MediaStore.Images.Media.getBitmap( + context.contentResolver, + outputFileResults.savedUri + ) + } + } + override fun onError(exception: ImageCaptureException) { + AppLog.e(TAG, "onError", "Photo capture failed: ${exception.message}") + } + }) + } + else{ + throw Exception("cameraController is not initialized") } } - override suspend fun showCameraPreview( + override suspend fun initial( context: Context, lifecycleOwner: LifecycleOwner, - previewView: PreviewView, + previewView : PreviewView, selector : CameraSelector, - imageAnalysis: ImageAnalysis?, @IntRange(from = 0, to = 2) captureMode: Int, @IntRange(from = 0, to = 3) flashMode: Int, - @IntRange(from = -1, to = 1) aspectRatio: Int + imageAnalyzer: ImageAnalysis.Analyzer?, ) { try{ - cameraProvider = ProcessCameraProvider.getInstance(context).get() - cameraProvider.unbindAll() - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider + cameraController = LifecycleCameraController(context) + cameraController.cameraSelector = selector + if(imageAnalyzer != null){ + cameraController.setImageAnalysisAnalyzer(ContextCompat.getMainExecutor(context), imageAnalyzer) } imageCapture = ImageCapture.Builder() .setCaptureMode(captureMode) .setFlashMode(flashMode) - .setTargetAspectRatio(aspectRatio) .build() + cameraController.imageCaptureIoExecutor = ContextCompat.getMainExecutor(context) + cameraController.imageCaptureMode = captureMode + cameraController.imageCaptureFlashMode = flashMode + cameraController.bindToLifecycle(lifecycleOwner) + previewView.controller = cameraController + } + catch (e: Exception){ + AppLog.e(TAG,"showCameraPreview", e.message!!) + } + } - if(imageAnalysis != null) { - cameraProvider.bindToLifecycle( - lifecycleOwner, - selector, - preview, - imageAnalysis, - imageCapture - ) - } - else { - cameraProvider.bindToLifecycle( - lifecycleOwner, - selector, - preview, - imageCapture - ) + override suspend fun release(context: Context) { + try{ + if(::cameraController.isInitialized) { + cameraController.unbind() } } catch (e: Exception){ - AppLog.e(TAG,"showCameraPreview", e.message!!) + AppLog.e(TAG,"release", e.message!!) } } + companion object { private val TAG = InternalCameraRepository::class.java.simpleName } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61d474b..8056bc4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,9 @@ qrcode-kotlin = "4.5.0" # camerax -------- camerax = "1.5.0" +# permission ----- +accompanist = "0.37.3" + # test ----------- junit = "4.13.2" junit-version = "1.3.0" @@ -143,6 +146,9 @@ camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", vers camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } camerax-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" } +# permission --- +com-google-accompanist-permission = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } + # test ----------- junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/presenter/build.gradle.kts b/presenter/build.gradle.kts index 11ce84c..4746940 100644 --- a/presenter/build.gradle.kts +++ b/presenter/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { // reflect implementation(kotlin("reflect")) + // permission + implementation(libs.com.google.accompanist.permission) + // test testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/presenter/src/main/java/com/foke/together/presenter/navigation/NavGraph.kt b/presenter/src/main/java/com/foke/together/presenter/navigation/NavGraph.kt index 5f80650..3bb2b2a 100644 --- a/presenter/src/main/java/com/foke/together/presenter/navigation/NavGraph.kt +++ b/presenter/src/main/java/com/foke/together/presenter/navigation/NavGraph.kt @@ -1,6 +1,8 @@ package com.foke.together.presenter.navigation +import android.Manifest import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -8,13 +10,27 @@ import androidx.navigation.compose.composable import com.foke.together.presenter.screen.CameraScreen import com.foke.together.presenter.screen.GenerateImageScreen import com.foke.together.presenter.screen.HomeScreen +import com.foke.together.presenter.screen.InternalCameraScreen +import com.foke.together.presenter.screen.InternalCameraScreenRoot import com.foke.together.presenter.screen.SelectFrameScreen import com.foke.together.presenter.screen.SelectMethodScreen import com.foke.together.presenter.screen.SettingScreen import com.foke.together.presenter.screen.ShareScreen +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +@OptIn(ExperimentalPermissionsApi::class) @Composable fun NavGraph(navController: NavHostController) { + val permission = rememberPermissionState(Manifest.permission.CAMERA) + + LaunchedEffect(permission) { + if(permission.status != PermissionStatus.Granted) { + permission.launchPermissionRequest() // 권한 요청 + } + } + NavHost( navController = navController, startDestination = NavRoute.Home.path @@ -24,6 +40,7 @@ fun NavGraph(navController: NavHostController) { addSelectFrameScreen(navController, this) addSelectMethodScreen(navController, this) addCameraScreen(navController, this) + addInternalCameraScreen(navController, this) addGenerateImageScreen(navController, this) addShareScreen(navController, this) } @@ -69,7 +86,7 @@ private fun addSelectMethodScreen( navGraphBuilder.composable(route = NavRoute.SelectMethod.path) { SelectMethodScreen( navigateToCamera = { - navController.navigate(NavRoute.Camera.path) + navController.navigate(NavRoute.InternalCamera.path) }, popBackStack = { navController.popBackStack(NavRoute.SelectFrame.path, inclusive = false) @@ -94,6 +111,22 @@ private fun addCameraScreen( } } +private fun addInternalCameraScreen( + navController: NavHostController, + navGraphBuilder: NavGraphBuilder +) { + navGraphBuilder.composable(route = NavRoute.InternalCamera.path) { + InternalCameraScreenRoot( + navigateToGenerateImage = { + navController.navigate(NavRoute.GenerateSingleRowImage.path) + }, + popBackStack = { + navController.popBackStack(NavRoute.Home.path, inclusive = false) + } + ) + } +} + private fun addGenerateImageScreen( navController: NavHostController, navGraphBuilder: NavGraphBuilder diff --git a/presenter/src/main/java/com/foke/together/presenter/navigation/NavRoute.kt b/presenter/src/main/java/com/foke/together/presenter/navigation/NavRoute.kt index e0aee63..1be42b0 100644 --- a/presenter/src/main/java/com/foke/together/presenter/navigation/NavRoute.kt +++ b/presenter/src/main/java/com/foke/together/presenter/navigation/NavRoute.kt @@ -7,6 +7,7 @@ sealed class NavRoute(val path: String) { object SelectFrame: NavRoute("select_frame") object SelectMethod: NavRoute("select_method") object Camera: NavRoute("camera") + object InternalCamera : NavRoute("internal_camera") object GenerateSingleRowImage: NavRoute("generate_single_row_image") object GenerateTwoRowImage: NavRoute("generate_two_row_image") object Share: NavRoute("share") diff --git a/presenter/src/main/java/com/foke/together/presenter/screen/GenerateImageScreen.kt b/presenter/src/main/java/com/foke/together/presenter/screen/GenerateImageScreen.kt index e0182a4..f70a48d 100644 --- a/presenter/src/main/java/com/foke/together/presenter/screen/GenerateImageScreen.kt +++ b/presenter/src/main/java/com/foke/together/presenter/screen/GenerateImageScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -31,6 +32,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.foke.together.domain.interactor.entity.DefaultCutFrameSet import com.foke.together.presenter.R import com.foke.together.presenter.frame.DefaultCutFrame @@ -51,6 +53,7 @@ fun GenerateImageScreen( val graphicsLayer2 = rememberGraphicsLayer() val coroutineScope = rememberCoroutineScope() val isFirstState = remember { mutableStateOf(true) } + val imageUri by viewModel.imageUri.collectAsStateWithLifecycle() LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { AppLog.d(TAG, "LifecycleEventEffect: ON_RESUME", "${viewModel.imageUri}") @@ -87,13 +90,13 @@ fun GenerateImageScreen( // TODO: 나중에 다른 CutFrameSet 어떻게 처리해야 할지? GetDefaultFrame( cutFrame = viewModel.cutFrame as DefaultCutFrameSet, - imageUri = viewModel.imageUri, + imageUri = imageUri, graphicsLayer = graphicsLayer1, ) } else { GetDefaultFrame( cutFrame = viewModel.cutFrame as DefaultCutFrameSet, - imageUri = viewModel.imageUri, + imageUri = imageUri, graphicsLayer = graphicsLayer2, isForPrint = true, isPaddingHorizontal = 16.dp diff --git a/presenter/src/main/java/com/foke/together/presenter/screen/InternalCameraScreen.kt b/presenter/src/main/java/com/foke/together/presenter/screen/InternalCameraScreen.kt new file mode 100644 index 0000000..87b4527 --- /dev/null +++ b/presenter/src/main/java/com/foke/together/presenter/screen/InternalCameraScreen.kt @@ -0,0 +1,169 @@ +package com.foke.together.presenter.screen + +import android.content.Context +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.foke.together.presenter.R +import com.foke.together.presenter.screen.state.InternalCameraState +import com.foke.together.presenter.theme.FourCutTogetherTheme +import com.foke.together.presenter.viewmodel.InternelCameraViewModel +import com.foke.together.util.AppLog + +private val TAG = "InternalCameraScreen" + +/*TODO(CameraScreen, InternalCameraScreen 합치기) */ +@Composable +fun InternalCameraScreenRoot( + navigateToGenerateImage: () -> Unit, + popBackStack: () -> Unit, + viewModel: InternelCameraViewModel = hiltViewModel() +){ + val state = viewModel.state + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val captureCount by viewModel.captureCount.collectAsStateWithLifecycle() + val progress by viewModel.progressState.collectAsStateWithLifecycle() + LifecycleEventEffect(Lifecycle.Event.ON_START) { + viewModel.setCaptureTimer(context) { navigateToGenerateImage() } + AppLog.d(TAG, "LifecycleEventEffect. ON_START", "") + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + AppLog.d(TAG, "LifecycleEventEffect. ON_RESUME", "") + viewModel.startCaptureTimer() + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + viewModel.stopCaptureTimer() + AppLog.d(TAG, "LifecycleEventEffect. ON_STOP", "") + } + InternalCameraScreen( + state = state, + captureCount = captureCount, + progress = progress, + initialPreview = { context, previewView -> + viewModel.initial(context, lifecycleOwner, previewView) + }, + releasePreview = { context -> + viewModel.release(context) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InternalCameraScreen( + state : InternalCameraState, + captureCount : Int, + progress: Float, + initialPreview : (Context, PreviewView) -> Unit, + releasePreview : (Context) -> Unit, +){ + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "${captureCount}번째 촬영", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + textAlign = TextAlign.Center + ) + } + ) + }, + bottomBar = { + BottomAppBar { } + } + ){ paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding() + ), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ){ + Text( + modifier = Modifier.fillMaxWidth().wrapContentHeight(), + text = stringResource(id = R.string.camera_exclamation_text), + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + textAlign = TextAlign.Center + ) + Box( + modifier = Modifier.aspectRatio(1.5f).fillMaxHeight() + ) { + AndroidView( + modifier = Modifier.align(Alignment.Center).fillMaxSize(), + factory = { context -> + PreviewView(context).also { preview -> + initialPreview(context, preview) + } + }, + onRelease = { previewView -> + releasePreview(previewView.context) + } + + ) + } + } + } +} + +@Preview( + device = Devices.TABLET, + showBackground = true, +) +@Composable +fun InternalScreenPreview(){ + val state by remember { mutableStateOf(InternalCameraState()) } + val captureCount by remember{ mutableStateOf(1) } + val progress by remember{ mutableStateOf(1f)} + FourCutTogetherTheme() { + InternalCameraScreen( + state = state, + captureCount = captureCount, + progress = progress, + initialPreview = { context, previewView -> + + }, + releasePreview = { context -> + + } + ) + } +} \ No newline at end of file diff --git a/presenter/src/main/java/com/foke/together/presenter/screen/state/InternalCameraState.kt b/presenter/src/main/java/com/foke/together/presenter/screen/state/InternalCameraState.kt new file mode 100644 index 0000000..7758f83 --- /dev/null +++ b/presenter/src/main/java/com/foke/together/presenter/screen/state/InternalCameraState.kt @@ -0,0 +1,9 @@ +package com.foke.together.presenter.screen.state + +import androidx.compose.runtime.Stable + +@Stable +data class InternalCameraState( + val aspectRatio : Float = 1.5f, + val cutCount : Int = 4, +) diff --git a/presenter/src/main/java/com/foke/together/presenter/viewmodel/GenerateImageViewModel.kt b/presenter/src/main/java/com/foke/together/presenter/viewmodel/GenerateImageViewModel.kt index ba69db4..e9542f7 100644 --- a/presenter/src/main/java/com/foke/together/presenter/viewmodel/GenerateImageViewModel.kt +++ b/presenter/src/main/java/com/foke/together/presenter/viewmodel/GenerateImageViewModel.kt @@ -2,22 +2,35 @@ package com.foke.together.presenter.viewmodel import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.foke.together.domain.interactor.GenerateImageFromViewUseCase import com.foke.together.domain.interactor.GeneratePhotoFrameUseCaseV1 +import com.foke.together.domain.interactor.GetCameraSourceTypeUseCase import com.foke.together.domain.interactor.entity.CutFrame import com.foke.together.domain.interactor.session.GetCurrentSessionUseCase import com.foke.together.util.AppLog import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class GenerateImageViewModel @Inject constructor( - getCurrentSessionUseCase: GetCurrentSessionUseCase, + private val getCameraSourceTypeUseCase : GetCameraSourceTypeUseCase, + private val getCurrentSessionUseCase: GetCurrentSessionUseCase, private val generateImageFromViewUseCase: GenerateImageFromViewUseCase, - generatePhotoFrameUseCaseV1: GeneratePhotoFrameUseCaseV1, + private val generatePhotoFrameUseCaseV1: GeneratePhotoFrameUseCaseV1, ): ViewModel() { val cutFrame: CutFrame = getCurrentSessionUseCase()?.cutFrame ?: run { throw Exception("invalid cut frame") } - val imageUri = generatePhotoFrameUseCaseV1.getCapturedImageListUri() + + val imageUri = getCameraSourceTypeUseCase().map{ sourceType -> + generatePhotoFrameUseCaseV1.getCapturedImageListUri(sourceType) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = listOf() + ) suspend fun generateImage(graphicsLayer: GraphicsLayer) { val finalCachedImageUri = generateImageFromViewUseCase(graphicsLayer, isCutFrameForPrint = false) diff --git a/presenter/src/main/java/com/foke/together/presenter/viewmodel/InternelCameraViewModel.kt b/presenter/src/main/java/com/foke/together/presenter/viewmodel/InternelCameraViewModel.kt index dbc8716..0c7a64c 100644 --- a/presenter/src/main/java/com/foke/together/presenter/viewmodel/InternelCameraViewModel.kt +++ b/presenter/src/main/java/com/foke/together/presenter/viewmodel/InternelCameraViewModel.kt @@ -1,20 +1,31 @@ package com.foke.together.presenter.viewmodel import android.content.Context -import androidx.camera.core.AspectRatio +import android.os.CountDownTimer import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.view.PreviewView +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.foke.together.domain.interactor.CaptureWithInternalCameraUseCase -import com.foke.together.domain.interactor.GetInternalCameraAspectRatioUseCase +import com.foke.together.domain.interactor.GeneratePhotoFrameUseCaseV1 import com.foke.together.domain.interactor.GetInternalCameraCaptureModeUseCase import com.foke.together.domain.interactor.GetInternalCameraFlashModeUseCase import com.foke.together.domain.interactor.GetInternalCameraLensFacingUseCase -import com.foke.together.domain.interactor.GetInternalCameraPreviewUseCase +import com.foke.together.domain.interactor.InternalCameraUseCase +import com.foke.together.domain.interactor.session.GetCurrentSessionUseCase +import com.foke.together.presenter.screen.state.InternalCameraState +import com.foke.together.util.AppLog +import com.foke.together.util.AppPolicy +import com.foke.together.util.AppPolicy.CAPTURE_INTERVAL +import com.foke.together.util.AppPolicy.COUNTDOWN_INTERVAL +import com.foke.together.util.SoundUtil import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -22,13 +33,19 @@ import javax.inject.Inject @HiltViewModel class InternelCameraViewModel @Inject constructor( - private val getInternalCameraPreviewUseCase: GetInternalCameraPreviewUseCase, + private val internalCameraUseCase: InternalCameraUseCase, private val getInternalCameraLensFacingUseCase: GetInternalCameraLensFacingUseCase, private val getInternalCameraFlashModeUseCase: GetInternalCameraFlashModeUseCase, private val getInternalCameraCaptureModeUseCase: GetInternalCameraCaptureModeUseCase, - private val getInternalCameraAspectRatioUseCase: GetInternalCameraAspectRatioUseCase, - private val captureWithInternalCameraUseCase: CaptureWithInternalCameraUseCase + private val getSessionUseCase : GetCurrentSessionUseCase, + private val generatePhotoFrameUseCaseV1: GeneratePhotoFrameUseCaseV1, ): ViewModel() { + + // TODO(MutableStateFlow 로 처리하기) + val captureCount = MutableStateFlow(1) + val progressState = MutableStateFlow(1f) + private var captureTimer: CountDownTimer? = null + private var mTimerState = false private val cameraSelector = getInternalCameraLensFacingUseCase().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), @@ -44,31 +61,96 @@ class InternelCameraViewModel @Inject constructor( private val flashMode = getInternalCameraFlashModeUseCase().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(subscribeDuration), - initialValue = ImageCapture.FLASH_MODE_ON + initialValue = ImageCapture.FLASH_MODE_OFF ) - private val aspectRatio = getInternalCameraAspectRatioUseCase().stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(subscribeDuration), - initialValue = AspectRatio.RATIO_DEFAULT + + private val curFrame = getSessionUseCase()?.cutFrame ?: run { throw Exception("invalid cut frame") } + + val state = InternalCameraState( + aspectRatio = curFrame.photoPosition.first().width.toFloat() / curFrame.height.toFloat(), + cutCount = curFrame.cutCount, ) - fun preview( + fun initial( context: Context, lifecycleOwner: LifecycleOwner, previewView: PreviewView, ) = viewModelScope.launch { - getInternalCameraPreviewUseCase( - context, - previewView, - lifecycleOwner, - cameraSelector.value, - null, - captureMode.value, - flashMode.value, - aspectRatio.value + internalCameraUseCase.initial( + context = context, + lifecycleOwner = lifecycleOwner, + cameraSelector = cameraSelector.value, + imageAnalyzer = null, + captureMode = captureMode.value, + flashMode = flashMode.value, + previewView = previewView ) + } + + fun release( + context: Context, + )= viewModelScope.launch{ + internalCameraUseCase.release(context) + } + + fun capture( + context: Context, + ) = viewModelScope.launch { + val fileName = "${AppPolicy.CAPTURED_FOUR_CUT_IMAGE_NAME}_${System.currentTimeMillis()}_${captureCount}" + internalCameraUseCase.capture(context, fileName) + } + + fun setCaptureTimer( + context: Context, + nextNavigate : () -> Unit + ){ + if (AppPolicy.isNoCameraDebugMode) { + nextNavigate() + return + } + + viewModelScope.launch { + generatePhotoFrameUseCaseV1.clearCapturedImageList() + } + + captureTimer = object : CountDownTimer(CAPTURE_INTERVAL, COUNTDOWN_INTERVAL) { + override fun onTick(millisUntilFinished: Long) { + progressState.value = 1f - (millisUntilFinished.toFloat() / CAPTURE_INTERVAL) + } + override fun onFinish() { + SoundUtil.getCameraSound(context = context ) + // TODO: 현재 External 실패 시, 스크린 캡쳐 화면을 사용하도록 구성함 + capture(context) + progressState.value = 1f + if (captureCount.value < AppPolicy.CAPTURE_COUNT) { + AppLog.d(TAG, "captureCount", "$captureCount") + captureCount.value += 1 + start() + } else { + AppLog.d(TAG, "captureCount", "$captureCount") + stopCaptureTimer() + captureCount.value = 1 + nextNavigate() + } + } + } + } + + fun startCaptureTimer() = viewModelScope.launch{ + if(captureTimer != null){ + if(!mTimerState){ + mTimerState = true + captureTimer!!.start() + } + } + } + fun stopCaptureTimer() = viewModelScope.launch{ + if(captureTimer != null){ + mTimerState = false + captureTimer!!.cancel() + } } companion object { diff --git a/presenter/src/main/java/com/foke/together/presenter/viewmodel/ShareViewModel.kt b/presenter/src/main/java/com/foke/together/presenter/viewmodel/ShareViewModel.kt index 9b0029f..657a6ee 100644 --- a/presenter/src/main/java/com/foke/together/presenter/viewmodel/ShareViewModel.kt +++ b/presenter/src/main/java/com/foke/together/presenter/viewmodel/ShareViewModel.kt @@ -70,7 +70,7 @@ class ShareViewModel @Inject constructor( val result = uploadFileUseCase(sessionKey, singleImageUri.toFile()) AppLog.d(TAG, "generateQRcode" ,"result: $result") - val downloadUrl: String = getDownloadUrlUseCase(sessionKey).getOrElse { "https://4cuts.store" } + val downloadUrl: String = getDownloadUrlUseCase(sessionKey).getOrElse { "https://foke.clon.dev" } if (AppPolicy.isDebugMode) { AppLog.e(TAG, "generateQRcode", "sessionKey: $sessionKey") AppLog.e(TAG, "generateQRcode", "downloadUrl: $downloadUrl") diff --git a/util/src/main/java/com/foke/together/util/ImageFileUtil.kt b/util/src/main/java/com/foke/together/util/ImageFileUtil.kt index d94c310..7be0ff9 100644 --- a/util/src/main/java/com/foke/together/util/ImageFileUtil.kt +++ b/util/src/main/java/com/foke/together/util/ImageFileUtil.kt @@ -92,7 +92,7 @@ object ImageFileUtil { putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - startActivity(context, createChooser(intent, "Share your image"), null) + context.startActivity( createChooser(intent, "Share your image"), null) } fun getBitmapFromUri(context: Context, uri: Uri): Bitmap {