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"> - - + - - - - - - +