Skip to content

Commit 9565d49

Browse files
committed
QR scanner implementation
1 parent 77147a4 commit 9565d49

5 files changed

Lines changed: 462 additions & 61 deletions

File tree

app/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ dependencies {
8989
implementation(libs.ui.graphics)
9090
implementation(libs.androidx.foundation)
9191

92+
// CameraX for QR scanning
93+
implementation("androidx.camera:camera-core:1.4.0")
94+
implementation("androidx.camera:camera-camera2:1.4.0")
95+
implementation("androidx.camera:camera-lifecycle:1.4.0")
96+
implementation("androidx.camera:camera-view:1.4.0")
97+
implementation("androidx.camera:camera-mlkit-vision:1.4.0")
98+
99+
// ML Kit barcode scanner (QR code only)
100+
implementation("com.google.mlkit:barcode-scanning:17.3.0")
101+
92102
testImplementation(libs.junit)
93103
androidTestImplementation(libs.androidx.junit)
94104
androidTestImplementation(libs.androidx.espresso.core)

app/src/main/AndroidManifest.xml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
2323
<uses-permission android:name="android.permission.WAKE_LOCK" />
2424

25+
<!-- Camera permission for QR code scanner -->
26+
<uses-permission android:name="android.permission.CAMERA" />
27+
2528
<application
2629
android:allowBackup="true"
2730
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -93,6 +96,20 @@
9396
</intent-filter>
9497
</service>
9598

99+
<!-- QR Code Scanner Activity -->
100+
<activity
101+
android:name=".presentation.ui.activities.QRScannerActivity"
102+
android:exported="true"
103+
android:label="@string/app_name"
104+
android:theme="@style/Theme.AirSync"
105+
android:screenOrientation="portrait">
106+
<!-- Allow external apps to launch the scanner -->
107+
<intent-filter>
108+
<action android:name="com.sameerasw.airsync.SCAN_QR" />
109+
<category android:name="android.intent.category.DEFAULT" />
110+
</intent-filter>
111+
</activity>
112+
96113
<!-- Mac Media Player Service - creates native Android media player UI -->
97114
<service
98115
android:name=".service.MacMediaPlayerService"
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
package com.sameerasw.airsync.presentation.ui.activities
2+
3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.content.Intent
6+
import android.content.pm.PackageManager
7+
import android.os.Bundle
8+
import android.util.Log
9+
import androidx.activity.ComponentActivity
10+
import androidx.activity.compose.setContent
11+
import androidx.activity.result.contract.ActivityResultContracts
12+
import androidx.camera.core.CameraSelector
13+
import androidx.camera.core.ImageAnalysis
14+
import androidx.camera.core.ImageProxy
15+
import androidx.camera.lifecycle.ProcessCameraProvider
16+
import androidx.camera.view.PreviewView
17+
import androidx.compose.foundation.background
18+
import androidx.compose.foundation.layout.*
19+
import androidx.compose.material.icons.Icons
20+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+
import androidx.compose.material3.*
22+
import androidx.compose.runtime.*
23+
import androidx.compose.ui.Alignment
24+
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.graphics.Color
26+
import androidx.compose.ui.text.style.TextAlign
27+
import androidx.compose.ui.unit.dp
28+
import androidx.compose.ui.viewinterop.AndroidView
29+
import androidx.compose.foundation.Canvas
30+
import androidx.core.content.ContextCompat
31+
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
32+
import com.google.mlkit.vision.barcode.BarcodeScanning
33+
import com.google.mlkit.vision.common.InputImage
34+
import com.sameerasw.airsync.ui.theme.AirSyncTheme
35+
import java.util.concurrent.Executors
36+
37+
class QRScannerActivity : ComponentActivity() {
38+
39+
private val cameraPermissionLauncher = registerForActivityResult(
40+
ActivityResultContracts.RequestPermission()
41+
) { isGranted ->
42+
if (!isGranted) {
43+
// Permission denied, finish the activity
44+
setResult(RESULT_CANCELED)
45+
finish()
46+
}
47+
}
48+
49+
override fun onCreate(savedInstanceState: Bundle?) {
50+
super.onCreate(savedInstanceState)
51+
52+
// Check camera permission
53+
if (ContextCompat.checkSelfPermission(
54+
this,
55+
Manifest.permission.CAMERA
56+
) != PackageManager.PERMISSION_GRANTED
57+
) {
58+
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
59+
return
60+
}
61+
62+
setContent {
63+
AirSyncTheme {
64+
QRScannerScreen(
65+
onQrScanned = { qrData ->
66+
// Return the scanned QR code data to the caller
67+
val intent = Intent().apply {
68+
putExtra("QR_CODE", qrData)
69+
}
70+
setResult(RESULT_OK, intent)
71+
finish()
72+
},
73+
onClosed = {
74+
setResult(RESULT_CANCELED)
75+
finish()
76+
}
77+
)
78+
}
79+
}
80+
}
81+
}
82+
83+
@SuppressLint("UnsafeOptInUsageError")
84+
@Composable
85+
private fun QRScannerScreen(
86+
onQrScanned: (String) -> Unit,
87+
onClosed: () -> Unit
88+
) {
89+
var scanned by remember { mutableStateOf(false) }
90+
var lastScannedCode by remember { mutableStateOf("") }
91+
var scanMessage by remember { mutableStateOf("") }
92+
93+
Scaffold(
94+
modifier = Modifier.fillMaxSize(),
95+
topBar = {
96+
@OptIn(ExperimentalMaterial3Api::class)
97+
TopAppBar(
98+
title = { Text("Scan QR Code") },
99+
navigationIcon = {
100+
IconButton(onClick = onClosed) {
101+
Icon(
102+
Icons.AutoMirrored.Filled.ArrowBack,
103+
contentDescription = "Back"
104+
)
105+
}
106+
}
107+
)
108+
}
109+
) { innerPadding ->
110+
Box(
111+
modifier = Modifier
112+
.fillMaxSize()
113+
.padding(innerPadding)
114+
.background(Color.Black)
115+
) {
116+
QrCodeScannerView(
117+
modifier = Modifier.fillMaxSize(),
118+
onQrScanned = { qrData ->
119+
if (!scanned && qrData != lastScannedCode) {
120+
lastScannedCode = qrData
121+
scanned = true
122+
scanMessage = "QR Code detected!"
123+
Log.d("QrScanner", "QR detected in screen: $qrData")
124+
// Small delay to ensure UI update before finishing
125+
onQrScanned(qrData)
126+
}
127+
}
128+
)
129+
130+
// Scanning frame overlay
131+
Canvas(
132+
modifier = Modifier
133+
.size(300.dp)
134+
.align(Alignment.Center)
135+
) {
136+
drawRect(
137+
color = Color(0x99FFFFFF),
138+
topLeft = androidx.compose.ui.geometry.Offset(
139+
(size.width - 200.dp.toPx()) / 2,
140+
(size.height - 200.dp.toPx()) / 2
141+
),
142+
size = androidx.compose.ui.geometry.Size(200.dp.toPx(), 200.dp.toPx()),
143+
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f)
144+
)
145+
}
146+
147+
// Status messages
148+
Column(
149+
modifier = Modifier
150+
.align(Alignment.BottomCenter)
151+
.padding(16.dp)
152+
.fillMaxWidth(),
153+
horizontalAlignment = Alignment.CenterHorizontally,
154+
verticalArrangement = Arrangement.spacedBy(8.dp)
155+
) {
156+
if (scanMessage.isNotEmpty()) {
157+
Text(
158+
scanMessage,
159+
color = Color.Green,
160+
textAlign = TextAlign.Center,
161+
modifier = Modifier.padding(bottom = 8.dp),
162+
style = MaterialTheme.typography.titleMedium
163+
)
164+
}
165+
Text(
166+
"Position QR code in frame\nKeep phone steady",
167+
color = Color.White,
168+
textAlign = TextAlign.Center,
169+
style = MaterialTheme.typography.bodyMedium
170+
)
171+
}
172+
}
173+
}
174+
}
175+
176+
@SuppressLint("UnsafeOptInUsageError")
177+
@Composable
178+
fun QrCodeScannerView(
179+
modifier: Modifier = Modifier,
180+
onQrScanned: (String) -> Unit
181+
) {
182+
var lastScanned by remember { mutableStateOf("") }
183+
184+
AndroidView(
185+
modifier = modifier,
186+
factory = { ctx ->
187+
val previewView = PreviewView(ctx)
188+
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
189+
190+
cameraProviderFuture.addListener({
191+
try {
192+
val cameraProvider = cameraProviderFuture.get()
193+
Log.d("QrScanner", "Camera provider obtained")
194+
195+
@Suppress("DEPRECATION")
196+
val preview = androidx.camera.core.Preview.Builder()
197+
.setTargetResolution(android.util.Size(1280, 720))
198+
.build()
199+
preview.setSurfaceProvider(previewView.surfaceProvider)
200+
201+
// Configure barcode scanner options for QR codes only
202+
val options = BarcodeScannerOptions.Builder()
203+
.setBarcodeFormats(256) // Barcode.FORMAT_QR_CODE
204+
.build()
205+
206+
val scanner = BarcodeScanning.getClient(options)
207+
Log.d("QrScanner", "Barcode scanner initialized")
208+
209+
// Configure image analysis for continuous scanning
210+
@Suppress("DEPRECATION")
211+
val analysis = ImageAnalysis.Builder()
212+
.setTargetResolution(android.util.Size(1280, 720))
213+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
214+
.build()
215+
216+
val cameraExecutor = Executors.newSingleThreadExecutor()
217+
218+
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
219+
processQrImage(scanner, imageProxy) { result ->
220+
if (result != lastScanned && result.isNotEmpty()) {
221+
lastScanned = result
222+
Log.d("QrScanner", "QR scanned, calling callback with: $result")
223+
onQrScanned(result)
224+
}
225+
}
226+
}
227+
228+
try {
229+
cameraProvider.unbindAll()
230+
val cameraSelector = CameraSelector.Builder()
231+
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
232+
.build()
233+
234+
cameraProvider.bindToLifecycle(
235+
ctx as androidx.lifecycle.LifecycleOwner,
236+
cameraSelector,
237+
preview,
238+
analysis
239+
)
240+
Log.d("QrScanner", "Camera bound successfully")
241+
} catch (e: Exception) {
242+
Log.e("QrScanner", "Camera binding failed: ${e.message}", e)
243+
}
244+
} catch (e: Exception) {
245+
Log.e("QrScanner", "Failed to initialize camera: ${e.message}", e)
246+
}
247+
}, ContextCompat.getMainExecutor(ctx))
248+
249+
previewView
250+
}
251+
)
252+
}
253+
254+
@SuppressLint("UnsafeOptInUsageError")
255+
private fun processQrImage(
256+
scanner: com.google.mlkit.vision.barcode.BarcodeScanner,
257+
imageProxy: ImageProxy,
258+
onResult: (String) -> Unit
259+
) {
260+
try {
261+
val mediaImage = imageProxy.image
262+
if (mediaImage != null) {
263+
val image = InputImage.fromMediaImage(
264+
mediaImage,
265+
imageProxy.imageInfo.rotationDegrees
266+
)
267+
scanner.process(image)
268+
.addOnSuccessListener { barcodes ->
269+
try {
270+
for (barcode in barcodes) {
271+
val rawValue = barcode.rawValue
272+
if (rawValue != null && rawValue.isNotEmpty()) {
273+
Log.d("QrScanner", "QR Code detected: $rawValue")
274+
onResult(rawValue)
275+
return@addOnSuccessListener
276+
}
277+
}
278+
} catch (e: Exception) {
279+
Log.e("QrScanner", "Error processing barcode: ${e.message}", e)
280+
}
281+
}
282+
.addOnFailureListener { e ->
283+
Log.e("QrScanner", "Scanner failed: ${e.message}", e)
284+
}
285+
.addOnCompleteListener {
286+
try {
287+
imageProxy.close()
288+
} catch (e: Exception) {
289+
Log.e("QrScanner", "Error closing imageProxy: ${e.message}")
290+
}
291+
}
292+
} else {
293+
imageProxy.close()
294+
}
295+
} catch (e: Exception) {
296+
Log.e("QrScanner", "Exception in processQrImage: ${e.message}", e)
297+
try {
298+
imageProxy.close()
299+
} catch (ex: Exception) {
300+
Log.e("QrScanner", "Error closing imageProxy in exception handler: ${ex.message}")
301+
}
302+
}
303+
}
304+

app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ fun ManualConnectionCard(
2929
onPcNameChange: (String) -> Unit,
3030
onIsPlusChange: (Boolean) -> Unit,
3131
onSymmetricKeyChange: (String) -> Unit,
32-
onConnect: () -> Unit
32+
onConnect: () -> Unit,
33+
onQrScanClick: (() -> Unit)? = null
3334
) {
3435
val haptics = LocalHapticFeedback.current
3536
var expanded by remember { mutableStateOf(false) }
@@ -69,6 +70,26 @@ fun ManualConnectionCard(
6970

7071
AnimatedVisibility(visible = expanded) {
7172
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) {
73+
// QR Scanner button
74+
if (onQrScanClick != null) {
75+
Button(
76+
onClick = {
77+
HapticUtil.performClick(haptics)
78+
onQrScanClick()
79+
},
80+
modifier = Modifier.fillMaxWidth(),
81+
shape = RoundedCornerShape(minCornerRadius)
82+
) {
83+
Icon(
84+
painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24),
85+
contentDescription = "Scan QR Code",
86+
modifier = Modifier
87+
.size(20.dp)
88+
.padding(end = 8.dp)
89+
)
90+
Text("Scan QR Code")
91+
}
92+
}
7293
OutlinedTextField(
7394
value = uiState.ipAddress,
7495
onValueChange = onIpChange,

0 commit comments

Comments
 (0)