diff --git a/app/build.gradle b/app/build.gradle
index 90dc1edb1..d7d7fb941 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 0950b404e..5971b3751 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 8e56bab9a..09c3028d3 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()
@@ -288,7 +311,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 +356,8 @@ class AddFragment : DialogFragment() {
} else {
positiveButton.isEnabled = validTopic(topic)
}
+
+ onCompletion()
}
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddQrFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddQrFragment.kt
new file mode 100644
index 000000000..7b7b8e66d
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/AddQrFragment.kt
@@ -0,0 +1,510 @@
+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
+import io.heckel.ntfy.R
+import io.heckel.ntfy.db.Repository
+import io.heckel.ntfy.db.User
+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 AddQrFragment : DialogFragment() {
+ private val api = ApiService()
+
+ private lateinit var repository: Repository
+ private lateinit var subscribeListener: SubscribeListener
+ 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
+ private lateinit var negativeButton: Button
+
+ // Subscribe page
+ private lateinit var subscribeInstantDeliveryBox: View
+ private lateinit var subscribeInstantDeliveryCheckbox: CheckBox
+ private lateinit var subscribeInstantDeliveryDescription: View
+ private lateinit var subscribeForegroundDescription: TextView
+ 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
+ private lateinit var loginPasswordText: TextInputEditText
+ private lateinit var loginProgress: ProgressBar
+ private lateinit var loginErrorText: TextView
+ private lateinit var loginErrorTextImage: View
+
+ interface SubscribeListener {
+ fun onSubscribe(topic: String, baseUrl: String, instant: Boolean)
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ subscribeListener = activity as SubscribeListener
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ if (activity == null) {
+ throw IllegalStateException("Activity cannot be null")
+ }
+
+ // Dependencies (Fragments need a default constructor)
+ repository = Repository.getInstance(requireActivity())
+ appBaseUrl = getString(R.string.app_base_url)
+ defaultBaseUrl = repository.getDefaultBaseUrl()
+
+ // Build root view
+ val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_qr_dialog, null)
+
+ // Main "pages"
+ subscribeView = view.findViewById(R.id.add_dialog_subscribe_view)
+ subscribeView.visibility = View.VISIBLE
+ loginView = view.findViewById(R.id.add_dialog_login_view)
+ loginView.visibility = View.GONE
+
+ // Fields for "subscribe page"
+ subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box)
+ subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_checkbox)
+ subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_description)
+ subscribeForegroundDescription = view.findViewById(R.id.add_dialog_subscribe_foreground_description)
+ subscribeProgress = view.findViewById(R.id.add_dialog_subscribe_progress)
+ subscribeErrorText = view.findViewById(R.id.add_dialog_subscribe_error_text)
+ 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)
+ loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
+ loginProgress = view.findViewById(R.id.add_dialog_login_progress)
+ loginErrorText = view.findViewById(R.id.add_dialog_login_error_text)
+ loginErrorTextImage = view.findViewById(R.id.add_dialog_login_error_text_image)
+
+ // Set foreground description text
+ subscribeForegroundDescription.text = getString(R.string.add_dialog_foreground_description, shortUrl(appBaseUrl))
+
+ // Show/hide based on flavor (faster shortcut for validateInputSubscribeView, which can only run onShow)
+ if (!BuildConfig.FIREBASE_AVAILABLE) {
+ subscribeInstantDeliveryBox.visibility = View.GONE
+ }
+
+ // Username/password validation on type
+ val loginTextWatcher = AfterChangedTextWatcher {
+ validateInputLoginView()
+ }
+ loginUsernameText.addTextChangedListener(loginTextWatcher)
+ loginPasswordText.addTextChangedListener(loginTextWatcher)
+
+ // Build dialog
+ val dialog = AlertDialog.Builder(activity)
+ .setView(view)
+ .setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ ->
+ // This will be overridden below to avoid closing the dialog immediately
+ }
+ .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
+ // This will be overridden below
+ }
+ .create()
+
+ // Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785)
+ dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
+
+ // Add logic to disable "Subscribe" button on invalid input
+ dialog.setOnShowListener {
+ positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
+ positiveButton.isEnabled = false
+ positiveButton.setOnClickListener {
+ positiveButtonClick()
+ }
+ negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
+ negativeButton.setOnClickListener {
+ negativeButtonClick()
+ }
+
+ subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ ->
+ validateInputSubscribeView()
+ }
+
+ 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("/", "")
+
+ 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()
+ if (subscribeView.visibility == View.VISIBLE) {
+ checkReadAndMaybeShowLogin(baseUrl, topic)
+ } else if (loginView.visibility == View.VISIBLE) {
+ loginAndMaybeDismiss(baseUrl, topic)
+ }
+ }
+
+ private fun checkReadAndMaybeShowLogin(baseUrl: String, topic: String) {
+ subscribeProgress.visibility = View.VISIBLE
+ subscribeErrorText.visibility = View.GONE
+ subscribeErrorTextImage.visibility = View.GONE
+ enableSubscribeView(false)
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val user = repository.getUser(baseUrl) // May be null
+ val authorized = api.checkAuth(baseUrl, topic, user)
+ if (authorized) {
+ Log.d(TAG, "Access granted to topic ${topicUrl(baseUrl, topic)}")
+ dismissDialog()
+ } else {
+ if (user != null) {
+ Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, but user already exists")
+ showErrorAndReenableSubscribeView(getString(R.string.add_dialog_login_error_not_authorized, user.username))
+ } else {
+ Log.w(TAG, "Access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
+ val activity = activity ?: return@launch // We may have pressed "Cancel"
+ activity.runOnUiThread {
+ showLoginView(activity)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Connection to topic failed: ${e.message}", e)
+ showErrorAndReenableSubscribeView(e.message)
+ }
+ }
+ }
+
+ private fun showErrorAndReenableSubscribeView(message: String?) {
+ val activity = activity ?: return // We may have pressed "Cancel"
+ activity.runOnUiThread {
+ subscribeProgress.visibility = View.GONE
+ subscribeErrorText.visibility = View.VISIBLE
+ subscribeErrorText.text = message
+ subscribeErrorTextImage.visibility = View.VISIBLE
+ enableSubscribeView(true)
+ }
+ }
+
+ private fun loginAndMaybeDismiss(baseUrl: String, topic: String) {
+ loginProgress.visibility = View.VISIBLE
+ loginErrorText.visibility = View.GONE
+ loginErrorTextImage.visibility = View.GONE
+ enableLoginView(false)
+ val user = User(
+ baseUrl = baseUrl,
+ username = loginUsernameText.text.toString(),
+ password = loginPasswordText.text.toString()
+ )
+ lifecycleScope.launch(Dispatchers.IO) {
+ Log.d(TAG, "Checking read access for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
+ try {
+ val authorized = api.checkAuth(baseUrl, topic, user)
+ if (authorized) {
+ Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}, adding to database")
+ repository.addUser(user)
+ dismissDialog()
+ } else {
+ Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
+ showErrorAndReenableLoginView(getString(R.string.add_dialog_login_error_not_authorized, user.username))
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Connection to topic failed during login: ${e.message}", e)
+ showErrorAndReenableLoginView(e.message)
+ }
+ }
+ }
+
+ private fun showErrorAndReenableLoginView(message: String?) {
+ val activity = activity ?: return // We may have pressed "Cancel"
+ activity.runOnUiThread {
+ loginProgress.visibility = View.GONE
+ loginErrorText.visibility = View.VISIBLE
+ loginErrorText.text = message
+ loginErrorTextImage.visibility = View.VISIBLE
+ enableLoginView(true)
+ }
+ }
+
+ private fun negativeButtonClick() {
+ if (subscribeView.visibility == View.VISIBLE) {
+ dialog?.cancel()
+ } else if (loginView.visibility == View.VISIBLE) {
+ showSubscribeView()
+ }
+ }
+
+ 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".
+ //TODO: Phil check this
+ val instantToggleAllowed = if (!BuildConfig.FIREBASE_AVAILABLE) {
+ false
+ } else if (defaultBaseUrl == null) {
+ true
+ } else {
+ false
+ }
+
+ if (instantToggleAllowed) {
+ subscribeInstantDeliveryBox.visibility = View.VISIBLE
+ subscribeInstantDeliveryDescription.visibility = if (subscribeInstantDeliveryCheckbox.isChecked) View.VISIBLE else View.GONE
+ subscribeForegroundDescription.visibility = View.GONE
+ } else {
+ subscribeInstantDeliveryBox.visibility = View.GONE
+ subscribeInstantDeliveryDescription.visibility = View.GONE
+ subscribeForegroundDescription.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
+ }
+
+ // Enable/disable "Subscribe" button
+ lifecycleScope.launch(Dispatchers.IO) {
+ val baseUrl = getBaseUrl()
+ val topic = subscribeTopicText.text.toString()
+ val subscription = repository.getSubscription(baseUrl, topic)
+
+ activity?.let {
+ it.runOnUiThread {
+ if (subscription != null || DISALLOWED_TOPICS.contains(topic)) {
+ positiveButton.isEnabled = false
+ } else if (subscribeUseAnotherServerCheckbox.isChecked) {
+ positiveButton.isEnabled = validTopic(topic) && validUrl(baseUrl)
+ } else {
+ positiveButton.isEnabled = validTopic(topic)
+ }
+
+ onCompletion()
+ }
+ }
+ }
+ }
+
+ private fun validateInputLoginView() {
+ if (!this::positiveButton.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) {
+ return // As per crash seen in Google Play
+ }
+ if (loginUsernameText.visibility == View.GONE) {
+ positiveButton.isEnabled = true
+ } else {
+ positiveButton.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false)
+ && (loginPasswordText.text?.isNotEmpty() ?: false)
+ }
+ }
+
+ private fun dismissDialog() {
+ Log.d(TAG, "Closing dialog and calling onSubscribe handler")
+ val activity = activity?: return // We may have pressed "Cancel"
+ activity.runOnUiThread {
+ val topic = subscribeTopicText.text.toString()
+ val baseUrl = getBaseUrl()
+ val instant = !BuildConfig.FIREBASE_AVAILABLE || baseUrl != appBaseUrl || subscribeInstantDeliveryCheckbox.isChecked
+ subscribeListener.onSubscribe(topic, baseUrl, instant)
+ dialog?.dismiss()
+ }
+ }
+
+ private fun getBaseUrl(): String {
+ return if (subscribeUseAnotherServerCheckbox.isChecked) {
+ subscribeBaseUrlText.text.toString()
+ } else {
+ return defaultBaseUrl ?: appBaseUrl
+ }
+ }
+
+ private fun showSubscribeView() {
+ resetSubscribeView()
+ positiveButton.text = getString(R.string.add_dialog_button_subscribe)
+ negativeButton.text = getString(R.string.add_dialog_button_cancel)
+ loginView.visibility = View.GONE
+ subscribeView.visibility = View.VISIBLE
+ }
+
+ private fun showLoginView(activity: Activity) {
+ resetLoginView()
+ loginProgress.visibility = View.INVISIBLE
+ positiveButton.text = getString(R.string.add_dialog_button_login)
+ negativeButton.text = getString(R.string.add_dialog_button_back)
+ subscribeView.visibility = View.GONE
+ loginView.visibility = View.VISIBLE
+ if (loginUsernameText.requestFocus()) {
+ val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+
+ private fun enableSubscribeView(enable: Boolean) {
+ subscribeInstantDeliveryCheckbox.isEnabled = enable
+ positiveButton.isEnabled = enable
+ }
+
+ private fun resetSubscribeView() {
+ subscribeProgress.visibility = View.GONE
+ subscribeErrorText.visibility = View.GONE
+ subscribeErrorTextImage.visibility = View.GONE
+ enableSubscribeView(true)
+ }
+
+ private fun enableLoginView(enable: Boolean) {
+ loginUsernameText.isEnabled = enable
+ loginPasswordText.isEnabled = enable
+ positiveButton.isEnabled = enable
+ if (enable && loginUsernameText.requestFocus()) {
+ val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+
+ private fun resetLoginView() {
+ loginProgress.visibility = View.GONE
+ loginErrorText.visibility = View.GONE
+ loginErrorTextImage.visibility = View.GONE
+ loginUsernameText.visibility = View.VISIBLE
+ loginUsernameText.text?.clear()
+ loginPasswordText.visibility = View.VISIBLE
+ loginPasswordText.text?.clear()
+ enableLoginView(true)
+ }
+
+ companion object {
+ const val TAG = "NtfyAddFragment"
+ private val DISALLOWED_TOPICS = listOf("docs", "static", "file") // If updated, also update in server
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index e131f32c7..d1d635102 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -65,6 +65,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private lateinit var mainListContainer: SwipeRefreshLayout
private lateinit var adapter: MainAdapter
private lateinit var fab: FloatingActionButton
+ private lateinit var fabAddNormal: FloatingActionButton
+ private lateinit var fabAddNormalLabel: TextView
+ private lateinit var fabAddQr: FloatingActionButton
+ private lateinit var fabAddQrLabel: TextView
// Other stuff
private var actionMode: ActionMode? = null
@@ -87,12 +91,32 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Action bar
title = getString(R.string.main_action_bar_title)
- // Floating action button ("+")
+ // Floating action button and menu options ("+")
+ var isFabOpen = false
fab = findViewById(R.id.fab)
+ fabAddNormal = findViewById(R.id.fab_add_normal)
+ fabAddNormalLabel = findViewById(R.id.fab_add_normal_label)
+ fabAddQr = findViewById(R.id.fab_add_qr)
+ fabAddQrLabel = findViewById(R.id.fab_add_qr_label)
+
fab.setOnClickListener {
+ if (!isFabOpen) {
+ showFabMenu()
+ isFabOpen = true
+ } else {
+ closeFabMenu()
+ isFabOpen = false
+ }
+ }
+
+ fabAddNormal.setOnClickListener {
onSubscribeButtonClick()
}
+ fabAddQr.setOnClickListener {
+ onSubscribeQrButtonClick()
+ }
+
// Swipe to refresh
mainListContainer = findViewById(R.id.main_subscriptions_list_container)
mainListContainer.setOnRefreshListener { refreshAllSubscriptions() }
@@ -214,6 +238,54 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
maybeRequestNotificationPermission()
}
+ private fun showFabMenu() {
+ fabAddNormal.alpha = 0f
+ fabAddNormal.visibility = View.VISIBLE
+ fabAddNormal.animate().alpha(1f).translationY(-45.0f).setDuration(300).start()
+
+ fabAddNormalLabel.visibility = View.VISIBLE
+ fabAddNormalLabel.alpha = 0f
+ fabAddNormalLabel.animate().alpha(1f).translationY(-45.0f).setDuration(300).start()
+
+ fabAddQr.alpha = 0f
+ fabAddQr.visibility = View.VISIBLE
+ fabAddQr.animate().alpha(1f).translationY(-85.0f).setDuration(300).start()
+
+ fabAddQrLabel.visibility = View.VISIBLE
+ fabAddQrLabel.alpha = 0f
+ fabAddQrLabel.animate().alpha(1f).translationY(-85.0f).setDuration(300).start()
+
+ fab.animate().rotation(45.0f).setDuration(300).start()
+ }
+
+ private fun closeFabMenu() {
+ fabAddNormal.animate()
+ .translationY(0f)
+ .alpha(0f)
+ .setDuration(300)
+ .withEndAction { fabAddNormal.visibility = View.GONE }
+
+ fabAddNormalLabel.animate()
+ .translationY(0f)
+ .alpha(0f)
+ .setDuration(300)
+ .withEndAction { fabAddNormalLabel.visibility = View.GONE }
+
+ fabAddQr.animate()
+ .translationY(0f)
+ .alpha(0f)
+ .setDuration(300)
+ .withEndAction { fabAddQr.visibility = View.GONE }
+
+ fabAddQrLabel.animate()
+ .translationY(0f)
+ .alpha(0f)
+ .setDuration(300)
+ .withEndAction { fabAddQrLabel.visibility = View.GONE }
+
+ fab.animate().rotation(0.0f).setDuration(300).start()
+ }
+
private fun maybeRequestNotificationPermission() {
// Android 13 (SDK 33) requires that we ask for permission to post notifications
// https://developer.android.com/develop/ui/views/notifications/notification-permission
@@ -441,6 +513,11 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
newFragment.show(supportFragmentManager, AddFragment.TAG)
}
+ private fun onSubscribeQrButtonClick() {
+ val newFragment = AddQrFragment()
+ newFragment.show(supportFragmentManager, AddFragment.TAG)
+ }
+
override fun onSubscribe(topic: String, baseUrl: String, instant: Boolean) {
Log.d(TAG, "Adding subscription ${topicShortUrl(baseUrl, topic)} (instant = $instant)")
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..07d30c223
--- /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/drawable/ic_qr_code_24dp.xml b/app/src/main/res/drawable/ic_qr_code_24dp.xml
new file mode 100644
index 000000000..5461a4281
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qr_code_24dp.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 0bb4bd6d3..f6fe199a5 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -208,16 +208,71 @@
android:autoLink="web"/>
-
+
+ android:layout_alignParentBottom="true"
+ android:layout_alignParentEnd="true"
+ style="@style/FloatingActionButton" />
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml
index ad0c7d49a..e10e14bd2 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">
-
-
+
-
-
-
-
-
-
+
diff --git a/app/src/main/res/layout/fragment_add_qr_dialog.xml b/app/src/main/res/layout/fragment_add_qr_dialog.xml
new file mode 100644
index 000000000..03ba2582d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_add_qr_dialog.xml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c77599010..36e170763 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -60,6 +60,8 @@
%1$s (UnifiedPush)
yesterday
Add subscription
+ Add subscription
+ Add subscription with QR Code
It looks like you don\'t have any subscriptions yet.
Click the + to create or subscribe to a topic.