diff --git a/app/src/main/java/io/heckel/ntfy/ui/DefaultServerFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/DefaultServerFragment.kt new file mode 100644 index 00000000..4e5048b2 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/DefaultServerFragment.kt @@ -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 + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index b7310f0c..97533ed8 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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 { pref -> - if (TextUtils.isEmpty(pref.text)) { + defaultBaseUrl?.summaryProvider = Preference.SummaryProvider { _ -> + 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 { _ -> + 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() 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 d1610772..e472fcef 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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) diff --git a/app/src/main/res/layout/fragment_default_server_dialog.xml b/app/src/main/res/layout/fragment_default_server_dialog.xml new file mode 100644 index 00000000..37966253 --- /dev/null +++ b/app/src/main/res/layout/fragment_default_server_dialog.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_default_server_dialog.xml b/app/src/main/res/menu/menu_default_server_dialog.xml new file mode 100644 index 00000000..d632cb81 --- /dev/null +++ b/app/src/main/res/menu/menu_default_server_dialog.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93fa01f6..cc0df167 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Save Delete Copy + Reset to default Copied to clipboard Service URL e.g. https://ntfy.example.com @@ -503,6 +504,11 @@ Cancel Delete user + + Default server + Enter your server\'s root URL to use your own server as a default when subscribing to new topics and/or sharing to topics. + Enter a valid service URL, e.g. https://ntfy.example.com + Add custom header Edit custom header diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index 7e4ec00e..1ef47d3e 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -35,7 +35,7 @@ app:summary="@string/settings_notifications_channel_prefs_summary"/> -