Base URL dropdown; working

This commit is contained in:
Philipp Heckel 2022-02-12 15:26:18 -05:00
parent 29a40080db
commit 3dcf4939c8
5 changed files with 199 additions and 91 deletions

View file

@ -20,9 +20,8 @@ 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.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.topicUrl
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -51,7 +50,6 @@ class AddFragment : DialogFragment() {
private lateinit var subscribeErrorTextImage: View
// Login page
private lateinit var users: List<User>
private lateinit var loginUsernameText: TextInputEditText
private lateinit var loginPasswordText: TextInputEditText
private lateinit var loginProgress: ProgressBar
@ -90,6 +88,7 @@ class AddFragment : DialogFragment() {
subscribeTopicText = view.findViewById(R.id.add_dialog_subscribe_topic_text)
subscribeBaseUrlLayout = view.findViewById(R.id.add_dialog_subscribe_base_url_layout)
subscribeBaseUrlLayout.background = view.background
subscribeBaseUrlLayout.makeEndIconSmaller(resources) // Hack!
subscribeBaseUrlText = view.findViewById(R.id.add_dialog_subscribe_base_url_text)
subscribeBaseUrlText.background = view.background
subscribeInstantDeliveryBox = view.findViewById(R.id.add_dialog_subscribe_instant_delivery_box)
@ -103,13 +102,6 @@ class AddFragment : DialogFragment() {
subscribeErrorTextImage = view.findViewById(R.id.add_dialog_subscribe_error_text_image)
subscribeErrorTextImage.visibility = View.GONE
// Hack: Make end icon smaller, see https://stackoverflow.com/a/57098715/1440785
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
val endIconImageView = subscribeBaseUrlLayout.findViewById<ImageView>(R.id.text_input_end_icon)
endIconImageView.minimumHeight = dimension.toInt()
endIconImageView.minimumWidth = dimension.toInt()
subscribeBaseUrlLayout.requestLayout()
// Fields for "login page"
loginUsernameText = view.findViewById(R.id.add_dialog_login_username)
loginPasswordText = view.findViewById(R.id.add_dialog_login_password)
@ -124,45 +116,11 @@ class AddFragment : DialogFragment() {
getString(R.string.add_dialog_use_another_server_description_noinstant)
}
// Base URL dropdown behavior; Oh my, why is this so complicated?!
val toggleEndIcon = {
if (subscribeBaseUrlText.text.isNotEmpty()) {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
} else if (baseUrls.isEmpty()) {
subscribeBaseUrlLayout.setEndIconDrawable(0)
} else {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
}
}
subscribeBaseUrlLayout.setEndIconOnClickListener {
if (subscribeBaseUrlText.text.isNotEmpty()) {
subscribeBaseUrlText.text.clear()
if (baseUrls.isEmpty()) {
subscribeBaseUrlLayout.setEndIconDrawable(0)
} else {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
}
} else if (subscribeBaseUrlText.text.isEmpty() && baseUrls.isNotEmpty()) {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp)
subscribeBaseUrlText.showDropDown()
}
}
subscribeBaseUrlText.setOnDismissListener { toggleEndIcon() }
subscribeBaseUrlText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
toggleEndIcon()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// Nothing
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Nothing
}
})
// Show/hide based on flavor
subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
// Fill autocomplete for base URL & users drop-down
// Add baseUrl auto-complete behavior
lifecycleScope.launch(Dispatchers.IO) {
// Auto-complete
val appBaseUrl = getString(R.string.app_base_url)
baseUrls = repository.getSubscriptions()
.groupBy { it.baseUrl }
@ -170,27 +128,11 @@ class AddFragment : DialogFragment() {
.filterNot { it == appBaseUrl }
.sorted()
val activity = activity ?: return@launch // We may have pressed "Cancel"
val adapter = ArrayAdapter(activity, R.layout.fragment_add_dialog_dropdown_item, baseUrls)
activity.runOnUiThread {
subscribeBaseUrlText.threshold = 1
subscribeBaseUrlText.setAdapter(adapter)
if (baseUrls.count() == 1) {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
subscribeBaseUrlText.setText(baseUrls.first())
} else if (baseUrls.count() > 1) {
subscribeBaseUrlLayout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
} else {
subscribeBaseUrlLayout.setEndIconDrawable(0)
}
initBaseUrlDropdown(baseUrls, subscribeBaseUrlText, subscribeBaseUrlLayout)
}
// Users dropdown
users = repository.getUsers()
}
// Show/hide based on flavor
subscribeInstantDeliveryBox.visibility = if (BuildConfig.FIREBASE_AVAILABLE) View.VISIBLE else View.GONE
// Username/password validation on type
val loginTextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
@ -384,13 +326,9 @@ class AddFragment : DialogFragment() {
if (subscription != null || DISALLOWED_TOPICS.contains(topic)) {
positiveButton.isEnabled = false
} else if (subscribeUseAnotherServerCheckbox.isChecked) {
positiveButton.isEnabled = topic.isNotBlank()
&& "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic)
&& baseUrl.isNotBlank()
&& "^https?://.+".toRegex().matches(baseUrl)
positiveButton.isEnabled = validTopic(topic) && validUrl(baseUrl)
} else {
positiveButton.isEnabled = topic.isNotBlank()
&& "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic)
positiveButton.isEnabled = validTopic(topic)
}
}
}

