WIP work towards new qr scanner dialog

This commit is contained in:
Tom Caputi 2023-12-10 16:06:37 -05:00
parent 078728161e
commit add8f4b6fe
5 changed files with 705 additions and 131 deletions

View file

@ -53,9 +53,6 @@ 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
@ -74,7 +71,6 @@ 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
@ -130,8 +126,6 @@ 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)
@ -212,109 +206,11 @@ 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<out String>,
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()

View file

@ -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<out String>,
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
}
}

View file

@ -114,8 +114,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
fabAddQr.setOnClickListener {
//TODO: QR dialog instead
onSubscribeButtonClick()
onSubscribeQrButtonClick()
}
// Swipe to refresh
@ -514,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)")

View file

@ -154,31 +154,6 @@
app:layout_constraintStart_toEndOf="@id/add_dialog_subscribe_error_text_image"
android:layout_marginTop="5dp"
tools:visibility="gone"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_error_text">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Or Scan ntfy QR code:" />
<androidx.camera.view.PreviewView
android:id="@+id/add_dialog_subscribe_camera_preview"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="#000" />
<TextView
android:id="@+id/add_dialog_subscribe_camera_preview_denied_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Camera permission is required to scan QR codes."
android:visibility="gone" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ScrollView

View file

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/add_dialog_subscribe_view">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/add_dialog_subscribe_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="3dp"
android:text="@string/add_dialog_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/add_dialog_subscribe_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_subscribe_title"
android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/>
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_box"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_title" android:layout_marginTop="6dp">
<CheckBox
android:text="@string/add_dialog_instant_delivery"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_instant_delivery_checkbox"
android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp"
android:layout_marginStart="-3dp"/>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_24dp"
android:id="@+id/add_dialog_subscribe_instant_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"
android:layout_marginTop="3dp"/>
</LinearLayout>
<TextView
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"/>
<TextView
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"/>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
android:id="@+id/add_dialog_subscribe_error_text_image"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/add_dialog_subscribe_error_text"
android:layout_marginTop="1dp"/>
<TextView
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"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingBottom="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_error_text">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Scan ntfy QR code:" />
<androidx.camera.view.PreviewView
android:id="@+id/add_dialog_subscribe_camera_preview"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="#000" />
<TextView
android:id="@+id/add_dialog_subscribe_camera_preview_denied_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Camera permission is required to scan QR codes."
android:visibility="gone" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/add_dialog_login_view">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/add_dialog_login_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="3dp"
android:text="@string/add_dialog_login_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<TextView
android:text="@string/add_dialog_login_description"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_login_description"
android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_title" android:paddingEnd="4dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_login_username"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="@string/add_dialog_login_username_hint"
android:importantForAutofill="no"
android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="10dp" app:layout_constraintTop_toBottomOf="@+id/add_dialog_login_description"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_dialog_login_password"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:hint="@string/add_dialog_login_password_hint"
android:importantForAutofill="no"
android:maxLines="1" android:inputType="textPassword" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_username"/>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"
android:id="@+id/add_dialog_login_error_text_image"
android:visibility="visible"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="@+id/add_dialog_login_error_text" app:layout_constraintTop_toTopOf="@+id/add_dialog_login_error_text"/>
<TextView
android:text="Login failed. User not authorized."
android:layout_width="0dp"
android:layout_height="wrap_content" android:id="@+id/add_dialog_login_error_text"
android:paddingStart="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_login_password"
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_text_image"/>
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="25dp"
android:layout_height="25dp"
android:id="@+id/add_dialog_login_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
android:indeterminate="true" android:layout_marginBottom="5dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>