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>