Skip to content

Commit

Permalink
Implement peekaboo-ui module with custom camera view both for Android…
Browse files Browse the repository at this point in the history
… and iOS (#19)
  • Loading branch information
onseok committed Dec 15, 2023
1 parent fba2792 commit 999f645
Show file tree
Hide file tree
Showing 14 changed files with 795 additions and 0 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,199 @@
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.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.CircularProgressIndicator
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
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 com.preat.peekaboo.ui.icon.IconPhotoCamera
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.absoluteValue

private val executor = Executors.newSingleThreadExecutor()

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

@Suppress("FunctionName")
@Composable
private fun CameraWithGrantedPermission(
modifier: Modifier,
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(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) }

Box(
modifier =
modifier
.pointerInput(isFrontCamera) {
detectHorizontalDragGestures { change, dragAmount ->
if (dragAmount.absoluteValue > 50.0) {
isFrontCamera = !isFrontCamera
}
}
},
) {
AndroidView(
factory = { previewView },
modifier = Modifier.fillMaxSize(),
)
CircularButton(
imageVector = IconPhotoCamera,
modifier = Modifier.align(Alignment.BottomCenter).padding(36.dp),
enabled = !capturePhotoStarted,
) {
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
}
},
)
}
if (capturePhotoStarted) {
CircularProgressIndicator(
modifier = Modifier.size(80.dp).align(Alignment.Center),
color = Color.White.copy(alpha = 0.7f),
strokeWidth = 8.dp,
)
}
}
}

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,70 @@
package com.preat.peekaboo.ui

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.preat.peekaboo.ui.icon.IconCustomArrowBack
import com.preat.peekaboo.ui.style.PeekabooColors

@Suppress("FunctionName")
@Composable
fun CircularButton(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean,
onClick: () -> Unit,
) {
Box(
modifier
.size(60.dp)
.clip(CircleShape)
.background(PeekabooColors.uiLightBlack)
.run {
if (enabled) {
clickable { onClick() }
} else {
this
}
},
contentAlignment = Alignment.Center,
) {
content()
}
}

@Suppress("FunctionName")
@Composable
fun CircularButton(
imageVector: ImageVector,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
CircularButton(
modifier = modifier,
content = {
Icon(imageVector, null, Modifier.size(34.dp), Color.White)
},
enabled = enabled,
onClick = onClick,
)
}

@Suppress("FunctionName")
@Composable
fun BackButton(onClick: () -> Unit) {
CircularButton(
imageVector = IconCustomArrowBack,
onClick = onClick,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.preat.peekaboo.ui

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

@Suppress("FunctionName")
@Composable
expect fun PeekabooCamera(
modifier: Modifier,
onCapture: (byteArray: ByteArray?) -> Unit,
)
Loading

0 comments on commit 999f645

Please sign in to comment.