Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
11 changes: 10 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy">

<!-- Permissions -->


<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <!-- For instant delivery foregrounds service -->
<uses-permission android:name="android.permission.WAKE_LOCK"/> <!-- To keep foreground service awake; soon not needed anymore -->
Expand All @@ -11,7 +12,12 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/> <!-- Only required on SDK <= 28 -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <!-- To reschedule the websocket retry -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <!-- As of Android 13, we need to ask for permission to post notifications -->
<uses-permission android:name="android.permission.CAMERA" /> <!-- To Scan QR Codes -->

<!-- Permissions -->
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<!--
Permission REQUEST_INSTALL_PACKAGES (F-Droid only!):
- Permission is used to install .apk files that were received as attachments
Expand Down Expand Up @@ -94,6 +100,9 @@
android:exported="false">
</activity>

<!-- Scan QR code -->
<activity android:name=".ui.QrScannerActivity" />

<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".service.SubscriberService"/>

Expand Down
45 changes: 44 additions & 1 deletion app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -56,6 +60,26 @@ class AddFragment : DialogFragment() {
private lateinit var loginErrorText: TextView
private lateinit var loginErrorTextImage: View

// QR Codes
private var qrActivityResultLauncher: ActivityResultLauncher<Intent> = 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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -168,6 +195,11 @@ class AddFragment : DialogFragment() {
negativeButton.setOnClickListener {
negativeButtonClick()
}

qrButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
qrButton.setOnClickListener {
qrButtonClick()
}
val subscribeTextWatcher = AfterChangedTextWatcher {
validateInputSubscribeView()
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions app/src/main/java/io/heckel/ntfy/ui/QrScannerActivity.kt
Original file line number Diff line number Diff line change
@@ -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<out String>,
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<PreviewView>(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<String, String> {
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"
}
}
43 changes: 43 additions & 0 deletions app/src/main/java/io/heckel/ntfy/util/QrCodeAnalyzer.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/res/layout/activity_qr_scanner.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.QrScannerActivity">

<androidx.camera.view.PreviewView
android:id="@+id/preview_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>