View file

@ -0,0 +1,58 @@
package io.heckel.ntfy.ui
import android.text.Editable
import android.text.TextWatcher
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
fun initBaseUrlDropdown(baseUrls: List<String>, textView: AutoCompleteTextView, layout: TextInputLayout) {
// Base URL dropdown behavior; Oh my, why is this so complicated?!
val toggleEndIcon = {
if (textView.text.isNotEmpty()) {
layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
} else if (baseUrls.isEmpty()) {
layout.setEndIconDrawable(0)
} else {
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
}
}
layout.setEndIconOnClickListener {
if (textView.text.isNotEmpty()) {
textView.text.clear()
if (baseUrls.isEmpty()) {
layout.setEndIconDrawable(0)
} else {
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
}
} else if (textView.text.isEmpty() && baseUrls.isNotEmpty()) {
layout.setEndIconDrawable(R.drawable.ic_drop_up_gray_24dp)
textView.showDropDown()
}
}
textView.setOnDismissListener { toggleEndIcon() }
textView.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
toggleEndIcon()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// Nothing
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Nothing
}
})
val adapter = ArrayAdapter(textView.context, R.layout.fragment_add_dialog_dropdown_item, baseUrls)
textView.threshold = 1
textView.setAdapter(adapter)
if (baseUrls.count() == 1) {
layout.setEndIconDrawable(R.drawable.ic_cancel_gray_24dp)
textView.setText(baseUrls.first())
} else if (baseUrls.count() > 1) {
layout.setEndIconDrawable(R.drawable.ic_drop_down_gray_24dp)
} else {
layout.setEndIconDrawable(0)
}
}

View file

