add: custom headers per server

This commit is contained in:
Tobias 2025-12-14 14:49:59 +01:00
parent 392ba7ec10
commit 1dfc8ea663
13 changed files with 492 additions and 266 deletions

View 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
)

View file

@ -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 {

View file

@ -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())

View file

@ -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"
}
}

View 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
}
}
}

View file

@ -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()

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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