Aperture: Implement QR scanner
Change-Id: Ie6011ed5e5782c8244777f3026e8a6f308820113
diff --git a/app/build.gradle b/app/build.gradle
index 3b5842f..e2d0ea7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -55,4 +55,7 @@
implementation "androidx.camera:camera-mlkit-vision:${camerax_version}"
// If you want to additionally use the CameraX Extensions library
implementation "androidx.camera:camera-extensions:${camerax_version}"
+
+ // ZXing
+ implementation 'com.google.zxing:core:3.5.0'
}
\ No newline at end of file
diff --git a/app/src/main/java/org/lineageos/aperture/ImageProxyExt.kt b/app/src/main/java/org/lineageos/aperture/ImageProxyExt.kt
new file mode 100644
index 0000000..45accf7
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/ImageProxyExt.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2022 The LineageOS Project
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture
+
+import androidx.camera.core.ImageProxy
+import com.google.zxing.PlanarYUVLuminanceSource
+
+private fun rotateYUVLuminancePlane(data: ByteArray, width: Int, height: Int): ByteArray {
+ val yuv = ByteArray(width * height)
+ // Rotate the Y luma
+ var i = 0
+ for (x in 0 until width) {
+ for (y in height - 1 downTo 0) {
+ yuv[i] = data[y * width + x]
+ i++
+ }
+ }
+ return yuv
+}
+
+internal val ImageProxy.planarYUVLuminanceSource: PlanarYUVLuminanceSource
+ get() {
+ val plane = planes[0]
+ val buffer = plane.buffer
+ var bytes = ByteArray(buffer.remaining())
+ buffer.get(bytes)
+
+ var width = width
+ var height = height
+
+ if (imageInfo.rotationDegrees == 90 || imageInfo.rotationDegrees == 270) {
+ bytes = rotateYUVLuminancePlane(bytes, width, height)
+ width = height.also { height = width }
+ }
+
+ return PlanarYUVLuminanceSource(
+ bytes, width, height, 0, 0, width, height, true
+ )
+ }
diff --git a/app/src/main/java/org/lineageos/aperture/MainActivity.kt b/app/src/main/java/org/lineageos/aperture/MainActivity.kt
index 6c758ad..7f329dc 100644
--- a/app/src/main/java/org/lineageos/aperture/MainActivity.kt
+++ b/app/src/main/java/org/lineageos/aperture/MainActivity.kt
@@ -49,6 +49,7 @@
import androidx.core.view.WindowCompat.getInsetsController
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
@@ -78,6 +79,7 @@
private val gridView by lazy { findViewById<GridView>(R.id.gridView) }
private val mainLayout by lazy { findViewById<ConstraintLayout>(R.id.mainLayout) }
private val photoModeButton by lazy { findViewById<ImageButton>(R.id.photoModeButton) }
+ private val qrModeButton by lazy { findViewById<ImageButton>(R.id.qrModeButton) }
private val recordChip by lazy { findViewById<Chip>(R.id.recordChip) }
private val settingsButton by lazy { findViewById<ImageButton>(R.id.settingsButton) }
private val shutterButton by lazy { findViewById<ImageButton>(R.id.shutterButton) }
@@ -168,6 +170,7 @@
}
zoomLevel.setLabelFormatter { "%.1fx".format(it) }
+ qrModeButton.setOnClickListener { changeCameraMode(CameraMode.QR) }
photoModeButton.setOnClickListener { changeCameraMode(CameraMode.PHOTO) }
videoModeButton.setOnClickListener { changeCameraMode(CameraMode.VIDEO) }
@@ -178,6 +181,7 @@
when (cameraMode) {
CameraMode.PHOTO -> takePhoto()
CameraMode.VIDEO -> captureVideo()
+ else -> {}
}
}
}
@@ -379,6 +383,7 @@
// Initialize the use case we want
cameraMode = sharedPreferences.lastCameraMode
val cameraUseCases = when (cameraMode) {
+ CameraMode.QR -> CameraController.IMAGE_ANALYSIS
CameraMode.PHOTO -> CameraController.IMAGE_CAPTURE
CameraMode.VIDEO -> CameraController.VIDEO_CAPTURE
}
@@ -390,6 +395,16 @@
)
}
+ // Setup QR mode
+ if (cameraMode == CameraMode.QR) {
+ cameraController.setImageAnalysisAnalyzer(cameraExecutor, QrImageAnalyzer(this))
+ timerButton.isVisible = false
+ shutterButton.isInvisible = true
+ } else {
+ timerButton.isVisible = true
+ shutterButton.isInvisible = false
+ }
+
// Bind use cases to camera
cameraController.cameraSelector = cameraSelector
cameraController.setEnabledUseCases(cameraUseCases)
@@ -526,6 +541,7 @@
* Update the camera mode buttons reflecting the current mode
*/
private fun updateCameraModeButtons() {
+ qrModeButton.isEnabled = cameraMode != CameraMode.QR
photoModeButton.isEnabled = cameraMode != CameraMode.PHOTO
videoModeButton.isEnabled = cameraMode != CameraMode.VIDEO
}
diff --git a/app/src/main/java/org/lineageos/aperture/QrImageAnalyzer.kt b/app/src/main/java/org/lineageos/aperture/QrImageAnalyzer.kt
new file mode 100644
index 0000000..354261a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/aperture/QrImageAnalyzer.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2022 The LineageOS Project
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.aperture
+
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.DialogInterface
+import android.text.SpannableString
+import android.text.method.LinkMovementMethod
+import android.text.util.Linkify
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.MultiFormatReader
+import com.google.zxing.common.HybridBinarizer
+
+class QrImageAnalyzer(private val activity: Activity) : ImageAnalysis.Analyzer {
+ private val dialog by lazy {
+ AlertDialog.Builder(activity)
+ .setTitle(R.string.qr_title)
+ .create()
+ }
+ private val reader by lazy { MultiFormatReader() }
+
+ private val clipboardManager by lazy { activity.getSystemService(ClipboardManager::class.java) }
+ private val keyguardManager by lazy { activity.getSystemService(KeyguardManager::class.java) }
+
+ override fun analyze(image: ImageProxy) {
+ val binaryBitmap = BinaryBitmap(HybridBinarizer(image.planarYUVLuminanceSource))
+
+ runCatching {
+ showQrDialog(reader.decodeWithState(binaryBitmap).text)
+ }
+
+ image.close()
+ }
+
+ private fun showQrDialog(message: String) {
+ activity.runOnUiThread {
+ if (dialog.isShowing) {
+ return@runOnUiThread
+ }
+
+ // Linkify message
+ SpannableString(message).let {
+ Linkify.addLinks(it, Linkify.ALL)
+ dialog.setMessage(it)
+ }
+
+ // Set buttons
+ dialog.setButton(
+ DialogInterface.BUTTON_NEUTRAL,
+ activity.getString(R.string.qr_copy)
+ ) { _, _ ->
+ clipboardManager.setPrimaryClip(ClipData.newPlainText("", message))
+ }
+
+ dialog.setButton(
+ DialogInterface.BUTTON_POSITIVE,
+ activity.getString(android.R.string.ok),
+ ) { _, _ ->
+ // Do nothing.
+ }
+
+ // Show dialog
+ dialog.show()
+
+ // Make links clickable if keyguard is unlocked
+ dialog.findViewById<TextView?>(android.R.id.message)?.movementMethod =
+ if (!keyguardManager.isKeyguardLocked) LinkMovementMethod.getInstance()
+ else null
+ }
+ }
+}
diff --git a/app/src/main/java/org/lineageos/aperture/SharedPreferencesExt.kt b/app/src/main/java/org/lineageos/aperture/SharedPreferencesExt.kt
index d2ca50c..4bed2e3 100644
--- a/app/src/main/java/org/lineageos/aperture/SharedPreferencesExt.kt
+++ b/app/src/main/java/org/lineageos/aperture/SharedPreferencesExt.kt
@@ -64,6 +64,7 @@
internal var SharedPreferences.lastCameraMode: CameraMode
get() {
return when (getString(LAST_CAMERA_MODE_KEY, LAST_CAMERA_MODE_DEFAULT)) {
+ "qr" -> CameraMode.QR
"photo" -> CameraMode.PHOTO
"video" -> CameraMode.VIDEO
// Default to photo
@@ -74,6 +75,7 @@
edit {
putString(
LAST_CAMERA_MODE_KEY, when (value) {
+ CameraMode.QR -> "qr"
CameraMode.PHOTO -> "photo"
CameraMode.VIDEO -> "video"
}
diff --git a/app/src/main/java/org/lineageos/aperture/utils/CameraMode.kt b/app/src/main/java/org/lineageos/aperture/utils/CameraMode.kt
index 4cf083b..8d32c31 100644
--- a/app/src/main/java/org/lineageos/aperture/utils/CameraMode.kt
+++ b/app/src/main/java/org/lineageos/aperture/utils/CameraMode.kt
@@ -7,6 +7,7 @@
package org.lineageos.aperture.utils
enum class CameraMode {
+ QR,
PHOTO,
VIDEO,
}
diff --git a/app/src/main/res/drawable/ic_qr.xml b/app/src/main/res/drawable/ic_qr.xml
new file mode 100644
index 0000000..af1f0bd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qr.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M13,21V19H15V21ZM11,19V14H13V19ZM19,16V12H21V16ZM17,12V10H19V12ZM5,14V12H7V14ZM3,12V10H5V12ZM12,5V3H14V5ZM4.5,7.5H7.5V4.5H4.5ZM3,9V3H9V9ZM4.5,19.5H7.5V16.5H4.5ZM3,21V15H9V21ZM16.5,7.5H19.5V4.5H16.5ZM15,9V3H21V9ZM17,21V18H15V16H19V19H21V21ZM13,14V12H17V14ZM9,14V12H7V10H13V12H11V14ZM10,9V5H12V7H14V9ZM5.25,6.75V5.25H6.75V6.75ZM5.25,18.75V17.25H6.75V18.75ZM17.25,6.75V5.25H18.75V6.75Z" />
+</vector>
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 11dbc7b..49d6aba 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -247,7 +247,7 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
- android:layout_width="100dp"
+ android:layout_width="135dp"
android:layout_height="50dp"
android:background="@drawable/layout_camera_mode"
app:layout_constraintBottom_toTopOf="@+id/flipCameraButton"
@@ -257,6 +257,19 @@
app:layout_constraintTop_toTopOf="parent">
<ImageButton
+ android:id="@+id/qrModeButton"
+ style="@style/ApertureCameraModeButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="@string/qr_mode_button_description"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toStartOf="@+id/photoModeButton"
+ app:layout_constraintHorizontal_bias="0.5"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:srcCompat="@drawable/ic_qr" />
+
+ <ImageButton
android:id="@+id/photoModeButton"
style="@style/ApertureCameraModeButton"
android:layout_width="wrap_content"
@@ -265,7 +278,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/videoModeButton"
app:layout_constraintHorizontal_bias="0.5"
- app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/qrModeButton"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_photo" />
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d225627..4c610f8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,6 +8,7 @@
<string name="torch_button_description">Torch</string>
<string name="flash_button_description">Flash</string>
<string name="settings_button_description">Settings</string>
+ <string name="qr_mode_button_description">Switch to QR scanner mode</string>
<string name="photo_mode_button_description">Switch to photo mode</string>
<string name="video_mode_button_description">Switch to video mode</string>
<string name="flip_camera_button_description">Flip camera</string>
@@ -17,6 +18,10 @@
<!-- Record chip -->
<string translatable="false" name="record_chip_default_text">00:00:00</string>
+ <!-- QR dialog -->
+ <string name="qr_copy">Copy to clipboard</string>
+ <string name="qr_title">Scan result</string>
+
<!-- Settings title -->
<string name="title_activity_settings">Settings</string>