From e7ecdc266e83dbe69fe6ab4cbd3e3e653baa1999 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Fri, 28 Jan 2022 00:02:20 -0500
Subject: [PATCH] Continued dialogs

---
 .../java/io/heckel/ntfy/ui/AddFragment.kt     | 248 ++++++++++--------
 .../main/res/drawable/ic_error_black_24dp.xml |   9 +
 .../main/res/layout/fragment_add_dialog.xml   | 113 +++++---
 app/src/main/res/values/strings.xml           |  10 +-
 assets/error_black_24dp.svg                   |   1 +
 5 files changed, 231 insertions(+), 150 deletions(-)
 create mode 100644 app/src/main/res/drawable/ic_error_black_24dp.xml
 create mode 100644 assets/error_black_24dp.svg

diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
index 85783b2..b08829c 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
@@ -19,7 +19,6 @@ import io.heckel.ntfy.db.User
 import io.heckel.ntfy.log.Log
 import io.heckel.ntfy.msg.ApiService
 import io.heckel.ntfy.util.topicUrl
-import kotlinx.android.synthetic.main.fragment_add_dialog.*
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlin.random.Random
@@ -34,23 +33,25 @@ class AddFragment : DialogFragment() {
     private lateinit var loginView: View
 
     // Subscribe page
-    private lateinit var topicNameText: TextInputEditText
-    private lateinit var baseUrlLayout: TextInputLayout
-    private lateinit var baseUrlText: AutoCompleteTextView
-    private lateinit var useAnotherServerCheckbox: CheckBox
-    private lateinit var useAnotherServerDescription: TextView
-    private lateinit var instantDeliveryBox: View
-    private lateinit var instantDeliveryCheckbox: CheckBox
-    private lateinit var instantDeliveryDescription: View
+    private lateinit var subscribeTopicText: TextInputEditText
+    private lateinit var subscribeBaseUrlLayout: TextInputLayout
+    private lateinit var subscribeBaseUrlText: AutoCompleteTextView
+    private lateinit var subscribeUseAnotherServerCheckbox: CheckBox
+    private lateinit var subscribeUseAnotherServerDescription: TextView
+    private lateinit var subscribeInstantDeliveryBox: View
+    private lateinit var subscribeInstantDeliveryCheckbox: CheckBox
+    private lateinit var subscribeInstantDeliveryDescription: View
+    private lateinit var subscribeProgress: ProgressBar
+    private lateinit var subscribeErrorImage: View
     private lateinit var subscribeButton: Button
 
     // Login page
     private lateinit var users: List<User>
-    private lateinit var usersSpinner: Spinner
-    private lateinit var usernameText: TextInputEditText
-    private lateinit var passwordText: TextInputEditText
+    private lateinit var loginUsersSpinner: Spinner
+    private lateinit var loginUsernameText: TextInputEditText
+    private lateinit var loginPasswordText: TextInputEditText
     private lateinit var loginProgress: ProgressBar
-    private lateinit var loginError: TextView
+    private lateinit var loginErrorImage: View
 
     private lateinit var baseUrls: List<String> // List of base URLs already used, excluding app_base_url
 
@@ -80,24 +81,26 @@ class AddFragment : DialogFragment() {
         loginView.visibility = View.GONE
 
         // Fields for "subscribe page"
-        topicNameText = view.findViewById(R.id.add_dialog_topic_text)
-        baseUrlLayout = view.findViewById(R.id.add_dialog_base_url_layout)
-        baseUrlText = view.findViewById(R.id.add_dialog_base_url_text)
-        instantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box)
-        instantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox)
-        instantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description)
-        useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox)
-        useAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description)
+        subscribeTopicText = view.findViewById(R.id.add_dialog_topic_text)
+        subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_base_url_layout)
+        subscribeBaseUrlText = view.findViewById(R.id.add_dialog_base_url_text)
+        subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box)
+        subscribeInstantDeliveryCheckbox = view.findViewById(R.id.add_dialog_instant_delivery_checkbox)
+        subscribeInstantDeliveryDescription = view.findViewById(R.id.add_dialog_instant_delivery_description)
+        subscribeUseAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox)
+        subscribeUseAnotherServerDescription = view.findViewById(R.id.add_dialog_use_another_server_description)
+        subscribeProgress = view.findViewById(R.id.add_dialog_progress)
+        subscribeErrorImage = view.findViewById(R.id.add_dialog_error_image)
 
         // Fields for "login page"
