add: custom headers per server
This commit is contained in:
parent
392ba7ec10
commit
1dfc8ea663
13 changed files with 492 additions and 266 deletions
10
app/src/main/java/io/heckel/ntfy/db/CustomHeader.kt
Normal file
10
app/src/main/java/io/heckel/ntfy/db/CustomHeader.kt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package io.heckel.ntfy.db
|
||||
|
||||
/**
|
||||
* Represents a custom HTTP header for a specific server
|
||||
*/
|
||||
data class CustomHeader(
|
||||
val baseUrl: String,
|
||||
val name: String,
|
||||
val value: String
|
||||
)
|
||||
|
|
@ -483,22 +483,71 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
}
|
||||
}
|
||||
|
||||
fun getCustomHeaders(): Map<String, String> {
|
||||
/**
|
||||
* Get all custom headers as a list of CustomHeader objects
|
||||
*/
|
||||
fun getCustomHeaders(): List<CustomHeader> {
|
||||
val json = sharedPrefs.getString(SHARED_PREFS_CUSTOM_HEADERS, null)
|
||||
return if (json != null) {
|
||||
try {
|
||||
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||
Gson().fromJson(json, type) ?: emptyMap()
|
||||
val type = object : TypeToken<List<CustomHeader>>() {}.type
|
||||
Gson().fromJson(json, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Repository", "Failed to parse custom headers", e)
|
||||
emptyMap()
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomHeaders(headers: Map<String, String>) {
|
||||
/**
|
||||
* Get custom headers for a specific server URL
|
||||
*/
|
||||
fun getCustomHeadersForServer(baseUrl: String): List<CustomHeader> {
|
||||
return getCustomHeaders().filter { it.baseUrl == baseUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new custom header
|
||||
*/
|
||||
fun addCustomHeader(header: CustomHeader) {
|
||||
val currentHeaders = getCustomHeaders().toMutableList()
|
||||
currentHeaders.add(header)
|
||||
saveCustomHeaders(currentHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom header
|
||||
*/
|
||||
fun updateCustomHeader(oldHeader: CustomHeader, newHeader: CustomHeader) {
|
||||
val currentHeaders = getCustomHeaders().toMutableList()
|
||||
val index = currentHeaders.indexOfFirst {
|
||||
it.baseUrl == oldHeader.baseUrl &&
|
||||
it.name == oldHeader.name
|
||||
}
|
||||
if (index >= 0) {
|
||||
currentHeaders[index] = newHeader
|
||||
saveCustomHeaders(currentHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom header
|
||||
*/
|
||||
fun deleteCustomHeader(header: CustomHeader) {
|
||||
val currentHeaders = getCustomHeaders().toMutableList()
|
||||
currentHeaders.removeAll {
|
||||
it.baseUrl == header.baseUrl &&
|
||||
it.name == header.name
|
||||
}
|
||||
saveCustomHeaders(currentHeaders)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the list of custom headers to SharedPreferences
|
||||
*/
|
||||
private fun saveCustomHeaders(headers: List<CustomHeader>) {
|
||||
val json = if (headers.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -173,22 +173,33 @@ class ApiService(private val context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Interceptor that adds custom headers to all HTTP requests
|
||||
* Interceptor that adds custom headers to HTTP requests based on the target server
|
||||
*/
|
||||
class CustomHeadersInterceptor(private val repository: Repository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val customHeaders = repository.getCustomHeaders()
|
||||
val requestUrl = originalRequest.url.toString()
|
||||
|
||||
// If no custom headers, proceed with original request
|
||||
// Extract base URL from the request (protocol + host + port)
|
||||
val baseUrl = "${originalRequest.url.scheme}://${originalRequest.url.host}" +
|
||||
if (originalRequest.url.port != 80 && originalRequest.url.port != 443) {
|
||||
":${originalRequest.url.port}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Get custom headers for this specific server
|
||||
val customHeaders = repository.getCustomHeadersForServer(baseUrl)
|
||||
|
||||
// If no custom headers for this server, proceed with original request
|
||||
if (customHeaders.isEmpty()) {
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
// Add custom headers to the request
|
||||
val requestBuilder = originalRequest.newBuilder()
|
||||
customHeaders.forEach { (name, value) ->
|
||||
requestBuilder.addHeader(name, value)
|
||||
customHeaders.forEach { header ->
|
||||
requestBuilder.addHeader(header.name, header.value)
|
||||
}
|
||||
|
||||
return chain.proceed(requestBuilder.build())
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.util.Log
|
||||
|
||||
class CustomHeadersFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var repository: Repository
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.custom_headers_preferences, rootKey)
|
||||
repository = Repository.getInstance(requireContext())
|
||||
|
||||
setupAddHeaderPreference()
|
||||
loadCustomHeaders()
|
||||
}
|
||||
|
||||
private fun setupAddHeaderPreference() {
|
||||
findPreference<Preference>("add_custom_header")?.setOnPreferenceClickListener {
|
||||
showAddHeaderDialog()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCustomHeaders() {
|
||||
val customHeaders = repository.getCustomHeaders()
|
||||
val preferenceScreen = preferenceScreen
|
||||
|
||||
// Remove existing header preferences (keep only the "Add Header" preference)
|
||||
val preferencesToRemove = mutableListOf<Preference>()
|
||||
for (i in 0 until preferenceScreen.preferenceCount) {
|
||||
val preference = preferenceScreen.getPreference(i)
|
||||
if (preference.key != "add_custom_header") {
|
||||
preferencesToRemove.add(preference)
|
||||
}
|
||||
}
|
||||
preferencesToRemove.forEach { preferenceScreen.removePreference(it) }
|
||||
|
||||
// Add preferences for each custom header
|
||||
customHeaders.forEach { (name, value) ->
|
||||
val headerPreference = Preference(requireContext()).apply {
|
||||
key = "header_$name"
|
||||
title = name
|
||||
summary = redactHeaderValue(value) // Show redacted value
|
||||
setOnPreferenceClickListener {
|
||||
showEditHeaderDialog(name, value)
|
||||
true
|
||||
}
|
||||
}
|
||||
preferenceScreen.addPreference(headerPreference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAddHeaderDialog() {
|
||||
val dialogView = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.dialog_custom_header, null)
|
||||
|
||||
val headerNameEdit = dialogView.findViewById<TextInputEditText>(R.id.header_name)
|
||||
val headerValueEdit = dialogView.findViewById<TextInputEditText>(R.id.header_value)
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.custom_headers_add_title)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton(R.string.custom_headers_add) { _, _ ->
|
||||
val name = headerNameEdit.text.toString().trim()
|
||||
val value = headerValueEdit.text.toString().trim()
|
||||
|
||||
if (validateHeaderName(name)) {
|
||||
addCustomHeader(name, value)
|
||||
} else {
|
||||
showInvalidHeaderDialog()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.user_dialog_button_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showEditHeaderDialog(originalName: String, originalValue: String) {
|
||||
val dialogView = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.dialog_custom_header, null)
|
||||
|
||||
val headerNameEdit = dialogView.findViewById<TextInputEditText>(R.id.header_name)
|
||||
val headerValueEdit = dialogView.findViewById<TextInputEditText>(R.id.header_value)
|
||||
|
||||
headerNameEdit.setText(originalName)
|
||||
headerValueEdit.setText(originalValue)
|
||||
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.custom_headers_edit_title)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton(R.string.custom_headers_save) { _, _ ->
|
||||
val name = headerNameEdit.text.toString().trim()
|
||||
val value = headerValueEdit.text.toString().trim()
|
||||
|
||||
if (validateHeaderName(name)) {
|
||||
updateCustomHeader(originalName, name, value)
|
||||
} else {
|
||||
showInvalidHeaderDialog()
|
||||
}
|
||||
}
|
||||
.setNeutralButton(R.string.custom_headers_delete) { _, _ ->
|
||||
showDeleteHeaderDialog(originalName)
|
||||
}
|
||||
.setNegativeButton(R.string.user_dialog_button_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showDeleteHeaderDialog(headerName: String) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.custom_headers_delete_title)
|
||||
.setMessage(getString(R.string.custom_headers_delete_message, headerName))
|
||||
.setPositiveButton(R.string.custom_headers_delete) { _, _ ->
|
||||
deleteCustomHeader(headerName)
|
||||
}
|
||||
.setNegativeButton(R.string.user_dialog_button_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showInvalidHeaderDialog() {
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.custom_headers_error_title)
|
||||
.setMessage(R.string.custom_headers_invalid_name)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun validateHeaderName(name: String): Boolean {
|
||||
if (name.isEmpty()) return false
|
||||
|
||||
// HTTP header names should only contain ASCII letters, digits, and hyphens
|
||||
// and must not start or end with hyphens
|
||||
val regex = Regex("^[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9]$|^[A-Za-z0-9]$")
|
||||
return regex.matches(name)
|
||||
}
|
||||
|
||||
private fun addCustomHeader(name: String, value: String) {
|
||||
try {
|
||||
val currentHeaders = repository.getCustomHeaders().toMutableMap()
|
||||
currentHeaders[name] = value
|
||||
repository.setCustomHeaders(currentHeaders)
|
||||
loadCustomHeaders() // Refresh the UI
|
||||
Log.d(TAG, "Added custom header: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to add custom header", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCustomHeader(originalName: String, newName: String, newValue: String) {
|
||||
try {
|
||||
val currentHeaders = repository.getCustomHeaders().toMutableMap()
|
||||
|
||||
// Remove the old header if name changed
|
||||
if (originalName != newName) {
|
||||
currentHeaders.remove(originalName)
|
||||
}
|
||||
|
||||
// Add/update the header
|
||||
currentHeaders[newName] = newValue
|
||||
|
||||
repository.setCustomHeaders(currentHeaders)
|
||||
loadCustomHeaders() // Refresh the UI
|
||||
Log.d(TAG, "Updated custom header: $originalName -> $newName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update custom header", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteCustomHeader(name: String) {
|
||||
try {
|
||||
val currentHeaders = repository.getCustomHeaders().toMutableMap()
|
||||
currentHeaders.remove(name)
|
||||
repository.setCustomHeaders(currentHeaders)
|
||||
loadCustomHeaders() // Refresh the UI
|
||||
Log.d(TAG, "Deleted custom header: $name")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete custom header", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun redactHeaderValue(value: String): String {
|
||||
return when {
|
||||
value.isEmpty() -> "(empty)"
|
||||
value.length <= 3 -> "•".repeat(value.length)
|
||||
else -> "•".repeat(8) // Always show 8 dots for longer values
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CustomHeadersFragment"
|
||||
}
|
||||
}
|
||||
199
app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt
Normal file
199
app/src/main/java/io/heckel/ntfy/ui/CustomHeaderFragment.kt
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package io.heckel.ntfy.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.db.CustomHeader
|
||||
import io.heckel.ntfy.util.AfterChangedTextWatcher
|
||||
import io.heckel.ntfy.util.dangerButton
|
||||
import io.heckel.ntfy.util.validUrl
|
||||
|
||||
class CustomHeaderFragment : DialogFragment() {
|
||||
private var header: CustomHeader? = null
|
||||
private lateinit var baseUrlsInUse: ArrayList<String>
|
||||
private lateinit var listener: CustomHeaderDialogListener
|
||||
|
||||
private lateinit var baseUrlView: TextInputEditText
|
||||
private lateinit var headerNameView: TextInputEditText
|
||||
private lateinit var headerValueView: TextInputEditText
|
||||
private lateinit var positiveButton: Button
|
||||
|
||||
interface CustomHeaderDialogListener {
|
||||
fun onAddCustomHeader(dialog: DialogFragment, header: CustomHeader)
|
||||
fun onUpdateCustomHeader(dialog: DialogFragment, oldHeader: CustomHeader, newHeader: CustomHeader)
|
||||
fun onDeleteCustomHeader(dialog: DialogFragment, header: CustomHeader)
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
listener = activity as CustomHeaderDialogListener
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
// Reconstruct header (if it is present in the bundle)
|
||||
val baseUrl = arguments?.getString(BUNDLE_BASE_URL)
|
||||
val headerName = arguments?.getString(BUNDLE_HEADER_NAME)
|
||||
val headerValue = arguments?.getString(BUNDLE_HEADER_VALUE)
|
||||
|
||||
if (baseUrl != null && headerName != null && headerValue != null) {
|
||||
header = CustomHeader(baseUrl, headerName, headerValue)
|
||||
}
|
||||
|
||||
// Required for validation
|
||||
baseUrlsInUse = arguments?.getStringArrayList(BUNDLE_BASE_URLS_IN_USE) ?: arrayListOf()
|
||||
|
||||
// Build root view
|
||||
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_custom_header_dialog, null)
|
||||
|
||||
val positiveButtonTextResId = if (header == null) R.string.custom_headers_add else R.string.custom_headers_save
|
||||
val titleView = view.findViewById(R.id.custom_header_dialog_title) as TextView
|
||||
val descriptionView = view.findViewById(R.id.custom_header_dialog_description) as TextView
|
||||
|
||||
baseUrlView = view.findViewById(R.id.custom_header_dialog_base_url)
|
||||
headerNameView = view.findViewById(R.id.custom_header_dialog_name)
|
||||
headerValueView = view.findViewById(R.id.custom_header_dialog_value)
|
||||
|
||||
if (header == null) {
|
||||
titleView.text = getString(R.string.custom_headers_add_title)
|
||||
descriptionView.text = getString(R.string.custom_header_dialog_description_add)
|
||||
baseUrlView.visibility = View.VISIBLE
|
||||
} else {
|
||||
titleView.text = getString(R.string.custom_headers_edit_title)
|
||||
descriptionView.text = getString(R.string.custom_header_dialog_description_edit)
|
||||
baseUrlView.visibility = View.GONE
|
||||
baseUrlView.setText(header!!.baseUrl)
|
||||
headerNameView.setText(header!!.name)
|
||||
headerValueView.setText(header!!.value)
|
||||
}
|
||||
|
||||
// Build dialog
|
||||
val builder = AlertDialog.Builder(activity)
|
||||
.setView(view)
|
||||
.setPositiveButton(positiveButtonTextResId) { _, _ ->
|
||||
saveClicked()
|
||||
}
|
||||
.setNegativeButton(R.string.user_dialog_button_cancel) { _, _ ->
|
||||
// Do nothing
|
||||
}
|
||||
if (header != null) {
|
||||
builder.setNeutralButton(R.string.custom_headers_delete) { _, _ ->
|
||||
if (this::listener.isInitialized) {
|
||||
listener.onDeleteCustomHeader(this, header!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.setOnShowListener {
|
||||
positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
|
||||
// Delete button should be red
|
||||
if (header != null) {
|
||||
dialog
|
||||
.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||
.dangerButton(requireContext())
|
||||
}
|
||||
|
||||
// Validate input when typing
|
||||
val textWatcher = AfterChangedTextWatcher {
|
||||
validateInput()
|
||||
}
|
||||
baseUrlView.addTextChangedListener(textWatcher)
|
||||
headerNameView.addTextChangedListener(textWatcher)
|
||||
headerValueView.addTextChangedListener(textWatcher)
|
||||
|
||||
// Focus
|
||||
if (header != null) {
|
||||
headerNameView.requestFocus()
|
||||
if (headerNameView.text != null) {
|
||||
headerNameView.setSelection(headerNameView.text!!.length)
|
||||
}
|
||||
} else {
|
||||
baseUrlView.requestFocus()
|
||||
}
|
||||
|
||||
// Validate now!
|
||||
validateInput()
|
||||
}
|
||||
|
||||
// Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun saveClicked() {
|
||||
if (!this::listener.isInitialized) return
|
||||
val baseUrl = baseUrlView.text?.toString() ?: ""
|
||||
val headerName = headerNameView.text?.toString()?.trim() ?: ""
|
||||
val headerValue = headerValueView.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (header == null) {
|
||||
val newHeader = CustomHeader(baseUrl, headerName, headerValue)
|
||||
listener.onAddCustomHeader(this, newHeader)
|
||||
} else {
|
||||
val newHeader = CustomHeader(
|
||||
if (baseUrl.isEmpty()) header!!.baseUrl else baseUrl,
|
||||
headerName,
|
||||
headerValue
|
||||
)
|
||||
listener.onUpdateCustomHeader(this, header!!, newHeader)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateInput() {
|
||||
val baseUrl = baseUrlView.text?.toString() ?: ""
|
||||
val headerName = headerNameView.text?.toString()?.trim() ?: ""
|
||||
val headerValue = headerValueView.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (header == null) {
|
||||
// New header: baseUrl, name, and value required
|
||||
positiveButton.isEnabled = validUrl(baseUrl)
|
||||
&& headerName.isNotEmpty()
|
||||
&& validateHeaderName(headerName)
|
||||
&& headerValue.isNotEmpty()
|
||||
} else {
|
||||
// Editing header: name and value required
|
||||
positiveButton.isEnabled = headerName.isNotEmpty()
|
||||
&& validateHeaderName(headerName)
|
||||
&& headerValue.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHeaderName(name: String): Boolean {
|
||||
if (name.isEmpty()) return false
|
||||
|
||||
// HTTP header names should only contain ASCII letters, digits, and hyphens
|
||||
// and must not start or end with hyphens
|
||||
val regex = Regex("^[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9]$|^[A-Za-z0-9]$")
|
||||
return regex.matches(name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NtfyCustomHeaderFragment"
|
||||
private const val BUNDLE_BASE_URL = "baseUrl"
|
||||
private const val BUNDLE_HEADER_NAME = "headerName"
|
||||
private const val BUNDLE_HEADER_VALUE = "headerValue"
|
||||
private const val BUNDLE_BASE_URLS_IN_USE = "baseUrlsInUse"
|
||||
|
||||
fun newInstance(header: CustomHeader?, baseUrlsInUse: List<String>): CustomHeaderFragment {
|
||||
val fragment = CustomHeaderFragment()
|
||||
val args = Bundle()
|
||||
args.putStringArrayList(BUNDLE_BASE_URLS_IN_USE, ArrayList(baseUrlsInUse))
|
||||
if (header != null) {
|
||||
args.putString(BUNDLE_BASE_URL, header.baseUrl)
|
||||
args.putString(BUNDLE_HEADER_NAME, header.name)
|
||||
args.putString(BUNDLE_HEADER_VALUE, header.value)
|
||||
}
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,9 +49,10 @@ 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 {
|
||||
UserFragment.UserDialogListener, CustomHeaderFragment.CustomHeaderDialogListener {
|
||||
private lateinit var settingsFragment: SettingsFragment
|
||||
private lateinit var userSettingsFragment: UserSettingsFragment
|
||||
private lateinit var customHeaderSettingsFragment: CustomHeaderSettingsFragment
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var serviceManager: SubscriberServiceManager
|
||||
|
|
@ -118,6 +119,10 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
if (fragment is UserSettingsFragment) {
|
||||
userSettingsFragment = fragment
|
||||
}
|
||||
// Save custom header settings fragment for later
|
||||
if (fragment is CustomHeaderSettingsFragment) {
|
||||
customHeaderSettingsFragment = fragment
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -795,6 +800,91 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
}
|
||||
}
|
||||
|
||||
class CustomHeaderSettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var repository: Repository
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.custom_header_preferences, rootKey)
|
||||
repository = Repository.getInstance(requireActivity())
|
||||
reload()
|
||||
}
|
||||
|
||||
data class CustomHeaderWithMetadata(
|
||||
val baseUrl: String,
|
||||
val headers: List<io.heckel.ntfy.db.CustomHeader>
|
||||
)
|
||||
|
||||
fun reload() {
|
||||
preferenceScreen.removeAll()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val headersByBaseUrl = repository.getCustomHeaders()
|
||||
.groupBy { it.baseUrl }
|
||||
.map { entry ->
|
||||
CustomHeaderWithMetadata(entry.key, entry.value)
|
||||
}
|
||||
.sortedBy { it.baseUrl }
|
||||
|
||||
activity?.runOnUiThread {
|
||||
addCustomHeaderPreferences(headersByBaseUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCustomHeaderPreferences(headersByBaseUrl: List<CustomHeaderWithMetadata>) {
|
||||
val baseUrlsInUse = headersByBaseUrl.map { it.baseUrl }
|
||||
|
||||
headersByBaseUrl.forEach { serverHeaders ->
|
||||
val baseUrl = serverHeaders.baseUrl
|
||||
val headers = serverHeaders.headers
|
||||
|
||||
val preferenceCategory = PreferenceCategory(preferenceScreen.context)
|
||||
preferenceCategory.title = shortUrl(baseUrl)
|
||||
preferenceScreen.addPreference(preferenceCategory)
|
||||
|
||||
headers.forEach { header ->
|
||||
val preference = Preference(preferenceScreen.context)
|
||||
preference.title = header.name
|
||||
preference.summary = redactHeaderValue(header.value)
|
||||
preference.onPreferenceClickListener = OnPreferenceClickListener { _ ->
|
||||
activity?.let {
|
||||
CustomHeaderFragment
|
||||
.newInstance(header, baseUrlsInUse)
|
||||
.show(it.supportFragmentManager, CustomHeaderFragment.TAG)
|
||||
}
|
||||
true
|
||||
}
|
||||
preferenceCategory.addPreference(preference)
|
||||
}
|
||||
}
|
||||
|
||||
// Add header
|
||||
val headerAddCategory = PreferenceCategory(preferenceScreen.context)
|
||||
headerAddCategory.title = getString(R.string.settings_general_custom_headers_prefs_header_add)
|
||||
preferenceScreen.addPreference(headerAddCategory)
|
||||
|
||||
val headerAddPref = Preference(preferenceScreen.context)
|
||||
headerAddPref.title = getString(R.string.settings_general_custom_headers_prefs_header_add_title)
|
||||
headerAddPref.summary = getString(R.string.settings_general_custom_headers_prefs_header_add_summary)
|
||||
headerAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ ->
|
||||
activity?.let {
|
||||
CustomHeaderFragment
|
||||
.newInstance(header = null, baseUrlsInUse = baseUrlsInUse)
|
||||
.show(it.supportFragmentManager, CustomHeaderFragment.TAG)
|
||||
}
|
||||
true
|
||||
}
|
||||
headerAddCategory.addPreference(headerAddPref)
|
||||
}
|
||||
|
||||
private fun redactHeaderValue(value: String): String {
|
||||
return when {
|
||||
value.isEmpty() -> "(empty)"
|
||||
value.length <= 3 -> "•".repeat(value.length)
|
||||
else -> "•".repeat(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD) {
|
||||
|
|
@ -833,6 +923,36 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
|
|||
}
|
||||
}
|
||||
|
||||
override fun onAddCustomHeader(dialog: DialogFragment, header: io.heckel.ntfy.db.CustomHeader) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.addCustomHeader(header)
|
||||
serviceManager.restart() // Restart to apply new headers
|
||||
runOnUiThread {
|
||||
customHeaderSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateCustomHeader(dialog: DialogFragment, oldHeader: io.heckel.ntfy.db.CustomHeader, newHeader: io.heckel.ntfy.db.CustomHeader) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.updateCustomHeader(oldHeader, newHeader)
|
||||
serviceManager.restart() // Restart to apply header changes
|
||||
runOnUiThread {
|
||||
customHeaderSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeleteCustomHeader(dialog: DialogFragment, header: io.heckel.ntfy.db.CustomHeader) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
repository.deleteCustomHeader(header)
|
||||
serviceManager.restart()
|
||||
runOnUiThread {
|
||||
customHeaderSettingsFragment.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAutoDownload() {
|
||||
if (!this::settingsFragment.isInitialized) return
|
||||
settingsFragment.setAutoDownload()
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/custom_headers_name_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/header_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/custom_headers_value_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/header_value"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
55
app/src/main/res/layout/fragment_custom_header_dialog.xml
Normal file
55
app/src/main/res/layout/fragment_custom_header_dialog.xml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:visibility="visible" android:paddingBottom="10dp">
|
||||
<TextView
|
||||
android:text="@string/custom_header_dialog_description_add"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:id="@+id/custom_header_dialog_description"
|
||||
android:paddingStart="4dp" android:paddingTop="3dp" app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/custom_header_dialog_title"/>
|
||||
<TextView
|
||||
android:id="@+id/custom_header_dialog_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:text="@string/custom_headers_add_title"
|
||||
android:textAlignment="viewStart"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/custom_header_dialog_base_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:hint="@string/custom_header_dialog_base_url_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:maxLines="1" android:inputType="text"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="6dp" app:layout_constraintTop_toBottomOf="@id/custom_header_dialog_description"/>
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/custom_header_dialog_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:hint="@string/custom_headers_name_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:maxLines="1" android:inputType="text"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="6dp" app:layout_constraintTop_toBottomOf="@id/custom_header_dialog_base_url"/>
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/custom_header_dialog_value"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:hint="@string/custom_headers_value_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:maxLines="1" android:inputType="text"
|
||||
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginTop="6dp" app:layout_constraintTop_toBottomOf="@id/custom_header_dialog_name"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -350,15 +350,23 @@
|
|||
<string name="settings_advanced_exact_alarms_title">Genaue Alarme</string>
|
||||
<string name="settings_advanced_exact_alarms_true">ntfy kann genaue Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Hier tippen, um die Berechtigung zu widerrufen.</string>
|
||||
<string name="settings_advanced_exact_alarms_false">ntfy kann keine genauen Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Tippe hier, um die Berechtigung zu erteilen.</string>
|
||||
<string name="settings_advanced_custom_headers_key">BenutzerdefinierteHeader</string>
|
||||
<string name="settings_advanced_custom_headers_title">Benutzerdefinierte Header</string>
|
||||
<string name="settings_advanced_custom_headers_summary">Benutzerdefinierte HTTP-Header zu allen Anfragen hinzufügen</string>
|
||||
|
||||
<!-- Custom Headers settings -->
|
||||
<string name="settings_general_custom_headers_key">BenutzerdefinierteHeader</string>
|
||||
<string name="settings_general_custom_headers_title">Benutzerdefinierte Header</string>
|
||||
<string name="settings_general_custom_headers_summary">Benutzerdefinierte HTTP-Header pro Server hinzufügen</string>
|
||||
<string name="settings_general_custom_headers_prefs_title">Benutzerdefinierte Header</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add">Header hinzufügen</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add_title">Header für einen Server hinzufügen</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add_summary">Header werden mit jeder HTTP-Anfrage an diesen Server gesendet</string>
|
||||
|
||||
<!-- Custom Headers dialog -->
|
||||
<string name="custom_headers_add_header">Header hinzufügen</string>
|
||||
<string name="custom_headers_add_summary">Neuen benutzerdefinierten HTTP-Header hinzufügen</string>
|
||||
<string name="custom_headers_add_title">Neuen Header hinzufügen</string>
|
||||
<string name="custom_headers_edit_title">Header bearbeiten</string>
|
||||
<string name="custom_headers_delete_title">Header löschen</string>
|
||||
<string name="custom_headers_delete_message">Sind Sie sicher, dass Sie den Header „%1$s“ löschen möchten?</string>
|
||||
<string name="custom_headers_delete_message">Sind Sie sicher, dass Sie den Header „%1$s" löschen möchten?</string>
|
||||
<string name="custom_headers_add">Hinzufügen</string>
|
||||
<string name="custom_headers_save">Speichern</string>
|
||||
<string name="custom_headers_delete">Löschen</string>
|
||||
|
|
@ -366,4 +374,7 @@
|
|||
<string name="custom_headers_invalid_name">Ungültige Zeichen im Header-Namen</string>
|
||||
<string name="custom_headers_name_hint">Header-Name (z. B. CF-Access-Client-Id)</string>
|
||||
<string name="custom_headers_value_hint">Header-Wert</string>
|
||||
<string name="custom_header_dialog_description_add">Fügen Sie einen benutzerdefinierten HTTP-Header hinzu, der mit jeder Anfrage an den angegebenen Server gesendet wird.</string>
|
||||
<string name="custom_header_dialog_description_edit">Sie können den Header-Namen/Wert für den ausgewählten Header bearbeiten oder ihn löschen.</string>
|
||||
<string name="custom_header_dialog_base_url_hint">Dienst-URL</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -356,9 +356,6 @@
|
|||
<string name="settings_advanced_exact_alarms_title">Exact alarms</string>
|
||||
<string name="settings_advanced_exact_alarms_true">ntfy can schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to revoke the permission.</string>
|
||||
<string name="settings_advanced_exact_alarms_false">ntfy cannot schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to grant the permission.</string>
|
||||
<string name="settings_advanced_custom_headers_key">CustomHeaders</string>
|
||||
<string name="settings_advanced_custom_headers_title">Custom Headers</string>
|
||||
<string name="settings_advanced_custom_headers_summary">Add custom HTTP headers to all requests</string>
|
||||
<string name="settings_about_header">About</string>
|
||||
<string name="settings_about_version_title">Version</string>
|
||||
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
|
||||
|
|
@ -404,6 +401,15 @@
|
|||
<string name="user_dialog_button_delete">Delete user</string>
|
||||
<string name="user_dialog_button_save">Save</string>
|
||||
|
||||
<!-- Custom Headers settings -->
|
||||
<string name="settings_general_custom_headers_key">CustomHeaders</string>
|
||||
<string name="settings_general_custom_headers_title">Custom headers</string>
|
||||
<string name="settings_general_custom_headers_summary">Add custom HTTP headers per server</string>
|
||||
<string name="settings_general_custom_headers_prefs_title">Custom Headers</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add">Add header</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add_title">Add a header for a server</string>
|
||||
<string name="settings_general_custom_headers_prefs_header_add_summary">Headers are sent with every HTTP request to that server</string>
|
||||
|
||||
<!-- Custom Headers dialog -->
|
||||
<string name="custom_headers_add_header">Add Header</string>
|
||||
<string name="custom_headers_add_summary">Add a new custom HTTP header</string>
|
||||
|
|
@ -418,4 +424,7 @@
|
|||
<string name="custom_headers_invalid_name">Header name contains invalid characters</string>
|
||||
<string name="custom_headers_name_hint">Header name (e.g., CF-Access-Client-Id)</string>
|
||||
<string name="custom_headers_value_hint">Header value</string>
|
||||
<string name="custom_header_dialog_description_add">Add a custom HTTP header that will be sent with every request to the specified server.</string>
|
||||
<string name="custom_header_dialog_description_edit">You may edit the header name/value for the selected header, or delete it.</string>
|
||||
<string name="custom_header_dialog_base_url_hint">Service URL</string>
|
||||
</resources>
|
||||
|
|
|
|||
4
app/src/main/res/xml/custom_header_preferences.xml
Normal file
4
app/src/main/res/xml/custom_header_preferences.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
app:title="@string/settings_general_custom_headers_prefs_title">
|
||||
</PreferenceScreen>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<Preference
|
||||
app:key="add_custom_header"
|
||||
app:title="@string/custom_headers_add_header"
|
||||
app:summary="@string/custom_headers_add_summary" />
|
||||
</PreferenceScreen>
|
||||
|
|
@ -45,6 +45,11 @@
|
|||
app:title="@string/settings_general_users_title"
|
||||
app:summary="@string/settings_general_users_summary"
|
||||
app:fragment="io.heckel.ntfy.ui.SettingsActivity$UserSettingsFragment"/>
|
||||
<Preference
|
||||
app:key="@string/settings_general_custom_headers_key"
|
||||
app:title="@string/settings_general_custom_headers_title"
|
||||
app:summary="@string/settings_general_custom_headers_summary"
|
||||
app:fragment="io.heckel.ntfy.ui.SettingsActivity$CustomHeaderSettingsFragment"/>
|
||||
<ListPreference
|
||||
app:key="@string/settings_general_dark_mode_key"
|
||||
app:title="@string/settings_general_dark_mode_title"
|
||||
|
|
@ -98,11 +103,6 @@
|
|||
app:key="@string/settings_advanced_clear_logs_key"
|
||||
app:title="@string/settings_advanced_clear_logs_title"
|
||||
app:summary="@string/settings_advanced_clear_logs_summary"/>
|
||||
<Preference
|
||||
app:key="@string/settings_advanced_custom_headers_key"
|
||||
app:title="@string/settings_advanced_custom_headers_title"
|
||||
app:summary="@string/settings_advanced_custom_headers_summary"
|
||||
app:fragment="io.heckel.ntfy.ui.CustomHeadersFragment" />
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory app:title="@string/settings_about_header">
|
||||
<Preference
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue