added qr code scanner

This commit is contained in:
Tom Caputi 2023-10-29 00:13:12 -04:00
parent c15efff72c
commit ccf7c1c281
5 changed files with 333 additions and 105 deletions

View file

@ -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"
}

View file

@ -11,6 +11,11 @@
<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" /> <!-- For QR Code scanning -->
<!-- Features -->
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<!--
Permission REQUEST_INSTALL_PACKAGES (F-Droid only!):
@ -163,6 +168,9 @@
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification"/>
<meta-data
android:name="com.google.android.gms.vision.DEPENDENCIES"
android:value="barcode" />
<!-- FileProvider required for older Android versions (<= P), to allow passing the file URI in the open intent.
Avoids "exposed beyond app through Intent.getData" exception, see see https://stackoverflow.com/a/57288352/1440785 -->

View file

@ -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<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()
@ -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()
}
}
}

View file

@ -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()
}
}

View file

@ -1,33 +1,33 @@
<?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">
<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">
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
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"
@ -35,7 +35,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/add_dialog_subscribe_description"
android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/>
<TextView
<TextView
android:text="@string/add_dialog_description_below"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_description"
@ -50,7 +50,7 @@
android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_description"/>
<CheckBox
<CheckBox
android:text="@string/add_dialog_use_another_server"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/add_dialog_subscribe_use_another_server_checkbox"
@ -59,111 +59,137 @@
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_topic_text"
android:layout_marginTop="-3dp"/>
<TextView
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: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"/>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
android:id="@+id/add_dialog_subscribe_base_url_layout"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
android:id="@+id/add_dialog_subscribe_base_url_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:padding="0dp"
android:visibility="gone"
app:endIconMode="custom"
app:hintEnabled="false"
app:boxBackgroundColor="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_description">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:padding="0dp"
android:visibility="gone"
app:endIconMode="custom"
app:hintEnabled="false"
app:boxBackgroundColor="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/add_dialog_subscribe_use_another_server_description">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
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: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"
/>
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
<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_base_url_layout" android:layout_marginTop="-3dp">
<CheckBox
<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
<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>
</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"/>
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"/>
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"/>
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"/>
</androidx.constraintlayout.widget.ConstraintLayout>
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="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
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/add_dialog_login_view">
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
<TextView
android:id="@+id/add_dialog_login_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -174,15 +200,15 @@
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<TextView
/>
<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
<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"
@ -190,7 +216,7 @@
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
<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"
@ -198,13 +224,13 @@
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
<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
<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"
@ -214,7 +240,7 @@
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_text_image"/>
<ProgressBar
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="25dp"
android:layout_height="25dp"
@ -222,6 +248,6 @@
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>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>