Default server dialog

This commit is contained in:
Philipp Heckel 2026-02-01 21:55:40 -05:00
parent 0b5a533191
commit b12f91bfbe
8 changed files with 325 additions and 23 deletions

View file

@ -0,0 +1,163 @@
package io.heckel.ntfy.ui
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.fragment.app.DialogFragment
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.util.AfterChangedTextWatcher
import io.heckel.ntfy.util.normalizeBaseUrl
import io.heckel.ntfy.util.validBaseUrl
class DefaultServerFragment : DialogFragment() {
private var currentUrl: String? = null
private lateinit var listener: DefaultServerDialogListener
private lateinit var toolbar: MaterialToolbar
private lateinit var saveMenuItem: MenuItem
private lateinit var resetMenuItem: MenuItem
private lateinit var urlViewLayout: TextInputLayout
private lateinit var urlView: TextInputEditText
interface DefaultServerDialogListener {
fun onDefaultServerUpdated(dialog: DialogFragment, url: String)
}
override fun onAttach(context: Context) {
super.onAttach(context)
listener = activity as DefaultServerDialogListener
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (activity == null) {
throw IllegalStateException("Activity cannot be null")
}
// Get current URL from arguments
currentUrl = arguments?.getString(BUNDLE_CURRENT_URL)
// Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_default_server_dialog, null)
// Setup toolbar
toolbar = view.findViewById(R.id.default_server_dialog_toolbar)
toolbar.setNavigationOnClickListener {
dismiss()
}
toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.default_server_dialog_action_save -> {
saveClicked()
true
}
R.id.default_server_dialog_action_reset -> {
resetClicked()
true
}
else -> false
}
}
saveMenuItem = toolbar.menu.findItem(R.id.default_server_dialog_action_save)
resetMenuItem = toolbar.menu.findItem(R.id.default_server_dialog_action_reset)
// Setup views
urlViewLayout = view.findViewById(R.id.default_server_dialog_url_layout)
urlView = view.findViewById(R.id.default_server_dialog_url)
// Set current URL
urlView.setText(currentUrl ?: "")
// Show reset option if there's a current URL
resetMenuItem.isVisible = !currentUrl.isNullOrEmpty()
// Validate input when typing
urlView.addTextChangedListener(AfterChangedTextWatcher {
validateInput()
})
// Build dialog
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
dialog.setContentView(view)
// Initial validation
validateInput()
return dialog
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
override fun onResume() {
super.onResume()
// Show keyboard after the dialog is fully visible
urlView.postDelayed({
urlView.requestFocus()
urlView.text?.let { text ->
urlView.setSelection(text.length)
}
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(urlView, InputMethodManager.SHOW_IMPLICIT)
}, 200)
}
private fun saveClicked() {
if (!this::listener.isInitialized) return
val url = urlView.text?.toString() ?: ""
val normalizedUrl = if (url.isEmpty()) "" else normalizeBaseUrl(url)
listener.onDefaultServerUpdated(this, normalizedUrl)
dismiss()
}
private fun resetClicked() {
if (!this::listener.isInitialized) return
listener.onDefaultServerUpdated(this, "")
dismiss()
}
private fun validateInput() {
if (!this::saveMenuItem.isInitialized) return
val url = urlView.text?.toString() ?: ""
// Clear previous errors
urlViewLayout.error = null
if (url.isEmpty()) {
// Empty is allowed (means use default)
saveMenuItem.isEnabled = true
} else if (!validBaseUrl(url)) {
// Show error for invalid URL
urlViewLayout.error = getString(R.string.default_server_dialog_url_error_invalid)
saveMenuItem.isEnabled = false
} else {
saveMenuItem.isEnabled = true
}
}
companion object {
const val TAG = "NtfyDefaultServerFragment"
private const val BUNDLE_CURRENT_URL = "currentUrl"
fun newInstance(currentUrl: String?): DefaultServerFragment {
val fragment = DefaultServerFragment()
val args = Bundle()
args.putString(BUNDLE_CURRENT_URL, currentUrl ?: "")
fragment.arguments = args
return fragment
}
}
}

View file