@ -10,12 +10,10 @@ import android.text.TextWatcher
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.msg.ApiService
@ -30,6 +28,9 @@ class ShareActivity : AppCompatActivity() {
// File to share
private var fileUri: Uri? = null
// List of base URLs used, excluding app_base_url
private lateinit var baseUrls: List<String>
// UI elements
private lateinit var menu: Menu
private lateinit var sendItem: MenuItem
@ -39,6 +40,9 @@ class ShareActivity : AppCompatActivity() {
private lateinit var contentFileIcon: ImageView
private lateinit var contentText: TextView
private lateinit var topicText: TextView
private lateinit var baseUrlLayout: TextInputLayout
private lateinit var baseUrlText: AutoCompleteTextView
private lateinit var useAnotherServerCheckbox: CheckBox
private lateinit var progress: ProgressBar
private lateinit var errorText: TextView
private lateinit var errorImage: ImageView
@ -48,7 +52,7 @@ class ShareActivity : AppCompatActivity() {
setContentView(R.layout.activity_share)
Log.init(this) // Init logs in all entry points
Log.d(TAG, "Create $this")
Log.d(TAG, "Create $this with intent $intent")
// Action bar
title = getString(R.string.share_title)
@ -63,6 +67,12 @@ class ShareActivity : AppCompatActivity() {
contentFileInfo = findViewById(R.id.share_content_file_info)
contentFileIcon = findViewById(R.id.share_content_file_icon)
topicText = findViewById(R.id.share_topic_text)
baseUrlLayout = findViewById(R.id.share_base_url_layout)
//baseUrlLayout.background = window.background
baseUrlLayout.makeEndIconSmaller(resources) // Hack!
baseUrlText = findViewById(R.id.share_base_url_text)
//baseUrlText.background = topicText.background
useAnotherServerCheckbox = findViewById(R.id.share_use_another_server_checkbox)
progress = findViewById(R.id.share_progress)
progress.visibility = View.GONE
errorText = findViewById(R.id.share_error_text)
@ -84,10 +94,31 @@ class ShareActivity : AppCompatActivity() {
contentText.addTextChangedListener(textWatcher)
topicText.addTextChangedListener(textWatcher)
// Add behavior to "use another" checkbox
useAnotherServerCheckbox.setOnCheckedChangeListener { _, isChecked ->
baseUrlLayout.visibility = if (isChecked) View.VISIBLE else View.GONE
validateInput()
}
// Add baseUrl auto-complete behavior
lifecycleScope.launch(Dispatchers.IO) {
val appBaseUrl = getString(R.string.app_base_url)
baseUrls = repository.getSubscriptions()
.groupBy { it.baseUrl }
.map { it.key }
.filterNot { it == appBaseUrl }
.sorted()
val activity = this@ShareActivity
activity.runOnUiThread {
initBaseUrlDropdown(baseUrls, baseUrlText, baseUrlLayout)
useAnotherServerCheckbox.isChecked = baseUrls.count() == 1
}
}
// Incoming intent
val intent = intent ?: return
if (intent.action != Intent.ACTION_SEND) return
if ("text/plain" == intent.type) {
if (intent.type == "text/plain") {
handleSendText(intent)
} else if (supportedImage(intent.type)) {
handleSendImage(intent)
@ -97,14 +128,19 @@ class ShareActivity : AppCompatActivity() {
}
private fun handleSendText(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text ->
contentText.text = text
show()
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "(no text)"
Log.d(TAG, "Shared content is text: $text")
contentText.text = text
show()
}
private fun handleSendImage(intent: Intent) {
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
Log.d(TAG, "Shared content is an image with URI $fileUri")
if (fileUri == null) {
Log.w(TAG, "Null URI is not allowed. Aborting.")
return
}
try {
val resolver = applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(fileUri!!)
@ -121,7 +157,12 @@ class ShareActivity : AppCompatActivity() {
}
private fun handleSendFile(intent: Intent) {
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri ?: return
fileUri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
Log.d(TAG, "Shared content is a file with URI $fileUri")
if (fileUri == null) {
Log.w(TAG, "Null URI is not allowed. Aborting.")
return
}
try {
val resolver = applicationContext.contentResolver
val info = fileStat(this, fileUri)
@ -130,7 +171,6 @@ class ShareActivity : AppCompatActivity() {
contentFileInfo.text = "${info.filename}\n${formatBytes(info.size)}"
contentFileIcon.setImageResource(mimeTypeToIconResource(mimeType))
show(file = true)
} catch (e: Exception) {
fileUri = null
contentText.text = ""
@ -170,7 +210,7 @@ class ShareActivity : AppCompatActivity() {
}
private fun onShareClick() {
val baseUrl = "https://ntfy.sh" // FIXME
val baseUrl = getBaseUrl()
val topic = topicText.text.toString()
val message = contentText.text.toString()
progress.visibility = View.VISIBLE
@ -226,8 +266,21 @@ class ShareActivity : AppCompatActivity() {
private fun validateInput() {
if (!this::sendItem.isInitialized) return // Initialized late in onCreateOptionsMenu
sendItem.isEnabled = contentText.text.isNotEmpty() && topicText.text.isNotEmpty()
sendItem.icon.alpha = if (sendItem.isEnabled) 255 else 130
val enabled = if (useAnotherServerCheckbox.isChecked) {
contentText.text.isNotEmpty() && validTopic(topicText.text.toString()) && validUrl(baseUrlText.text.toString())
} else {
contentText.text.isNotEmpty() && topicText.text.isNotEmpty()
}
sendItem.isEnabled = enabled
sendItem.icon.alpha = if (enabled) 255 else 130
}
private fun getBaseUrl(): String {
return if (useAnotherServerCheckbox.isChecked) {
baseUrlText.text.toString()
} else {
getString(R.string.app_base_url)
}
}
companion object {

View file

@ -6,11 +6,15 @@ import android.content.ContentResolver
import android.content.Context
import android.content.res.Configuration
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.content.res.Resources
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.OpenableColumns
import android.util.TypedValue
import android.view.View
import android.view.Window
import android.widget.ImageView
import androidx.appcompat.app.AppCompatDelegate
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Notification
@ -40,6 +44,14 @@ fun shortUrl(url: String) = url
.replace("http://", "")
.replace("https://", "")
fun validTopic(topic: String): Boolean {
return "[-_A-Za-z0-9]{1,64}".toRegex().matches(topic) // Must match server side!
}
fun validUrl(url: String): Boolean {
return "^https?://.+".toRegex().matches(url)
}
fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
@ -271,3 +283,12 @@ class ContentUriRequestBody(
}
}
}
// Hack: Make end icon for drop down smaller, see https://stackoverflow.com/a/57098715/1440785
fun View.makeEndIconSmaller(resources: Resources) {
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 30f, resources.displayMetrics)
val endIconImageView = findViewById<ImageView>(R.id.text_input_end_icon)
endIconImageView.minimumHeight = dimension.toInt()
endIconImageView.minimumWidth = dimension.toInt()
requestLayout()
}

View file

@ -21,9 +21,9 @@
android:paddingBottom="2dp"
android:text="@string/share_content_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
app:layout_constraintTop_toTopOf="parent" android:paddingStart="2dp"/>
<com.google.android.material.imageview.ShapeableImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content" app:srcCompat="@drawable/ic_cancel_gray_24dp"
@ -75,9 +75,9 @@
android:paddingBottom="3dp"
android:text="Share to topic"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/share_content_file_box" android:layout_marginTop="10dp"/>
app:layout_constraintTop_toBottomOf="@id/share_content_file_box" android:layout_marginTop="15dp" android:paddingStart="2dp"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/share_topic_text"
android:layout_width="match_parent"
@ -86,6 +86,44 @@
android:maxLines="1" android:inputType="text" android:maxLength="64"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/share_topic_title"/>
<CheckBox
android:text="@string/add_dialog_use_another_server"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/share_use_another_server_checkbox"
android:layout_marginStart="-3dp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/share_topic_text"/>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense.ExposedDropdownMenu"
android:id="@+id/share_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/share_use_another_server_checkbox">
<AutoCompleteTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/share_base_url_text"
android:hint="@string/app_base_url"
android:maxLines="1"
android:layout_marginTop="0dp"
android:layout_marginBottom="0dp"
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>
<TextView
android:text="Unable to resolve host example.com"
android:layout_width="0dp"
@ -95,7 +133,7 @@
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
app:layout_constraintStart_toEndOf="@id/share_error_image"
android:layout_marginTop="5dp" app:layout_constraintTop_toBottomOf="@+id/share_topic_text"/>
android:layout_marginTop="5dp" app:layout_constraintTop_toBottomOf="@id/share_base_url_layout"/>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp" app:srcCompat="@drawable/ic_error_red_24dp"