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 4f60404..be11dd5 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -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 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(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) } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt b/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt new file mode 100644 index 0000000..d1fd23a --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/BaseUrl.kt @@ -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, 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) + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt index e11d151..8345b04 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt @@ -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 + // 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(Intent.EXTRA_STREAM) as? Uri ?: return + fileUri = intent.getParcelableExtra(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(Intent.EXTRA_STREAM) as? Uri ?: return + fileUri = intent.getParcelableExtra(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 { diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 635819b..f9308f8 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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(R.id.text_input_end_icon) + endIconImageView.minimumHeight = dimension.toInt() + endIconImageView.minimumWidth = dimension.toInt() + requestLayout() +} diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml index d7e7697..d11421f 100644 --- a/app/src/main/res/layout/activity_share.xml +++ b/app/src/main/res/layout/activity_share.xml @@ -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"/> + app:layout_constraintTop_toBottomOf="@id/share_content_file_box" android:layout_marginTop="15dp" android:paddingStart="2dp"/> + + + + + android:layout_marginTop="5dp" app:layout_constraintTop_toBottomOf="@id/share_base_url_layout"/>