diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index fd8d912..6b7198f 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -91,7 +91,11 @@ dependencies { implementation(libs.google.hilt.android.core) // ZXing/Camera (QR) - implementation(libs.androidx.camera) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.zxing.core) implementation(libs.zxing.cpp) } diff --git a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt index c926081..23d67a2 100644 --- a/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt +++ b/androidApp/src/main/java/app/opass/ccip/android/ui/screens/ticket/TicketScreen.kt @@ -5,13 +5,22 @@ package app.opass.ccip.android.ui.screens.ticket +import android.Manifest +import android.content.Context import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -22,7 +31,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -33,6 +41,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -56,8 +65,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import app.opass.ccip.android.R @@ -73,6 +86,9 @@ import coil.compose.SubcomposeAsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import kotlinx.coroutines.android.awaitFrame +import zxingcpp.BarcodeReader +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine @Composable fun Screen.Ticket.TicketScreen( @@ -162,9 +178,16 @@ private fun RequestTicket( val eventConfig by viewModel.eventConfig.collectAsStateWithLifecycle() val isVerifying by viewModel.isVerifying.collectAsStateWithLifecycle() + var shouldShowCameraPreview by rememberSaveable { mutableStateOf(false) } var shouldShowVerificationDialog by rememberSaveable { mutableStateOf(false) } var shouldShowManualEntryDialog by rememberSaveable { mutableStateOf(false) } + val hasCamera = context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = RequestPermission(), + onResult = { shouldShowCameraPreview = it } + ) + val startActivityForResult = rememberLauncherForActivityResult( contract = PickVisualMedia(), onResult = { uri -> @@ -189,6 +212,16 @@ private fun RequestTicket( ) } + if (shouldShowCameraPreview) { + CameraPreviewScreen( + onTokenFound = { + viewModel.getAttendee(eventConfig!!.id, it) + shouldShowCameraPreview = false + }, + onDismiss = { shouldShowCameraPreview = false } + ) + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -223,8 +256,21 @@ private fun RequestTicket( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally) ) { - if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { - Button(onClick = {}) { + if (hasCamera) { + Button( + onClick = { + val hasCameraPerm = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + + if (hasCameraPerm) { + shouldShowCameraPreview = true + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + ) { Text(text = stringResource(R.string.scan)) } } @@ -394,3 +440,67 @@ private fun ManualEntryDialog(onConfirm: (token: String) -> Unit = {}, onDismiss } ) } + +@Composable +fun CameraPreviewScreen(onTokenFound: (token: String) -> Unit = {}, onDismiss: () -> Unit = {}) { + val lensFacing = CameraSelector.LENS_FACING_BACK + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val previewView = remember { PreviewView(context) } + + val preview = Preview.Builder() + .build() + val cameraxSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + val reader = BarcodeReader().apply { + options.tryRotate = true + } + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build().apply { + setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> + reader.read(imageProxy).firstOrNull()?.let { result -> + if (result.format == BarcodeReader.Format.QR_CODE || !result.text.isNullOrBlank()) { + onTokenFound(result.text!!) + } + } + imageProxy.close() + } + } + + LaunchedEffect(key1 = lensFacing) { + val cameraProvider = getCameraProvider(context) + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraxSelector, imageAnalysis, preview) + preview.surfaceProvider = previewView.surfaceProvider + } + + Dialog( + onDismissRequest = { + + onDismiss() + }, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + decorFitsSystemWindows = true, + usePlatformDefaultWidth = false + ), + ) { + Surface(modifier = Modifier.fillMaxSize()) { + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + } + } +} + +private suspend fun getCameraProvider(context: Context): ProcessCameraProvider { + return suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(context).also { cameraProvider -> + cameraProvider.addListener({ + continuation.resume(cameraProvider.get()) + }, ContextCompat.getMainExecutor(context)) + } + } +} diff --git a/androidApp/src/main/java/app/opass/ccip/android/utils/QrImageAnalyzer.kt b/androidApp/src/main/java/app/opass/ccip/android/utils/QrImageAnalyzer.kt new file mode 100644 index 0000000..b836b4d --- /dev/null +++ b/androidApp/src/main/java/app/opass/ccip/android/utils/QrImageAnalyzer.kt @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2024 OPass + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.opass.ccip.android.utils + +import android.util.Log +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.zxing.BarcodeFormat +import com.google.zxing.Result +import com.google.zxing.client.result.ResultParser +import zxingcpp.BarcodeReader + +class QrImageAnalyzer(onTokenFound: (token: String) -> Unit = {}): ImageAnalysis.Analyzer { + + private val reader = BarcodeReader().apply { + options.tryRotate = true + } + + override fun analyze(image: ImageProxy) { + image.use { + reader.read(image).firstOrNull()?.let { rawResult -> + if (rawResult.format != BarcodeReader.Format.QR_CODE || rawResult.text.isNullOrBlank()) return + + val result = Result( + rawResult.text, + rawResult.bytes, + null, + BarcodeFormat.QR_CODE + ) + val parsedResult = ResultParser.parseResult(result) + Log.d("AAYUSH", "TOKEN: ${parsedResult.displayResult}, ${rawResult.text}") + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7302d0..d55d31e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,10 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-adaptive-android = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidx-adaptiveAndroid" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } -androidx-camera = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } androidx-hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-composeNavigation" } androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }