Skip to content

Commit d0b8f6d

Browse files
authored
feat(android): implement QR code scanner (#1124)
1 parent c4f425d commit d0b8f6d

File tree

15 files changed

+283
-8
lines changed

15 files changed

+283
-8
lines changed

android/app/build.gradle

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ project.ext.react = [
6666
appName : getAppName(),
6767
applicationId : getApplicationId(),
6868
architectures : getArchitectures(),
69+
enableCamera : !getSingleAppMode(),
6970
enableFabric : isFabricEnabled(project),
7071
enableFlipper : getFlipperVersion(rootDir),
7172
enableHermes : true,
@@ -258,6 +259,12 @@ android {
258259
}
259260

260261
sourceSets {
262+
if (project.ext.react.enableCamera) {
263+
main.java.srcDirs += "src/camera/java"
264+
} else {
265+
main.java.srcDirs += "src/no-camera/java"
266+
}
267+
261268
if (project.ext.react.enableFlipper) {
262269
debug.java.srcDirs += "src/flipper/java"
263270
}
@@ -350,6 +357,12 @@ dependencies {
350357
androidTestImplementation libraries.androidJUnit
351358
androidTestImplementation libraries.androidEspressoCore
352359

360+
if (project.ext.react.enableCamera) {
361+
implementation libraries.androidCamera
362+
implementation libraries.androidCameraMlKitVision
363+
implementation libraries.mlKitBarcodeScanning
364+
}
365+
353366
if (project.ext.react.enableFlipper) {
354367
def flipperVersion = project.ext.react.enableFlipper
355368
debugImplementation("com.facebook.flipper:flipper:${flipperVersion}") {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.microsoft.reacttestapp.camera
2+
3+
import android.Manifest
4+
import android.content.DialogInterface
5+
import android.content.pm.PackageManager
6+
import androidx.appcompat.app.AlertDialog
7+
import androidx.core.app.ActivityCompat
8+
import androidx.core.content.ContextCompat
9+
import com.google.android.material.snackbar.Snackbar
10+
import com.microsoft.reacttestapp.MainActivity
11+
import com.microsoft.reacttestapp.R
12+
import com.microsoft.reacttestapp.testApp
13+
14+
fun MainActivity.canUseCamera(): Boolean {
15+
return ContextCompat.checkSelfPermission(
16+
this,
17+
Manifest.permission.CAMERA
18+
) == PackageManager.PERMISSION_GRANTED
19+
}
20+
21+
fun MainActivity.scanForQrCode() {
22+
when {
23+
canUseCamera() -> {
24+
val fragment = QRCodeScannerFragment(mainThreadHandler) {
25+
AlertDialog
26+
.Builder(this)
27+
.setTitle(R.string.is_this_the_right_url)
28+
.setMessage(it)
29+
.setPositiveButton(R.string.yes) { _: DialogInterface, _: Int ->
30+
testApp.reloadJSFromServer(this, it)
31+
}
32+
.setNegativeButton(R.string.no) { _: DialogInterface, _: Int ->
33+
// Nothing to do
34+
}
35+
.show()
36+
}
37+
fragment.show(supportFragmentManager, QRCodeScannerFragment.TAG)
38+
}
39+
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
40+
Snackbar
41+
.make(
42+
findViewById(R.id.recyclerview),
43+
R.string.camera_usage_description,
44+
Snackbar.LENGTH_LONG
45+
)
46+
.setAction(android.R.string.ok) {
47+
ActivityCompat.requestPermissions(
48+
this,
49+
arrayOf(Manifest.permission.CAMERA),
50+
MainActivity.REQUEST_CODE_PERMISSIONS
51+
)
52+
}
53+
.show()
54+
}
55+
else -> {
56+
ActivityCompat.requestPermissions(
57+
this,
58+
arrayOf(Manifest.permission.CAMERA),
59+
MainActivity.REQUEST_CODE_PERMISSIONS
60+
)
61+
}
62+
}
63+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.microsoft.reacttestapp.camera
2+
3+
import android.os.Bundle
4+
import android.os.Handler
5+
import android.view.LayoutInflater
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import androidx.camera.core.ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
9+
import androidx.camera.mlkit.vision.MlKitAnalyzer
10+
import androidx.camera.view.CameraController.COORDINATE_SYSTEM_VIEW_REFERENCED
11+
import androidx.camera.view.LifecycleCameraController
12+
import androidx.camera.view.PreviewView
13+
import androidx.core.util.Consumer
14+
import androidx.fragment.app.DialogFragment
15+
import com.google.mlkit.vision.barcode.BarcodeScanner
16+
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
17+
import com.google.mlkit.vision.barcode.BarcodeScanning
18+
import com.google.mlkit.vision.barcode.common.Barcode
19+
import com.microsoft.reacttestapp.R
20+
import java.util.concurrent.Executors
21+
22+
class QRCodeScannerFragment(
23+
private val mainThreadHandler: Handler,
24+
private val consumer: Consumer<String>
25+
) : DialogFragment() {
26+
27+
companion object {
28+
const val TAG = "QRCodeScannerFragment"
29+
}
30+
31+
private var barcodeScanner: BarcodeScanner? = null
32+
33+
private val cameraController by lazy {
34+
LifecycleCameraController(requireContext())
35+
}
36+
37+
private val cameraExecutor by lazy {
38+
Executors.newSingleThreadExecutor()
39+
}
40+
41+
override fun onCreate(savedInstanceState: Bundle?) {
42+
super.onCreate(savedInstanceState)
43+
44+
val scanner = BarcodeScanning.getClient(
45+
BarcodeScannerOptions.Builder()
46+
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
47+
.build()
48+
)
49+
50+
cameraController.imageCaptureMode = CAPTURE_MODE_MINIMIZE_LATENCY
51+
cameraController.setImageAnalysisAnalyzer(
52+
cameraExecutor,
53+
MlKitAnalyzer(
54+
listOf(scanner),
55+
COORDINATE_SYSTEM_VIEW_REFERENCED,
56+
cameraExecutor
57+
) {
58+
it.getValue(scanner)?.let { barcodes ->
59+
for (barcode in barcodes) {
60+
when (barcode.valueType) {
61+
Barcode.TYPE_URL -> {
62+
// Close the scanner otherwise it will keep posting results
63+
scanner.close()
64+
mainThreadHandler.post {
65+
dismiss()
66+
consumer.accept(barcode.url?.url)
67+
}
68+
}
69+
}
70+
}
71+
}
72+
}
73+
)
74+
75+
barcodeScanner = scanner
76+
}
77+
78+
override fun onCreateView(
79+
inflater: LayoutInflater,
80+
container: ViewGroup?,
81+
savedInstanceState: Bundle?
82+
): View {
83+
val view = inflater.inflate(R.layout.camera_view, container, false)
84+
cameraController.bindToLifecycle(viewLifecycleOwner)
85+
86+
val previewView = view.findViewById<PreviewView>(R.id.preview_view)
87+
previewView.controller = cameraController
88+
89+
return view
90+
}
91+
92+
override fun onStart() {
93+
super.onStart()
94+
dialog?.window?.setLayout(
95+
ViewGroup.LayoutParams.MATCH_PARENT,
96+
ViewGroup.LayoutParams.MATCH_PARENT
97+
)
98+
}
99+
100+
override fun onDestroyView() {
101+
super.onDestroyView()
102+
barcodeScanner?.close()
103+
}
104+
}

android/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
xmlns:tools="http://schemas.android.com/tools"
44
package="com.microsoft.reacttestapp">
55

6+
<uses-feature android:name="android.hardware.camera.any" />
7+
68
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
9+
<uses-permission android:name="android.permission.CAMERA" />
710
<uses-permission android:name="android.permission.INTERNET" />
811

912
<application

android/app/src/main/java/com/microsoft/reacttestapp/MainActivity.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@ package com.microsoft.reacttestapp
33
import android.app.Activity
44
import android.content.Intent
55
import android.os.Bundle
6+
import android.os.Looper
67
import android.view.LayoutInflater
78
import android.widget.TextView
9+
import androidx.core.os.HandlerCompat
810
import androidx.recyclerview.widget.DividerItemDecoration
911
import androidx.recyclerview.widget.LinearLayoutManager
1012
import androidx.recyclerview.widget.RecyclerView
1113
import com.facebook.react.ReactActivity
1214
import com.facebook.react.bridge.ReactContext
1315
import com.facebook.react.modules.systeminfo.ReactNativeVersion
16+
import com.facebook.react.packagerconnection.PackagerConnectionSettings
1417
import com.google.android.material.appbar.MaterialToolbar
18+
import com.microsoft.reacttestapp.camera.canUseCamera
19+
import com.microsoft.reacttestapp.camera.scanForQrCode
1520
import com.microsoft.reacttestapp.component.ComponentActivity
1621
import com.microsoft.reacttestapp.component.ComponentBottomSheetDialogFragment
1722
import com.microsoft.reacttestapp.component.ComponentListAdapter
@@ -20,6 +25,15 @@ import com.microsoft.reacttestapp.manifest.Component
2025
import com.microsoft.reacttestapp.react.BundleSource
2126

2227
class MainActivity : ReactActivity() {
28+
29+
companion object {
30+
const val REQUEST_CODE_PERMISSIONS = 42
31+
}
32+
33+
val mainThreadHandler by lazy {
34+
HandlerCompat.createAsync(Looper.getMainLooper())
35+
}
36+
2337
private var didInitialNavigation = false
2438

2539
private val newComponentViewModel = { component: Component ->
@@ -76,7 +90,7 @@ class MainActivity : ReactActivity() {
7690
didInitialNavigation =
7791
savedInstanceState?.getBoolean("didInitialNavigation", false) == true
7892

79-
if (components.count() > 0) {
93+
if (components.isNotEmpty()) {
8094
val index =
8195
if (components.count() == 1) 0 else session.lastOpenedComponent(checksum)
8296
index?.let {
@@ -97,7 +111,7 @@ class MainActivity : ReactActivity() {
97111
setupRecyclerView(components, checksum)
98112
}
99113

100-
components.count() > 0 -> {
114+
components.isNotEmpty() -> {
101115
val slug = BuildConfig.ReactTestApp_singleApp
102116
val component = components.find { it.slug == slug }
103117
?: throw IllegalArgumentException("No component with slug: $slug")
@@ -111,6 +125,20 @@ class MainActivity : ReactActivity() {
111125
}
112126
}
113127

128+
override fun onRequestPermissionsResult(
129+
requestCode: Int,
130+
permissions: Array<out String>,
131+
grantResults: IntArray
132+
) {
133+
if (requestCode == REQUEST_CODE_PERMISSIONS) {
134+
if (canUseCamera()) {
135+
scanForQrCode()
136+
}
137+
} else {
138+
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
139+
}
140+
}
141+
114142
override fun onSaveInstanceState(outState: Bundle) {
115143
outState.putBoolean("didInitialNavigation", didInitialNavigation)
116144
super.onSaveInstanceState(outState)
@@ -158,6 +186,7 @@ class MainActivity : ReactActivity() {
158186
true
159187
}
160188
R.id.load_from_dev_server -> {
189+
PackagerConnectionSettings(this).debugServerHost = ""
161190
reload(BundleSource.Server)
162191
true
163192
}
@@ -167,6 +196,10 @@ class MainActivity : ReactActivity() {
167196
session.shouldRememberLastComponent = enable
168197
true
169198
}
199+
R.id.scan_qr_code -> {
200+
scanForQrCode()
201+
true
202+
}
170203
R.id.show_dev_options -> {
171204
reactInstanceManager.devSupportManager.showDevOptionsDialog()
172205
true

android/app/src/main/java/com/microsoft/reacttestapp/TestApp.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.microsoft.reacttestapp
22

3+
import android.app.Activity
34
import android.app.Application
45
import android.content.Context
56
import com.facebook.react.PackageList
@@ -17,6 +18,10 @@ class TestApp : Application(), ReactApplication {
1718
private lateinit var reactNativeBundleNameProvider: ReactBundleNameProvider
1819
private lateinit var reactNativeHostInternal: TestAppReactNativeHost
1920

21+
fun reloadJSFromServer(activity: Activity?, bundleURL: String) {
22+
reactNativeHostInternal.reloadJSFromServer(activity, bundleURL)
23+
}
24+
2025
override fun onCreate() {
2126
super.onCreate()
2227

android/app/src/main/java/com/microsoft/reacttestapp/component/ComponentActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ class ComponentActivity : ReactActivity() {
9292

9393
override fun onOptionsItemSelected(item: MenuItem): Boolean {
9494
if (android.R.id.home == item.itemId) {
95+
@Suppress("DEPRECATION")
9596
onBackPressed()
9697
return true
9798
}

android/app/src/main/java/com/microsoft/reacttestapp/react/TestAppReactNativeHost.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.microsoft.reacttestapp.react
33
import android.app.Activity
44
import android.app.Application
55
import android.content.Context
6+
import android.net.Uri
67
import android.util.Log
78
import com.facebook.hermes.reactexecutor.HermesExecutorFactory
89
import com.facebook.react.PackageList
@@ -19,6 +20,7 @@ import com.facebook.react.devsupport.DevServerHelper
1920
import com.facebook.react.devsupport.InspectorPackagerConnection.BundleStatus
2021
import com.facebook.react.devsupport.interfaces.DevSupportManager
2122
import com.facebook.react.modules.systeminfo.ReactNativeVersion
23+
import com.facebook.react.packagerconnection.PackagerConnectionSettings
2224
import com.facebook.soloader.SoLoader
2325
import com.microsoft.reacttestapp.BuildConfig
2426
import com.microsoft.reacttestapp.R
@@ -134,6 +136,17 @@ class TestAppReactNativeHost(
134136
onBundleSourceChanged?.invoke(newSource)
135137
}
136138

139+
fun reloadJSFromServer(activity: Activity?, bundleURL: String) {
140+
val uri = Uri.parse(bundleURL)
141+
PackagerConnectionSettings(activity).debugServerHost =
142+
if (uri.port > 0) {
143+
"${uri.host}:${uri.port}"
144+
} else {
145+
uri.host
146+
}
147+
reload(activity, BundleSource.Server)
148+
}
149+
137150
override fun createReactInstanceManager(): ReactInstanceManager {
138151
ReactMarker.logMarker(ReactMarkerConstants.BUILD_REACT_INSTANCE_MANAGER_START)
139152
val reactInstanceManager = ReactInstanceManager.builder()
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:layout_width="match_parent"
4+
android:layout_height="match_parent">
5+
6+
<androidx.camera.view.PreviewView
7+
android:id="@+id/preview_view"
8+
android:layout_width="match_parent"
9+
android:layout_height="match_parent" />
10+
11+
</FrameLayout>

android/app/src/main/res/menu/top_app_bar.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
android:contentDescription="@string/content_description_remember_last_component"
1818
android:title="@string/remember_last_component"
1919
app:showAsAction="never" />
20+
<item
21+
android:id="@+id/scan_qr_code"
22+
android:contentDescription="@string/content_description_scan_qr_code"
23+
android:title="@string/scan_qr_code"
24+
app:showAsAction="never" />
2025
<item
2126
android:id="@+id/show_dev_options"
2227
android:contentDescription="@string/content_description_show_dev_options"

0 commit comments

Comments
 (0)