-        usersSpinner = view.findViewById(R.id.add_dialog_login_users_spinner)
-        usernameText = view.findViewById(R.id.add_dialog_login_username)
-        passwordText = view.findViewById(R.id.add_dialog_login_password)
+        loginUsersSpinner = view.findViewById(R.id.add_dialog_login_users_spinner)
+        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)
-        loginError = view.findViewById(R.id.add_dialog_login_error)
+        loginErrorImage = view.findViewById(R.id.add_dialog_login_error_image)
 
         // Set "Use another server" description based on flavor
-        useAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) {
+        subscribeUseAnotherServerDescription.text = if (BuildConfig.FIREBASE_AVAILABLE) {
             getString(R.string.add_dialog_use_another_server_description)
         } else {
             getString(R.string.add_dialog_use_another_server_description_noinstant)
@@ -105,29 +108,29 @@ class AddFragment : DialogFragment() {
 
         // Base URL dropdown behavior; Oh my, why is this so complicated?!
         val toggleEndIcon = {
-            if (baseUrlText.text.isNotEmpty()) {
-                baseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
+            if (subscribeBaseUrlText.text.isNotEmpty()) {
+                subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
             } else if (baseUrls.isEmpty()) {
-                baseUrlLayout.setEndIconDrawable(0)
+                subscribeBaseUrlLayout.setEndIconDrawable(0)
             } else {
-                baseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
+                subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
             }
         }
-        baseUrlLayout.setEndIconOnClickListener {
-            if (baseUrlText.text.isNotEmpty()) {
-                baseUrlText.text.clear()
+        subscribeBaseUrlLayout.setEndIconOnClickListener {
+            if (subscribeBaseUrlText.text.isNotEmpty()) {
+                subscribeBaseUrlText.text.clear()
                 if (baseUrls.isEmpty()) {
-                    baseUrlLayout.setEndIconDrawable(0)
+                    subscribeBaseUrlLayout.setEndIconDrawable(0)
                 } else {
-                    baseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
+                    subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
                 }
-            } else if (baseUrlText.text.isEmpty() && baseUrls.isNotEmpty()) {
-                baseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp)
-                baseUrlText.showDropDown()
+            } else if (subscribeBaseUrlText.text.isEmpty() && baseUrls.isNotEmpty()) {
+                subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp)
+                subscribeBaseUrlText.showDropDown()
             }
         }
-        baseUrlText.setOnDismissListener { toggleEndIcon() }
-        baseUrlText.addTextChangedListener(object : TextWatcher {
+        subscribeBaseUrlText.setOnDismissListener { toggleEndIcon() }
+        subscribeBaseUrlText.addTextChangedListener(object : TextWatcher {
             override fun afterTextChanged(s: Editable?) {
                 toggleEndIcon()
             }
@@ -150,41 +153,34 @@ class AddFragment : DialogFragment() {
                 .sorted()
             val adapter = ArrayAdapter(requireActivity(), R.layout.fragment_add_dialog_dropdown_item, baseUrls)
             requireActivity().runOnUiThread {
-                baseUrlText.threshold = 1
-                baseUrlText.setAdapter(adapter)
+                subscribeBaseUrlText.threshold = 1
+                subscribeBaseUrlText.setAdapter(adapter)
                 if (baseUrls.count() == 1) {
-                    baseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
-                    baseUrlText.setText(baseUrls.first())
+                    subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
+                    subscribeBaseUrlText.setText(baseUrls.first())
                 } else if (baseUrls.count() > 1) {
-                    baseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
+                    subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
                 } else {
-                    baseUrlLayout.setEndIconDrawable(0)
+                    subscribeBaseUrlLayout.setEndIconDrawable(0)
                 }
             }
 
             // Users dropdown
             users = repository.getUsers()
-            if (users.isEmpty()) {
-                usersSpinner.visibility = View.GONE
-            } else {
-                val spinnerEntries = users.toMutableList()
-                spinnerEntries.add(0, User(0, "Create new", "")) // FIXME
-                usersSpinner.adapter = ArrayAdapter(requireActivity(), R.layout.fragment_add_dialog_dropdown_item, spinnerEntries)
-            }
         }
 
         // Show/hide based on flavor
-        instantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
+        subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
 
         // Show/hide spinner and username/password fields
-        usersSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+        loginUsersSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
             override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
                 if (position == 0) {
-                    usernameText.visibility = View.VISIBLE
-                    passwordText.visibility = View.VISIBLE
+                    loginUsernameText.visibility = View.VISIBLE
+                    loginPasswordText.visibility = View.VISIBLE
                 } else {
-                    usernameText.visibility = View.GONE
-                    passwordText.visibility = View.GONE
+                    loginUsernameText.visibility = View.GONE
+                    loginPasswordText.visibility = View.GONE
                 }
             }
             override fun onNothingSelected(parent: AdapterView<*>?) {
@@ -224,24 +220,24 @@ class AddFragment : DialogFragment() {
                     // Nothing
                 }
             }
-            topicNameText.addTextChangedListener(textWatcher)
-            baseUrlText.addTextChangedListener(textWatcher)
-            instantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked ->
-                if (isChecked) instantDeliveryDescription.visibility = View.VISIBLE
-                else instantDeliveryDescription.visibility = View.GONE
+            subscribeTopicText.addTextChangedListener(textWatcher)
+            subscribeBaseUrlText.addTextChangedListener(textWatcher)
+            subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, isChecked ->
+                if (isChecked) subscribeInstantDeliveryDescription.visibility = View.VISIBLE
+                else subscribeInstantDeliveryDescription.visibility = View.GONE
             }
-            useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
+            subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
                 if (isChecked) {
-                    useAnotherServerDescription.visibility = View.VISIBLE
-                    baseUrlLayout.visibility = View.VISIBLE
-                    instantDeliveryBox.visibility = View.GONE
-                    instantDeliveryDescription.visibility = View.GONE
+                    subscribeUseAnotherServerDescription.visibility = View.VISIBLE
+                    subscribeBaseUrlLayout.visibility = View.VISIBLE
+                    subscribeInstantDeliveryBox.visibility = View.GONE
+                    subscribeInstantDeliveryDescription.visibility = View.GONE
                 } else {
-                    useAnotherServerDescription.visibility = View.GONE
-                    baseUrlLayout.visibility = View.GONE
-                    instantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
-                    if (instantDeliveryCheckbox.isChecked) instantDeliveryDescription.visibility = View.VISIBLE
-                    else instantDeliveryDescription.visibility = View.GONE
+                    subscribeUseAnotherServerDescription.visibility = View.GONE
+                    subscribeBaseUrlLayout.visibility = View.GONE
+                    subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
+                    if (subscribeInstantDeliveryCheckbox.isChecked) subscribeInstantDeliveryDescription.visibility = View.VISIBLE
+                    else subscribeInstantDeliveryDescription.visibility = View.GONE
                 }
                 validateInput()
             }
@@ -251,7 +247,7 @@ class AddFragment : DialogFragment() {
     }
 
     private fun subscribeButtonClick() {
-        val topic = topicNameText.text.toString()
+        val topic = subscribeTopicText.text.toString()
         val baseUrl = getBaseUrl()
         if (subscribeView.visibility == View.VISIBLE) {
             checkAnonReadAndMaybeShowLogin(baseUrl, topic)
@@ -261,19 +257,44 @@ class AddFragment : DialogFragment() {
     }
 
     private fun checkAnonReadAndMaybeShowLogin(baseUrl: String, topic: String) {
+        subscribeProgress.visibility = View.VISIBLE
+        subscribeErrorImage.visibility = View.GONE
         lifecycleScope.launch(Dispatchers.IO) {
             Log.d(TAG, "Checking anonymous read access to topic ${topicUrl(baseUrl, topic)}")
-            val authorized = api.checkAnonTopicRead(baseUrl, topic)
-            if (authorized) {
-                Log.d(TAG, "Anonymous access granted to topic ${topicUrl(baseUrl, topic)}")
-                dismiss(authUserId = null)
-            } else {
-                Log.w(TAG, "Anonymous access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
+            try {
+                val authorized = api.checkAnonTopicRead(baseUrl, topic)
+                if (authorized) {
+                    Log.d(TAG, "Anonymous access granted to topic ${topicUrl(baseUrl, topic)}")
+                    dismiss(authUserId = null)
+                } else {
+                    Log.w(TAG, "Anonymous access not allowed to topic ${topicUrl(baseUrl, topic)}, showing login dialog")
+                    requireActivity().runOnUiThread {
+                        // Show/hide users dropdown
+                        if (users.isEmpty()) {
+                            loginUsersSpinner.visibility = View.GONE
+                        } else {
+                            val spinnerEntries = users.toMutableList()
+                            spinnerEntries.add(0, User(0, getString(R.string.add_dialog_login_new_user), ""))
+                            loginUsersSpinner.adapter = ArrayAdapter(requireActivity(), R.layout.fragment_add_dialog_dropdown_item, spinnerEntries)
+                            loginUsersSpinner.setSelection(1)
+                            /*loginUsernameText.visibility = View.GONE
+                            loginPasswordText.visibility = View.GONE*/
+                        }
+
+                        // Show login page
+                        subscribeView.visibility = View.GONE
+                        loginProgress.visibility = View.INVISIBLE
+                        loginView.visibility = View.VISIBLE
+                    }
+                }
+            } catch (e: Exception) {
+                Log.w(TAG, "Connection to topic failed: ${e.message}", e)
                 requireActivity().runOnUiThread {
-                    subscribeView.visibility = View.GONE
-                    loginError.visibility = View.INVISIBLE
-                    loginProgress.visibility = View.INVISIBLE
-                    loginView.visibility = View.VISIBLE
+                    subscribeProgress.visibility = View.GONE
+                    subscribeErrorImage.visibility = View.VISIBLE
+                    Toast
+                        .makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG)
+                        .show()
                 }
             }
         }
@@ -281,32 +302,45 @@ class AddFragment : DialogFragment() {
 
     private fun checkAuthAndMaybeDismiss(baseUrl: String, topic: String) {
         loginProgress.visibility = View.VISIBLE
-        loginError.visibility = View.INVISIBLE
-        val existingUser = usersSpinner.selectedItem != null && usersSpinner.selectedItem is User && usersSpinner.selectedItemPosition > 0
+        loginErrorImage.visibility = View.GONE
+        val existingUser = loginUsersSpinner.selectedItem != null && loginUsersSpinner.selectedItem is User && loginUsersSpinner.selectedItemPosition > 0
         val user = if (existingUser) {
-            usersSpinner.selectedItem as User
+            loginUsersSpinner.selectedItem as User
         } else {
             User(
                 id = Random.nextLong(),
-                username = usernameText.text.toString(),
-                password = passwordText.text.toString()
+                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)}")
-            val authorized = api.checkUserTopicRead(baseUrl, topic, user.username, user.password)
-            if (authorized) {
-                Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
-                if (!existingUser) {
-                    Log.d(TAG, "Adding new user ${user.username} to database")
-                    repository.addUser(user)
+            try {
+                val authorized = api.checkUserTopicRead(baseUrl, topic, user.username, user.password)
+                if (authorized) {
+                    Log.d(TAG, "Access granted for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
+                    if (!existingUser) {
+                        Log.d(TAG, "Adding new user ${user.username} to database")
+                        repository.addUser(user)
+                    }
+                    dismiss(authUserId = user.id)
+                } else {
+                    Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
+                    requireActivity().runOnUiThread {
+                        loginProgress.visibility = View.GONE
+                        loginErrorImage.visibility = View.VISIBLE
+                        Toast
+                            .makeText(context, getString(R.string.add_dialog_login_error_not_authorized), Toast.LENGTH_LONG)
+                            .show()
+                    }
                 }
-                dismiss(authUserId = user.id)
-            } else {
-                Log.w(TAG, "Access not allowed for user ${user.username} to topic ${topicUrl(baseUrl, topic)}")
+            } catch (e: Exception) {
                 requireActivity().runOnUiThread {
                     loginProgress.visibility = View.GONE
-                    loginError.visibility = View.VISIBLE
+                    loginErrorImage.visibility = View.VISIBLE
+                    Toast
+                        .makeText(context, getString(R.string.add_dialog_error_connection_failed, e.message), Toast.LENGTH_LONG)
+                        .show()
                 }
             }
         }
@@ -314,14 +348,14 @@ class AddFragment : DialogFragment() {
 
     private fun validateInput() = lifecycleScope.launch(Dispatchers.IO) {
         val baseUrl = getBaseUrl()
-        val topic = topicNameText.text.toString()
+        val topic = subscribeTopicText.text.toString()
         val subscription = repository.getSubscription(baseUrl, topic)
 
         activity?.let {
             it.runOnUiThread {
                 if (subscription != null || DISALLOWED_TOPICS.contains(topic)) {
                     subscribeButton.isEnabled = false
-                } else if (useAnotherServerCheckbox.isChecked) {
+                } else if (subscribeUseAnotherServerCheckbox.isChecked) {
                     subscribeButton.isEnabled = topic.isNotBlank()
                             && "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic)
                             && baseUrl.isNotBlank()
@@ -337,12 +371,12 @@ class AddFragment : DialogFragment() {
     private fun dismiss(authUserId: Long?) {
         Log.d(TAG, "Closing dialog and calling onSubscribe handler")
         requireActivity().runOnUiThread {
-            val topic = topicNameText.text.toString()
+            val topic = subscribeTopicText.text.toString()
             val baseUrl = getBaseUrl()
-            val instant = if (!BuildConfig.FIREBASE_AVAILABLE || useAnotherServerCheckbox.isChecked) {
+            val instant = if (!BuildConfig.FIREBASE_AVAILABLE || subscribeUseAnotherServerCheckbox.isChecked) {
                 true
             } else {
-                instantDeliveryCheckbox.isChecked
+                subscribeInstantDeliveryCheckbox.isChecked
             }
             subscribeListener.onSubscribe(topic, baseUrl, instant, authUserId = authUserId)
             dialog?.dismiss()
@@ -350,8 +384,8 @@ class AddFragment : DialogFragment() {
     }
 
     private fun getBaseUrl(): String {
-        return if (useAnotherServerCheckbox.isChecked) {
-            baseUrlText.text.toString()
+        return if (subscribeUseAnotherServerCheckbox.isChecked) {
+            subscribeBaseUrlText.text.toString()
         } else {
             getString(R.string.app_base_url)
         }
diff --git a/app/src/main/res/drawable/ic_error_black_24dp.xml b/app/src/main/res/drawable/ic_error_black_24dp.xml
new file mode 100644
index 0000000..7fd767b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_error_black_24dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"
+      android:fillColor="#F44336"/>
+</vector>
diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml
index 46a7354..e9cec79 100644
--- a/app/src/main/res/layout/fragment_add_dialog.xml
+++ b/app/src/main/res/layout/fragment_add_dialog.xml
@@ -1,48 +1,71 @@
 <?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">
+                                                   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
-            android:orientation="vertical"
+    <androidx.constraintlayout.widget.ConstraintLayout
             android:layout_width="match_parent"
-            android:layout_height="match_parent" android:id="@+id/add_dialog_subscribe_view" tools:visibility="gone">
+            android:layout_height="match_parent"
+            android:orientation="horizontal"
+            android:id="@+id/add_dialog_subscribe_view"
+            android:visibility="visible">
         <TextView
                 android:id="@+id/add_dialog_title_text"
-                android:layout_width="match_parent"
+                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"/>
+                android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintEnd_toStartOf="@id/add_dialog_error_image"/>
 
         <TextView
                 android:text="@string/add_dialog_description_below"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content" android:id="@+id/add_dialog_description_below"
-                android:paddingStart="4dp" android:paddingTop="3dp"/>
+                android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/add_dialog_title_text"/>
         <com.google.android.material.textfield.TextInputEditText
                 android:id="@+id/add_dialog_topic_text"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content" android:hint="@string/add_dialog_topic_name_hint"
                 android:importantForAutofill="no"
-                android:maxLines="1" android:inputType="text" android:maxLength="64"/>
+                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_description_below"/>
         <CheckBox
                 android:text="@string/add_dialog_use_another_server"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content" android:id="@+id/add_dialog_use_another_server_checkbox"
-                android:layout_marginTop="-5dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/>
+                android:layout_marginStart="-3dp" app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/add_dialog_topic_text"
+                android:layout_marginTop="-3dp"/>
+        <ProgressBar
+                style="?android:attr/progressBarStyle"
+                android:layout_width="24dp"
+                android:layout_height="24dp"
+                android:id="@+id/add_dialog_progress"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@id/add_dialog_error_image"
+                app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below"
+                android:indeterminate="true" android:layout_marginBottom="5dp" android:visibility="gone"/>
         <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_use_another_server_description"
-                android:paddingStart="4dp" android:paddingTop="0dp" android:layout_marginTop="-5dp"
-                android:visibility="gone"/>
+                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_use_another_server_checkbox"/>
         <com.google.android.material.textfield.TextInputLayout
                 style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
                 android:id="@+id/add_dialog_base_url_layout"
@@ -54,7 +77,9 @@
                 android:visibility="gone"
                 app:endIconMode="custom"
                 app:hintEnabled="false"
-                app:boxBackgroundColor="@android:color/transparent">
+                app:boxBackgroundColor="@android:color/transparent" app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/add_dialog_use_another_server_description">
             <AutoCompleteTextView
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
@@ -76,7 +101,9 @@
         <LinearLayout
                 android:orientation="horizontal"
                 android:layout_width="match_parent"
-                android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_box">
+                android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_box"
+                app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/add_dialog_base_url_layout" android:layout_marginTop="-3dp">
             <CheckBox
                     android:text="@string/add_dialog_instant_delivery"
                     android:layout_width="wrap_content"
@@ -95,14 +122,24 @@
                 android:text="@string/add_dialog_instant_delivery_description"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content" android:id="@+id/add_dialog_instant_delivery_description"
-                android:paddingStart="4dp" android:paddingTop="0dp" android:layout_marginTop="-5dp"
-                android:visibility="gone"/>
-    </LinearLayout>
+                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_instant_delivery_box"/>
+        <ImageView
+                android:layout_width="24dp"
+                android:layout_height="24dp" app:srcCompat="@drawable/ic_error_black_24dp"
+                android:id="@+id/add_dialog_error_image"
+                app:layout_constraintBottom_toTopOf="@+id/add_dialog_description_below"
+                android:layout_marginBottom="5dp" app:layout_constraintEnd_toStartOf="@+id/add_dialog_progress"
+                app:layout_constraintStart_toEndOf="@+id/add_dialog_title_text" android:visibility="gone"/>
+    </androidx.constraintlayout.widget.ConstraintLayout>
     <androidx.constraintlayout.widget.ConstraintLayout
             android:layout_width="match_parent"
-                                                       android:layout_height="match_parent"
-                                                       android:orientation="horizontal"
-                                                       android:id="@+id/add_dialog_login_view"
+            android:layout_height="match_parent"
+            android:orientation="horizontal"
+            android:id="@+id/add_dialog_login_view"
+            android:visibility="gone"
     >
         <TextView
                 android:id="@+id/add_dialog_login_title"
@@ -115,7 +152,7 @@
                 android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
                 app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toTopOf="parent"
-                app:layout_constraintEnd_toStartOf="@id/add_dialog_login_progress"/>
+                app:layout_constraintEnd_toStartOf="@id/add_dialog_login_error_image"/>
         <TextView
                 android:text="This topic requires you to login. Please pick an existing user or type in a username and password."
                 android:layout_width="match_parent"
@@ -144,24 +181,22 @@
                 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"/>
-        <TextView
-                android:text="Login failed"
-                android:layout_width="0dp"
-                android:layout_height="wrap_content" android:id="@+id/add_dialog_login_error"
-                android:paddingStart="4dp" android:paddingTop="3dp"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/add_dialog_login_password"
-                app:layout_constraintStart_toStartOf="parent" android:visibility="invisible"/>
         <ProgressBar
                 style="?android:attr/progressBarStyle"
                 android:layout_width="25dp"
                 android:layout_height="25dp"
                 android:id="@+id/add_dialog_login_progress"
-                app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toEndOf="@id/add_dialog_login_title"
-                app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description" android:layout_marginTop="5dp"
-                android:indeterminate="true"/>
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@id/add_dialog_login_error_image"
+                app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
+                android:indeterminate="true" android:layout_marginBottom="5dp"/>
+        <ImageView
+                android:layout_width="24dp"
+                android:layout_height="24dp" app:srcCompat="@drawable/ic_error_black_24dp"
+                android:id="@+id/add_dialog_login_error_image"
+                android:visibility="gone"
+                app:layout_constraintBottom_toTopOf="@+id/add_dialog_login_description"
+                app:layout_constraintEnd_toStartOf="@id/add_dialog_login_progress" android:layout_marginBottom="5dp"
+                app:layout_constraintStart_toEndOf="@+id/add_dialog_login_title"/>
     </androidx.constraintlayout.widget.ConstraintLayout>
-
-
 </LinearLayout>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1fbb3b6..185be30 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -78,14 +78,13 @@
     <!-- Add dialog -->
     <string name="add_dialog_title">Subscribe to topic</string>
     <string name="add_dialog_description_below">
-        Topics are not password-protected, so choose a name that\'s not easy to
-        guess. Once subscribed, you can PUT/POST to receive notifications on your phone.
+        Topics may not be password-protected, so choose a name that\'s not easy to
+        guess. Once subscribed, you can PUT/POST to receive notifications.
     </string>
     <string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
     <string name="add_dialog_use_another_server">Use another server</string>
     <string name="add_dialog_use_another_server_description">
-        You can subscribe to topics from your own server. This option requires a foreground service and
-        consumes more power, but also delivers notifications faster.
+        You can subscribe to topics from your own server. This option requires a foreground service.
     </string>
     <string name="add_dialog_use_another_server_description_noinstant">
         You can subscribe to topics from your own server. Simply type in the base
@@ -98,6 +97,9 @@
     </string>
     <string name="add_dialog_button_cancel">Cancel</string>
     <string name="add_dialog_button_subscribe">Subscribe</string>
+    <string name="add_dialog_error_connection_failed">Connection failed: %1$s</string>
+    <string name="add_dialog_login_error_not_authorized">Login failed. User not authorized.</string>
+    <string name="add_dialog_login_new_user">New user</string>
 
     <!-- Detail activity -->
     <string name="detail_deep_link_subscribed_toast_message">Subscribed to topic %1$s</string>
diff --git a/assets/error_black_24dp.svg b/assets/error_black_24dp.svg
new file mode 100644
index 0000000..8c61b7a
--- /dev/null
+++ b/assets/error_black_24dp.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
\ No newline at end of file