This commit is contained in:
Tom Caputi 2023-12-12 04:11:20 -07:00 committed by GitHub
commit cbc27d2ca6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1059 additions and 112 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()
@ -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()
}
}
}

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

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

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

@ -0,0 +1,15 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5z"/>
<path android:fillColor="@android:color/white" android:pathData="M3,21h8v-8H3V21zM5,15h4v4H5V15z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,3v8h8V3H13zM19,9h-4V5h4V9z"/>
<path android:fillColor="@android:color/white" android:pathData="M19,19h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,13h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M15,15h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,17h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M15,19h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M17,17h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M17,13h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M19,15h2v2h-2z"/>
</vector>

View file

@ -208,16 +208,71 @@
android:autoLink="web"/>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_margin="24dp">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:contentDescription="@string/main_add_button_description"
android:src="@drawable/ic_add_black_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
style="@style/FloatingActionButton"
/>
android:layout_alignParentBottom="true"
android:layout_alignParentEnd="true"
style="@style/FloatingActionButton" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_normal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/main_add_normal_button_description"
android:src="@drawable/ic_add_black_24dp"
android:layout_above="@id/fab"
android:layout_alignParentEnd="true"
android:backgroundTint="@color/gray_400"
android:tint="@color/gray_400"
app:fabSize="mini"
app:elevation="4dp"
android:visibility="gone" />
<TextView
android:id="@+id/fab_add_normal_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_toLeftOf="@id/fab_add_normal"
android:layout_marginRight="8dp"
android:layout_alignTop="@id/fab_add_normal"
android:layout_alignBottom="@id/fab_add_normal"
android:text="@string/main_add_normal_button_description"
android:visibility="gone" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_qr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/main_add_qr_button_description"
android:src="@drawable/ic_qr_code_24dp"
android:layout_above="@id/fab_add_normal"
android:layout_alignParentEnd="true"
android:backgroundTint="@color/gray_400"
android:tint="@color/gray_400"
app:fabSize="mini"
app:elevation="4dp"
android:visibility="gone" />
<TextView
android:id="@+id/fab_add_qr_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_toLeftOf="@id/fab_add_qr"
android:layout_marginRight="8dp"
android:layout_alignTop="@id/fab_add_qr"
android:layout_alignBottom="@id/fab_add_qr"
android:text="@string/main_add_qr_button_description"
android:visibility="gone" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

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,112 @@
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"/>
</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 +175,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 +191,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 +199,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 +215,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 +223,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>

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>

View file

@ -60,6 +60,8 @@
<string name="main_item_status_unified_push">%1$s (UnifiedPush)</string>
<string name="main_item_date_yesterday">yesterday</string>
<string name="main_add_button_description">Add subscription</string>
<string name="main_add_normal_button_description">Add subscription</string>
<string name="main_add_qr_button_description">Add subscription with QR Code</string>
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
<string name="main_how_to_intro">
Click the + to create or subscribe to a topic.