diff --git a/app/build.gradle b/app/build.gradle index 90dc1edb1..5af5224c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -128,4 +128,10 @@ dependencies { // Image viewer implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1' + + // QR Scanning + implementation 'com.google.mlkit:barcode-scanning:17.3.0' + implementation "androidx.camera:camera-camera2:1.2.3" + implementation "androidx.camera:camera-lifecycle:1.2.3" + implementation "androidx.camera:camera-view:1.2.3" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0950b404e..00fc3e844 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ - + + @@ -11,7 +12,12 @@ + + + + + diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 8e56bab9a..3167381a2 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -4,11 +4,14 @@ import android.app.Activity import android.app.AlertDialog import android.app.Dialog import android.content.Context +import android.content.Intent import android.os.Bundle import android.view.View import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.* +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputEditText @@ -34,6 +37,7 @@ class AddFragment : DialogFragment() { private lateinit var loginView: View private lateinit var positiveButton: Button private lateinit var negativeButton: Button + private lateinit var qrButton: Button // Subscribe page private lateinit var subscribeTopicText: TextInputEditText @@ -56,6 +60,26 @@ class AddFragment : DialogFragment() { private lateinit var loginErrorText: TextView private lateinit var loginErrorTextImage: View + // QR Codes + private var qrActivityResultLauncher: ActivityResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + //receive data from firstActivity + val topic = data?.getStringExtra(QrScannerActivity.QR_CODE_DATA_TOPIC) + val url = data?.getStringExtra(QrScannerActivity.QR_CODE_DATA_SERVER_URL) + subscribeTopicText.setText(topic) + if (url != getDefaultBaseUrl()) { + subscribeBaseUrlText.setText(url) + subscribeUseAnotherServerCheckbox.isChecked = true + } else { + subscribeUseAnotherServerCheckbox.isChecked = false + } + } else if (result.resultCode == Activity.RESULT_CANCELED) { + Toast.makeText(requireContext(),"Unable to scan QR Code",Toast.LENGTH_SHORT).show() + } + } + interface SubscribeListener { fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) } @@ -152,6 +176,9 @@ class AddFragment : DialogFragment() { .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ -> // This will be overridden below } + .setNeutralButton("Scan QR") {_, _ -> + // This will be overridden below + } .create() // Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785) @@ -168,6 +195,11 @@ class AddFragment : DialogFragment() { negativeButton.setOnClickListener { negativeButtonClick() } + + qrButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + qrButton.setOnClickListener { + qrButtonClick() + } val subscribeTextWatcher = AfterChangedTextWatcher { validateInputSubscribeView() } @@ -288,6 +320,12 @@ class AddFragment : DialogFragment() { } } + private fun qrButtonClick() { + Log.d(TAG, "Entering QR scanner mode") + val intent = Intent(this.context, QrScannerActivity::class.java) + qrActivityResultLauncher.launch(intent) + } + private fun validateInputSubscribeView() { if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play @@ -366,10 +404,15 @@ class AddFragment : DialogFragment() { return if (subscribeUseAnotherServerCheckbox.isChecked) { subscribeBaseUrlText.text.toString() } else { - return defaultBaseUrl ?: appBaseUrl + return getDefaultBaseUrl() } } + private fun getDefaultBaseUrl(): String { + return defaultBaseUrl ?: appBaseUrl + } + + private fun showSubscribeView() { resetSubscribeView() positiveButton.text = getString(R.string.add_dialog_button_subscribe) diff --git a/app/src/main/java/io/heckel/ntfy/ui/QrScannerActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/QrScannerActivity.kt new file mode 100644 index 000000000..a11775bb6 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/QrScannerActivity.kt @@ -0,0 +1,172 @@ +package io.heckel.ntfy.ui + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import io.heckel.ntfy.R +import io.heckel.ntfy.util.Log +import io.heckel.ntfy.util.QrCodeAnalyzer +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +class QrScannerActivity : AppCompatActivity(R.layout.activity_qr_scanner) { + private lateinit var cameraExecutor: ExecutorService + private final val ntfyQrDataPrefix = "ntfy://" + private final val ntfyQrDataTopicSeperator = ";" + + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + cameraExecutor = Executors.newSingleThreadExecutor() + + checkCameraPermission() + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + } + + /** + * 1. This function is responsible to request the required CAMERA permission + */ + private fun checkCameraPermission() { + try { + val requiredPermissions = arrayOf(Manifest.permission.CAMERA) + ActivityCompat.requestPermissions(this, requiredPermissions, 0) + } catch (e: IllegalArgumentException) { + checkIfCameraPermissionIsGranted() + } + } + + /** + * 2. This function will check if the CAMERA permission has been granted. + * If so, it will call the function responsible to initialize the camera preview. + * Otherwise, it will raise an alert. + */ + private fun checkIfCameraPermissionIsGranted() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + // Permission granted: start the preview + startCamera() + } else { + // Permission denied + AlertDialog.Builder(this) + .setTitle("Permission required") + .setMessage("This application needs to access the camera to process barcodes") + .setPositiveButton("Ok") { _, _ -> + // Keep asking for permission until granted + checkCameraPermission() + } + .setCancelable(false) + .create() + .apply { + setCanceledOnTouchOutside(false) + show() + } + } + } + + /** + * 3. This function is executed once the user has granted or denied the missing permission + */ + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + checkIfCameraPermissionIsGranted() + } + + /** + * This function is responsible for the setup of the camera preview and the image analyzer. + */ + private fun startCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + // Preview + val preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(this.findViewById(R.id.preview_view).surfaceProvider) + } + + // Image analyzer + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(cameraExecutor, QrCodeAnalyzer { data -> + Log.d(TAG, "Found URL with value $data") + val intent = Intent() + try { + val (qrUrl, qrTopic) = parseQrCodeData(data) + intent.putExtra(QR_CODE_DATA_SERVER_URL, qrUrl) + intent.putExtra(QR_CODE_DATA_TOPIC, qrTopic) + setResult(Activity.RESULT_OK, intent) + } catch (e: Exception) { + Log.e(TAG, "QR reading failed with message ${e.message.toString()}") + setResult(Activity.RESULT_CANCELED, intent) + } + finish() + + }) + } + + // Select back camera as a default + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + // Unbind use cases before rebinding + cameraProvider.unbindAll() + + // Bind use cases to camera + cameraProvider.bindToLifecycle( + this, cameraSelector, preview, imageAnalyzer + ) + + } catch (exc: Exception) { + exc.printStackTrace() + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun parseQrCodeData(data: String?): Pair { + if (data == null || !data.startsWith(ntfyQrDataPrefix)) { + throw Exception("Bad QR code data format") + } + + val startIndex = ntfyQrDataPrefix.length + val seperatorIndex = data.indexOf(ntfyQrDataTopicSeperator) + + if (seperatorIndex == -1) { + throw Exception("Bad QR code data format") + } + + val url = data.substring(startIndex, seperatorIndex) + val topicName = data.substring(seperatorIndex + 1) + + return Pair(url, topicName) + } + + companion object { + const val TAG = "NtfyQrScannerActivity" + const val QR_CODE_DATA_SERVER_URL = "qrServerUrls" + const val QR_CODE_DATA_TOPIC = "qrTopic" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/util/QrCodeAnalyzer.kt b/app/src/main/java/io/heckel/ntfy/util/QrCodeAnalyzer.kt new file mode 100644 index 000000000..d5c6e6cac --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/util/QrCodeAnalyzer.kt @@ -0,0 +1,43 @@ +package io.heckel.ntfy.util + +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +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 + +class QrCodeAnalyzer(private val onBarcodeScanned: (String?) -> Unit): ImageAnalysis.Analyzer { + + @OptIn(ExperimentalGetImage::class) override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image + + if (mediaImage != null) { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val scanner = BarcodeScanning.getClient(options); + + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + Log.d("QR Analyzer", "${barcode.valueType}") + val valueType = barcode.valueType + when (valueType) { + Barcode.TYPE_TEXT -> { + onBarcodeScanned(barcode.rawValue) + break + } + } + } + } + .addOnFailureListener {} + .addOnCompleteListener { + imageProxy.close() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_qr_scanner.xml b/app/src/main/res/layout/activity_qr_scanner.xml new file mode 100644 index 000000000..bb6b81a83 --- /dev/null +++ b/app/src/main/res/layout/activity_qr_scanner.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file