@ -51,7 +51,8 @@ import java.util.concurrent.TimeUnit
* https://github.com/googlearchive/android-preferences/blob/master/app/src/main/java/com/example/androidx/preference/sample/MainActivity.kt
*/
class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
UserFragment.UserDialogListener, CustomHeaderFragment.CustomHeaderDialogListener {
UserFragment.UserDialogListener, CustomHeaderFragment.CustomHeaderDialogListener,
DefaultServerFragment.DefaultServerDialogListener {
private lateinit var settingsFragment: SettingsFragment
private lateinit var userSettingsFragment: UserSettingsFragment
private lateinit var customHeaderSettingsFragment: CustomHeaderSettingsFragment
@ -483,31 +484,22 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
// Default Base URL
val appBaseUrl = getString(R.string.app_base_url)
val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return
val defaultBaseUrl: EditTextPreference? = findPreference(defaultBaseUrlPrefId)
defaultBaseUrl?.text = repository.getDefaultBaseUrl() ?: ""
defaultBaseUrl?.extras?.putString("message", getString(R.string.settings_general_default_base_url_message))
defaultBaseUrl?.extras?.putString("hint", getString(R.string.app_base_url))
defaultBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
val baseUrl = value ?: return
repository.setDefaultBaseUrl(baseUrl)
}
override fun getString(key: String, defValue: String?): String? {
return repository.getDefaultBaseUrl()
val defaultBaseUrl: Preference? = findPreference(defaultBaseUrlPrefId)
defaultBaseUrl?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
defaultBaseUrl?.onPreferenceClickListener = OnPreferenceClickListener {
activity?.let { activity ->
DefaultServerFragment
.newInstance(repository.getDefaultBaseUrl())
.show(activity.supportFragmentManager, DefaultServerFragment.TAG)
}
true
}
defaultBaseUrl?.setOnBindEditTextListener { editText ->
editText.addTextChangedListener(AfterChangedTextWatcher {
val okayButton: Button = editText.rootView.findViewById(android.R.id.button1)
val value = editText.text.toString()
okayButton.isEnabled = value.isEmpty() || validUrl(value)
})
}
defaultBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
if (TextUtils.isEmpty(pref.text)) {
defaultBaseUrl?.summaryProvider = Preference.SummaryProvider<Preference> { _ ->
val currentUrl = repository.getDefaultBaseUrl()
if (currentUrl.isNullOrEmpty()) {
getString(R.string.settings_general_default_base_url_default_summary, appBaseUrl)
} else {
pref.text
currentUrl
}
}
@ -736,6 +728,21 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
repository.setAutoDownloadMaxSize(autoDownloadSelectionCopy)
}
fun refreshDefaultServerSummary() {
val appBaseUrl = getString(R.string.app_base_url)
val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return
val defaultBaseUrl: Preference? = findPreference(defaultBaseUrlPrefId)
// Re-set the summary provider to trigger a refresh
defaultBaseUrl?.summaryProvider = Preference.SummaryProvider<Preference> { _ ->
val currentUrl = repository.getDefaultBaseUrl()
if (currentUrl.isNullOrEmpty()) {
getString(R.string.settings_general_default_base_url_default_summary, appBaseUrl)
} else {
currentUrl
}
}
}
private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
val context = context ?: return@launch
@ -1091,6 +1098,13 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
override fun onDefaultServerUpdated(dialog: DialogFragment, url: String) {
repository.setDefaultBaseUrl(url)
if (this::settingsFragment.isInitialized) {
settingsFragment.refreshDefaultServerSummary()
}
}
private fun setAutoDownload() {
if (!this::settingsFragment.isInitialized) return
settingsFragment.setAutoDownload()

View file

@ -106,6 +106,29 @@ fun validUrl(url: String): Boolean {
return "^https?://\\S+".toRegex().matches(url)
}
/**
* Validates that a URL is a valid base URL for ntfy servers.
* Only allows URLs that:
* - Start with http:// or https://
* - Do NOT have any path fragment (e.g., "https://ntfy.example.com" is allowed,
* but "https://ntfy.example.com/subpath" is not)
*/
fun validBaseUrl(url: String): Boolean {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return false
}
val httpUrl = url.toHttpUrlOrNull() ?: return false
val hasPath = httpUrl.pathSegments.any { it.isNotEmpty() } // No path (pathSegments will be [""] for "https://example.com/")
return !hasPath
}
/**
* Normalizes a base URL by removing the trailing slash if present.
*/
fun normalizeBaseUrl(url: String): String {
return url.trimEnd('/')
}
fun formatDateShort(timestampSecs: Long): String {
val date = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/default_server_dialog_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/default_server_dialog_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
android:paddingStart="0dp"
android:paddingEnd="12dp"
app:navigationIcon="@drawable/ic_close_white_24dp"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/default_server_dialog_title"
app:titleTextColor="?attr/colorOnSurface"
app:menu="@menu/menu_default_server_dialog" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/default_server_dialog_description"
android:text="@string/default_server_dialog_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/default_server_dialog_url_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/default_server_dialog_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="10dp"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/default_server_dialog_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/common_service_url"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="textUri"
app:placeholderText="@string/common_service_url_placeholder"/>
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/default_server_dialog_action_save"
android:title="@string/common_button_save"
android:enabled="false"
app:showAsAction="always" />
<item
android:id="@+id/default_server_dialog_action_reset"
android:title="@string/common_button_reset_to_default"
android:visible="false"
app:showAsAction="never" />
</menu>

View file

@ -7,6 +7,7 @@
<string name="common_button_save">Save</string>
<string name="common_button_delete">Delete</string>
<string name="common_button_copy">Copy</string>
<string name="common_button_reset_to_default">Reset to default</string>
<string name="common_copied_to_clipboard">Copied to clipboard</string>
<string name="common_service_url">Service URL</string>
<string name="common_service_url_placeholder">e.g. https://ntfy.example.com</string>
@ -503,6 +504,11 @@
<string name="user_dialog_button_cancel">Cancel</string>
<string name="user_dialog_button_delete">Delete user</string>
<!-- Default server dialog (DefaultServerFragment) -->
<string name="default_server_dialog_title">Default server</string>
<string name="default_server_dialog_description">Enter your server\'s root URL to use your own server as a default when subscribing to new topics and/or sharing to topics.</string>
<string name="default_server_dialog_url_error_invalid">Enter a valid service URL, e.g. https://ntfy.example.com</string>
<!-- Custom headers dialog (CustomHeaderFragment) -->
<string name="custom_headers_dialog_title_add">Add custom header</string>
<string name="custom_headers_dialog_title_edit">Edit custom header</string>

View file

@ -35,7 +35,7 @@
app:summary="@string/settings_notifications_channel_prefs_summary"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_general_header">
<EditTextPreference
<Preference
app:key="@string/settings_general_default_base_url_key"
app:title="@string/settings_general_default_base_url_title" />
<Preference

View file

@ -1,3 +1,4 @@
Features:
* Search within a topic (#141, ntfy-android#153, thanks to @Copephobia and @StoyanYonkov for reporting and sponsoring)
* Add "reconnecting to N topics ..." to foreground notification (#1101, thanks to @milosivanovic for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation