Skip to content

Commit

Permalink
Implement custom QR code scanning UI (#903)
Browse files Browse the repository at this point in the history
Implement custom UI for QR code scanning
  • Loading branch information
aleksandar-apostolov authored Nov 7, 2023
1 parent 0733bb4 commit 0fdb67b
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 50 deletions.
7 changes: 7 additions & 0 deletions dogfooding/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.coil.compose)

// QR code scanning
implementation(libs.androidx.camera.core)
implementation(libs.play.services.mlkit.barcode.scanning)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.camera2)

// hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import io.getstream.video.android.DirectCallActivity
import io.getstream.video.android.ui.join.CallJoinScreen
import io.getstream.video.android.ui.join.barcode.BarcodeScanner
import io.getstream.video.android.ui.lobby.CallLobbyScreen
import io.getstream.video.android.ui.login.LoginScreen
import io.getstream.video.android.ui.outgoing.DirectCallJoinScreen
Expand Down Expand Up @@ -66,6 +67,9 @@ fun AppNavHost(
navigateToDirectCallJoin = {
navController.navigate(AppScreens.DirectCallJoin.route)
},
navigateToBarcodeScanner = {
navController.navigate(AppScreens.BarcodeScanning.route)
},
)
}
composable(
Expand Down Expand Up @@ -93,6 +97,11 @@ fun AppNavHost(
},
)
}
composable(AppScreens.BarcodeScanning.route) {
BarcodeScanner {
navController.popBackStack()
}
}
}
}

Expand All @@ -101,8 +110,7 @@ enum class AppScreens(val route: String) {
CallJoin("call_join"),
CallLobby("call_lobby/{cid}"),
DirectCallJoin("direct_call_join"),
;

BarcodeScanning("barcode_scanning"), ;
fun routeWithArg(argValue: Any): String = when (this) {
Login -> this.route.replace("{auto_log_in}", argValue.toString())
CallLobby -> this.route.replace("{cid}", argValue.toString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@

package io.getstream.video.android.ui.join

import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
Expand Down Expand Up @@ -77,15 +75,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.android.gms.tasks.OnSuccessListener
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import io.getstream.video.android.BuildConfig
import io.getstream.video.android.DeeplinkingActivity
import io.getstream.video.android.R
import io.getstream.video.android.analytics.FirebaseEvents
import io.getstream.video.android.compose.theme.VideoTheme
import io.getstream.video.android.compose.ui.components.avatar.UserAvatar
import io.getstream.video.android.datastore.delegate.StreamUserDataStore
Expand All @@ -100,10 +91,9 @@ fun CallJoinScreen(
navigateToCallLobby: (callId: String) -> Unit,
navigateUpToLogin: (autoLogIn: Boolean) -> Unit,
navigateToDirectCallJoin: () -> Unit,
navigateToBarcodeScanner: () -> Unit = {},
) {
val uiState by callJoinViewModel.uiState.collectAsState(CallJoinUiState.Nothing)
val context = LocalContext.current
val qrCodeCallback = rememberQrCodeCallback()
var isSignOutDialogVisible by remember { mutableStateOf(false) }
val isLoggedOut by callJoinViewModel.isLoggedOut.collectAsState(initial = false)

Expand Down Expand Up @@ -137,10 +127,7 @@ fun CallJoinScreen(
.weight(1f),
callJoinViewModel = callJoinViewModel,
openCamera = {
val options = GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE, Barcode.FORMAT_AZTEC).build()
val scanner = GmsBarcodeScanning.getClient(context, options)
scanner.startScan().addOnSuccessListener(qrCodeCallback)
navigateToBarcodeScanner()
},
)
}
Expand Down Expand Up @@ -441,39 +428,6 @@ private fun SignOutDialog(
)
}

@Composable
private fun rememberQrCodeCallback(): OnSuccessListener<Barcode> {
val context = LocalContext.current
val firebaseAnalytics by lazy { FirebaseAnalytics.getInstance(context) }

return remember {
OnSuccessListener<Barcode> {
val url = it.url?.url
val callId = if (url != null) {
val id = Uri.parse(url).getQueryParameter("id")
if (!id.isNullOrEmpty()) {
id
} else {
null
}
} else {
null
}

if (!callId.isNullOrEmpty()) {
firebaseAnalytics.logEvent(FirebaseEvents.SCAN_QR_CODE, null)
context.startActivity(DeeplinkingActivity.createIntent(context, callId))
} else {
Toast.makeText(
context,
"Unrecognised meeting QR code format",
Toast.LENGTH_SHORT,
).show()
}
}
}
}

@Preview
@Composable
private fun CallJoinScreenPreview() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2014-2023 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* 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 io.getstream.video.android.ui.join.barcode

import android.net.Uri
import android.util.Log
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.android.gms.tasks.OnSuccessListener
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import io.getstream.video.android.DeeplinkingActivity
import io.getstream.video.android.R
import io.getstream.video.android.analytics.FirebaseEvents
import io.getstream.video.android.compose.theme.VideoTheme
import java.util.concurrent.Executor
import java.util.concurrent.Executors

@Composable
internal fun BarcodeScanner(navigateBack: () -> Unit = {}) {
val executor: Executor = Executors.newSingleThreadExecutor()
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE, Barcode.FORMAT_AZTEC)
.build()
val barcodeScanner = remember { BarcodeScanning.getClient(options) }
val qrCodeCallback = rememberQrCodeCallback()
val imageAnalysis = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(executor) { imageProxy ->
processImageProxy(imageProxy, barcodeScanner, qrCodeCallback)
}
}
val color = VideoTheme.colors.primaryAccent
Box(modifier = Modifier.fillMaxSize()) {
CameraPreview(imageAnalysis = imageAnalysis)
CornerRectWithArcs(color = color, cornerRadius = 32f, strokeWidth = 12f)
IconButton(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp),
onClick = {
navigateBack()
},
) {
Icon(
imageVector = Icons.Filled.Cancel,
contentDescription = null,
tint = Color.White,
)
}
Text(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(8.dp),
textAlign = TextAlign.Center,
color = Color.White,
text = stringResource(id = R.string.scan_qr_code_to_enter),
)
}
}

@Composable
private fun CameraPreview(
modifier: Modifier = Modifier,
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER,
imageAnalysis: ImageAnalysis,
) {
val lifecycleOwner = LocalLifecycleOwner.current

AndroidView(
modifier = modifier,
factory = { context ->
val previewView = PreviewView(context).apply {
this.scaleType = scaleType
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}

val cameraProviderFuture = ProcessCameraProvider.getInstance(context)

cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = androidx.camera.core.Preview.Builder()
.build()
.apply {
setSurfaceProvider(previewView.surfaceProvider)
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalysis,
)
} catch (e: Exception) {
Log.e("BarcodeScanner", "Could not bind camera to lifecycle", e)
}
}, ContextCompat.getMainExecutor(context))
previewView
},
)
}

@Composable
private fun BoxScope.CornerRectWithArcs(color: Color, cornerRadius: Float, strokeWidth: Float) {
Canvas(
modifier = Modifier
.align(Alignment.Center)
.size(250.dp, 250.dp)
.padding(32.dp),
) {
val cornerData = listOf(
Pair(180f, Offset(0f, 0f)),
Pair(270f, Offset(size.width - cornerRadius * 2, 0f)),
Pair(90f, Offset(0f, size.height - cornerRadius * 2)),
Pair(0f, Offset(size.width - cornerRadius * 2, size.height - cornerRadius * 2)),
)
cornerData.forEach {
drawArc(
color = color,
startAngle = it.first,
sweepAngle = 90f,
useCenter = false,
style = Stroke(width = strokeWidth),
size = Size(cornerRadius * 2, cornerRadius * 2),
topLeft = it.second,
)
}
}
}

@Composable
private fun rememberQrCodeCallback(): OnSuccessListener<Barcode> {
val context = LocalContext.current
val firebaseAnalytics by lazy { FirebaseAnalytics.getInstance(context) }

return remember {
OnSuccessListener<Barcode> {
val url = it.url?.url
val callId = if (url != null) {
val id = Uri.parse(url).getQueryParameter("id")
if (!id.isNullOrEmpty()) {
id
} else {
null
}
} else {
null
}

if (!callId.isNullOrEmpty()) {
firebaseAnalytics.logEvent(FirebaseEvents.SCAN_QR_CODE, null)
context.startActivity(DeeplinkingActivity.createIntent(context, callId))
} else {
Toast.makeText(
context,
"Unrecognised meeting QR code format",
Toast.LENGTH_SHORT,
).show()
}
}
}
}

@OptIn(ExperimentalGetImage::class)
private fun processImageProxy(
imageProxy: ImageProxy,
barcodeScanner: BarcodeScanner,
onBarcodeScanned: OnSuccessListener<Barcode>,
) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
// Handle the scanned barcode data
onBarcodeScanned.onSuccess(barcode)
}
}
.addOnCompleteListener {
imageProxy.close()
}
} else {
imageProxy.close()
}
}

@ExperimentalGetImage
@Preview
@Composable
private fun BarcodeScanUIPreview() {
VideoTheme {
BarcodeScanner()
}
}
Loading

0 comments on commit 0fdb67b

Please sign in to comment.