Default server dialog
This commit is contained in:
parent
0b5a533191
commit
b12f91bfbe
8 changed files with 325 additions and 23 deletions
163
app/src/main/java/io/heckel/ntfy/ui/DefaultServerFragment.kt
Normal file
163
app/src/main/java/io/heckel/ntfy/ui/DefaultServerFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
81
app/src/main/res/layout/fragment_default_server_dialog.xml
Normal file
81
app/src/main/res/layout/fragment_default_server_dialog.xml
Normal 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>
|
||||
14
app/src/main/res/menu/menu_default_server_dialog.xml
Normal file
14
app/src/main/res/menu/menu_default_server_dialog.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue