diff --git a/app/build.gradle b/app/build.gradle
index 90dc1ed..d7d7fb9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -128,4 +128,11 @@ dependencies {
// Image viewer
implementation 'com.github.stfalcon-studio:StfalconImageViewer:v1.0.1'
+
+ // QR Scanner
+ implementation 'com.google.mlkit:barcode-scanning:17.0.0'
+ implementation "androidx.camera:camera-camera2:1.0.1"
+ implementation "androidx.camera:camera-lifecycle:1.0.1"
+ implementation "androidx.camera:camera-view:1.0.0-alpha28"
+
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0950b40..5971b37 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,11 @@
+
+
+
+
+
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 8e56bab..c94e4a4 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
@@ -1,16 +1,34 @@
package io.heckel.ntfy.ui
+import android.Manifest
+import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.app.Dialog
import android.content.Context
+import android.content.DialogInterface
+import android.content.pm.PackageManager
import android.os.Bundle
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.util.SparseArray
+import android.view.SurfaceHolder
+import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.*
+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 androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.BuildConfig
@@ -21,6 +39,11 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import java.io.IOException
+import java.net.URL
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
class AddFragment : DialogFragment() {
private val api = ApiService()
@@ -30,6 +53,9 @@ class AddFragment : DialogFragment() {
private lateinit var appBaseUrl: String
private var defaultBaseUrl: String? = null
+ private lateinit var cameraExecutor: ExecutorService
+ private lateinit var subscribeCameraPreview: PreviewView
+
private lateinit var subscribeView: View
private lateinit var loginView: View
private lateinit var positiveButton: Button
@@ -48,6 +74,7 @@ class AddFragment : DialogFragment() {
private lateinit var subscribeProgress: ProgressBar
private lateinit var subscribeErrorText: TextView
private lateinit var subscribeErrorTextImage: View
+ private lateinit var subscribeCameraPreviewPermissionText: TextView
// Login page
private lateinit var loginUsernameText: TextInputEditText
@@ -103,6 +130,8 @@ class AddFragment : DialogFragment() {
subscribeErrorText.visibility = View.GONE
subscribeErrorTextImage = view.findViewById(R.id.add_dialog_subscribe_error_text_image)
subscribeErrorTextImage.visibility = View.GONE
+ subscribeCameraPreview = view.findViewById(R.id.add_dialog_subscribe_camera_preview)
+ subscribeCameraPreviewPermissionText = view.findViewById(R.id.add_dialog_subscribe_camera_preview_denied_text)
// Fields for "login page"
loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
@@ -183,11 +212,109 @@ class AddFragment : DialogFragment() {
// Focus topic text (keyboard is shown too, see above)
subscribeTopicText.requestFocus()
+
+ cameraExecutor = Executors.newSingleThreadExecutor()
+ checkIfCameraPermissionIsGranted()
}
return dialog
}
+ override fun onDestroy() {
+ super.onDestroy()
+ cameraExecutor.shutdown()
+ }
+
+ private fun checkCameraPermissionsRaw(): Boolean {
+ return ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun checkIfCameraPermissionIsGranted() {
+ if (checkCameraPermissionsRaw()) {
+ startCamera()
+ } else {
+ subscribeCameraPreviewPermissionText.visibility = View.VISIBLE
+ val requiredPermissions = arrayOf(Manifest.permission.CAMERA)
+ requestPermissions(requiredPermissions, 0)
+ }
+ }
+
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+
+ if (checkCameraPermissionsRaw()) {
+ startCamera()
+ }
+ }
+
+ private fun vibratePhone(context: Context) {
+ // This is deprecated, but we aren't using a high enough version for the new API
+ val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ if (vibrator.hasVibrator()) {
+ // Vibrate for 500 milliseconds
+ vibrator.vibrate(200)
+ }
+ }
+
+ private fun startCamera() {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
+
+ subscribeCameraPreviewPermissionText.visibility = View.GONE
+
+ cameraProviderFuture.addListener({
+ val cameraProvider = cameraProviderFuture.get()
+
+ val preview = Preview.Builder()
+ .build()
+ .also {
+ it.setSurfaceProvider(subscribeCameraPreview.surfaceProvider)
+ }
+
+ val imageAnalyzer = ImageAnalysis.Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ .also {
+ it.setAnalyzer(cameraExecutor, QrCodeAnalyzer { urlString ->
+ var url = URL(urlString)
+ var baseUrl = "${url.protocol}://${url.host}"
+ var route = url.path.replaceFirst("/", "")
+
+ subscribeUseAnotherServerCheckbox.isChecked = true
+ subscribeBaseUrlText.setText(baseUrl)
+ subscribeTopicText.setText(route)
+ validateInputSubscribeView {
+ if (positiveButton.isEnabled) {
+ positiveButtonClick()
+ vibratePhone(requireContext())
+ }
+ }
+
+ })
+ }
+
+ // 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 (e: Exception) {
+ e.printStackTrace()
+ }
+ }, ContextCompat.getMainExecutor(requireContext()))
+ }
+
private fun positiveButtonClick() {
val topic = subscribeTopicText.text.toString()
val baseUrl = getBaseUrl()
@@ -288,7 +415,7 @@ class AddFragment : DialogFragment() {
}
}
- private fun validateInputSubscribeView() {
+ private fun validateInputSubscribeView(onCompletion: () -> Unit = {}) {
if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play
// Show/hide things: This logic is intentionally kept simple. Do not simplify "just because it's pretty".
@@ -333,6 +460,8 @@ class AddFragment : DialogFragment() {
} else {
positiveButton.isEnabled = validTopic(topic)
}
+
+ onCompletion()
}
}
}
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 0000000..07d30c2
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/util/QrCodeAnalyzer.kt
@@ -0,0 +1,58 @@
+package io.heckel.ntfy.util
+
+import android.annotation.SuppressLint
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import com.google.mlkit.vision.barcode.Barcode
+import com.google.mlkit.vision.barcode.BarcodeScannerOptions
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.common.InputImage
+
+typealias BarcodeScannedCallback = (barcode: String) -> Unit
+
+class QrCodeAnalyzer(private val onBarcodeScanned: BarcodeScannedCallback) : ImageAnalysis.Analyzer {
+
+ private var lastSuccessfulScanTimestamp: Long = 0
+ private var lastProcessedTimestamp: Long = 0
+
+ @SuppressLint("UnsafeOptInUsageError")
+ override fun analyze(image: ImageProxy) {
+ if (System.currentTimeMillis() - lastSuccessfulScanTimestamp < 3000) {
+ image.close()
+ return
+ } else if (System.currentTimeMillis() - lastProcessedTimestamp < 100) {
+ image.close()
+ return
+ }
+
+ val img = image.image
+ if (img != null) {
+ val inputImage = InputImage.fromMediaImage(img, image.imageInfo.rotationDegrees)
+
+ // Process image searching for barcodes
+ val options = BarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ .build()
+
+ val scanner = BarcodeScanning.getClient(options)
+
+ scanner.process(inputImage)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ if (barcode.valueType !== Barcode.TYPE_URL) continue
+
+ onBarcodeScanned(barcodes[0].url.url)
+ lastSuccessfulScanTimestamp = System.currentTimeMillis()
+ break
+ }
+ }
+ .addOnFailureListener {
+ }
+ .addOnCompleteListener {
+ image.close()
+ }
+ }
+
+ lastProcessedTimestamp = System.currentTimeMillis()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml
index ad0c7d4..c43989f 100644
--- a/app/src/main/res/layout/fragment_add_dialog.xml
+++ b/app/src/main/res/layout/fragment_add_dialog.xml
@@ -1,33 +1,33 @@
-
+
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:id="@+id/add_dialog_subscribe_view">
-
-
+
-
-
+ android:text="@string/add_dialog_use_another_server_description"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_use_another_server_description"
+ android:paddingStart="4dp" android:paddingTop="0dp"
+ android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_checkbox"
+ android:layout_marginTop="-5dp"/>
+
-
+ android:id="@+id/add_dialog_subscribe_base_url_text"
+ android:hint="@string/app_base_url"
+ android:maxLines="1"
+ android:layout_marginTop="0dp"
+ android:layout_marginBottom="10dp"
+ android:inputType="textNoSuggestions"
+ android:paddingStart="0dp"
+ android:paddingEnd="0dp"
+ android:paddingTop="5dp"
+ android:paddingBottom="5dp"
+ android:layout_marginStart="4dp"
+ android:layout_marginEnd="4dp"
+ android:textAppearance="?android:attr/textAppearanceMedium"
+ />
-
-
-
-
-
+
+ android:text="@string/add_dialog_instant_delivery_description"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_description"
+ android:paddingStart="4dp" android:paddingTop="0dp"
+ android:visibility="gone" app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_box"/>
+ android:text="@string/add_dialog_foreground_description"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_foreground_description"
+ android:paddingStart="4dp" app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_instant_delivery_description"/>
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text"
+ android:layout_marginTop="1dp"/>
-
+ android:text="Unable to resolve host example.com"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_error_text"
+ android:paddingStart="4dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_foreground_description"
+ android:paddingEnd="4dp"
+ android:textAppearance="@style/DangerText"
+ app:layout_constraintStart_toEndOf="@id/add_dialog_subscribe_error_text_image"
+ android:layout_marginTop="5dp"
+ tools:visibility="gone"/>
+
+
+
+
+
+
+
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:id="@+id/add_dialog_login_view">
-
-
+
-
-
-
-
-
-
+