Skip to content

Commit

Permalink
Merge pull request #20 from TEAM-PREAT/feature/add-custom-camera-view
Browse files Browse the repository at this point in the history
[feature/add-custom-camera-view] Implement Custom Camera View
  • Loading branch information
onseok authored Dec 17, 2023
2 parents fba2792 + 8249037 commit 66f2591
Show file tree
Hide file tree
Showing 21 changed files with 1,139 additions and 58 deletions.
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ espresso-core = "3.5.1"
appcompat = "1.6.1"
material = "1.11.0"
accompanist = "0.32.0"
camerax = "1.3.1"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
Expand All @@ -27,6 +28,7 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref =
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
components-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "compose"}
nexus-publish = { module = "io.github.gradle-nexus.publish-plugin:io.github.gradle-nexus.publish-plugin.gradle.plugin", version.ref = "nexus-publish" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
Expand All @@ -35,6 +37,9 @@ espresso-core = { group = "androidx.test.espresso", name = "espresso-core", vers
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
Expand Down
53 changes: 53 additions & 0 deletions peekaboo-ui/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
id("module.publication")
}

kotlin {
androidTarget {
publishLibraryVariants("release")
compilations.all {
kotlinOptions {
jvmTarget = "11"
}
}
}

iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(libs.components.resources)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.permissions)
implementation(libs.camera.camera2)
implementation(libs.camera.lifecycle)
implementation(libs.camera.view)
}
}
}

android {
namespace = "com.preat.peekaboo.ui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
sourceSets["main"].res.srcDirs("src/androidMain/res")
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
12 changes: 12 additions & 0 deletions peekaboo-ui/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.CAMERA" />
<application>
<provider
android:name="com.preat.peekaboo.ui.ImageViewerFileProvider"
android:authorities="com.preat.peekaboo.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.preat.peekaboo.ui

import androidx.core.content.FileProvider

class ImageViewerFileProvider : FileProvider(R.xml.file_paths)
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package com.preat.peekaboo.ui

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCapture.OnImageCapturedCallback
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

private val executor = Executors.newSingleThreadExecutor()

@Suppress("FunctionName")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
actual fun PeekabooCamera(
modifier: Modifier,
cameraMode: CameraMode,
captureIcon: @Composable (onClick: () -> Unit) -> Unit,
convertIcon: @Composable (onClick: () -> Unit) -> Unit,
progressIndicator: @Composable () -> Unit,
onCapture: (byteArray: ByteArray?) -> Unit,
) {
val cameraPermissionState =
rememberPermissionState(
android.Manifest.permission.CAMERA,
)
when (cameraPermissionState.status) {
PermissionStatus.Granted -> {
CameraWithGrantedPermission(
modifier = modifier,
cameraMode = cameraMode,
captureIcon = captureIcon,
convertIcon = convertIcon,
progressIndicator = progressIndicator,
onCapture = onCapture,
)
}
is PermissionStatus.Denied -> {
LaunchedEffect(Unit) {
cameraPermissionState.launchPermissionRequest()
}
}
}
}

@Suppress("FunctionName")
@Composable
private fun CameraWithGrantedPermission(
modifier: Modifier,
cameraMode: CameraMode,
captureIcon: @Composable (() -> Unit) -> Unit,
convertIcon: @Composable (onClick: () -> Unit) -> Unit,
progressIndicator: @Composable () -> Unit,
onCapture: (byteArray: ByteArray) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) }

val preview = Preview.Builder().build()
val previewView = remember { PreviewView(context) }
val imageCapture: ImageCapture = remember { ImageCapture.Builder().build() }
var isFrontCamera by rememberSaveable {
mutableStateOf(
when (cameraMode) {
CameraMode.Front -> true
CameraMode.Back -> false
},
)
}
val cameraSelector =
remember(isFrontCamera) {
val lensFacing =
if (isFrontCamera) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
CameraSelector.Builder()
.requireLensFacing(lensFacing)
.build()
}

DisposableEffect(Unit) {
onDispose {
cameraProvider?.unbindAll()
}
}

LaunchedEffect(isFrontCamera) {
cameraProvider =
suspendCoroutine<ProcessCameraProvider> { continuation ->
ProcessCameraProvider.getInstance(context).also { cameraProvider ->
cameraProvider.addListener(
{
continuation.resume(cameraProvider.get())
},
executor,
)
}
}
cameraProvider?.unbindAll()
cameraProvider?.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture,
)
preview.setSurfaceProvider(previewView.surfaceProvider)
}

var capturePhotoStarted by remember { mutableStateOf(false) }

val triggerCapture: () -> Unit = {
capturePhotoStarted = true
imageCapture.takePicture(
executor,
object : OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
val rotationDegrees = image.imageInfo.rotationDegrees
val buffer = image.planes[0].buffer
val data = buffer.toByteArray()

// Rotate the image if necessary
val rotatedData =
if (rotationDegrees != 0) {
rotateImage(data, rotationDegrees)
} else {
data
}

image.close()
onCapture(rotatedData)
capturePhotoStarted = false
}
},
)
}

val toggleCamera: () -> Unit = {
isFrontCamera = !isFrontCamera
}

Box(modifier = modifier) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize(),
)
// Call the triggerCapture lambda when the capture button is clicked
captureIcon(triggerCapture)
convertIcon(toggleCamera)
if (capturePhotoStarted) {
progressIndicator()
}
}
}

private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}

private fun rotateImage(
data: ByteArray,
degrees: Int,
): ByteArray {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
val outputStream = ByteArrayOutputStream()
rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
return outputStream.toByteArray()
}
3 changes: 3 additions & 0 deletions peekaboo-ui/src/androidMain/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="my_images" path="share_images/"/>
</paths>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.preat.peekaboo.ui

sealed class CameraMode {
data object Front : CameraMode()

data object Back : CameraMode()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.preat.peekaboo.ui

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

/**
* `PeekabooCamera` is a composable function that provides a customizable camera UI within a Compose Multiplatform application.
* It allows for the display of a camera preview, along with custom capture and convert buttons, and an optional progress indicator during photo capture.
*
* @param modifier The [Modifier] applied to the camera UI component for layout customization.
* @param cameraMode The initial camera mode (front or back). Default is [CameraMode.Back].
* @param captureIcon A composable lambda for the capture button. It takes an `onClick` lambda that triggers the image capture process.
* @param convertIcon An optional composable lambda for a button to toggle the camera mode. It takes an `onClick` lambda for switching the camera.
* @param progressIndicator An optional composable lambda displayed during photo capture processing.
* @param onCapture A lambda called when a photo is captured, providing the photo as a ByteArray or null if the capture fails.
*/
@Suppress("FunctionName")
@Composable
expect fun PeekabooCamera(
modifier: Modifier,
cameraMode: CameraMode = CameraMode.Back,
captureIcon: @Composable (onClick: () -> Unit) -> Unit,
convertIcon: @Composable (onClick: () -> Unit) -> Unit = {},
progressIndicator: @Composable () -> Unit = {},
onCapture: (byteArray: ByteArray?) -> Unit,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.preat.peekaboo.ui

internal sealed interface CameraAccess {
data object Undefined : CameraAccess

data object Denied : CameraAccess

data object Authorized : CameraAccess
}
Loading

0 comments on commit 66f2591

Please sign in to comment.