add: header-auth with UI
This commit is contained in:
parent
e3317dd592
commit
c1bb7b8971
15 changed files with 356 additions and 13 deletions
|
|
@ -11,6 +11,8 @@ import io.heckel.ntfy.util.Log
|
|||
import io.heckel.ntfy.util.validUrl
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
class Repository(private val sharedPrefs: SharedPreferences, private val database: Database) {
|
||||
private val subscriptionDao = database.subscriptionDao()
|
||||
|
|
@ -481,6 +483,35 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
}
|
||||
}
|
||||
|
||||
fun getCustomHeaders(): Map<String, String> {
|
||||
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()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Repository", "Failed to parse custom headers", e)
|
||||
emptyMap()
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomHeaders(headers: Map<String, String>) {
|
||||
val json = if (headers.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
Gson().toJson(headers)
|
||||
}
|
||||
|
||||
if (json == null) {
|
||||
sharedPrefs.edit().remove(SHARED_PREFS_CUSTOM_HEADERS).apply()
|
||||
} else {
|
||||
sharedPrefs.edit().putString(SHARED_PREFS_CUSTOM_HEADERS, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getState(subscriptionId: Long): ConnectionState {
|
||||
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
|
||||
}
|
||||
|
|
@ -506,6 +537,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
|
||||
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
|
||||
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
|
||||
const val SHARED_PREFS_CUSTOM_HEADERS = "CustomHeaders"
|
||||
|
||||
private const val LAST_TOPICS_COUNT = 3
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package io.heckel.ntfy.msg
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import io.heckel.ntfy.BuildConfig
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.db.User
|
||||
import io.heckel.ntfy.util.*
|
||||
import okhttp3.*
|
||||
|
|
@ -13,21 +15,26 @@ import java.nio.charset.StandardCharsets.UTF_8
|
|||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class ApiService {
|
||||
class ApiService(private val context: Context) {
|
||||
private val repository = Repository.getInstance(context)
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.addInterceptor(CustomHeadersInterceptor(repository))
|
||||
.build()
|
||||
private val publishClient = OkHttpClient.Builder()
|
||||
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.addInterceptor(CustomHeadersInterceptor(repository))
|
||||
.build()
|
||||
private val subscriberClient = OkHttpClient.Builder()
|
||||
.readTimeout(77, TimeUnit.SECONDS) // Assuming that keepalive messages are more frequent than this
|
||||
.addInterceptor(CustomHeadersInterceptor(repository))
|
||||
.build()
|
||||
private val parser = NotificationParser()
|
||||
|
||||
|
|
@ -165,6 +172,29 @@ class ApiService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interceptor that adds custom headers to all HTTP requests
|
||||
*/
|
||||
private class CustomHeadersInterceptor(private val repository: Repository) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val customHeaders = repository.getCustomHeaders()
|
||||
|
||||
// If no custom headers, 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)
|
||||
}
|
||||
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
|
||||
class UnauthorizedException(val user: User?) : Exception()
|
||||
class EntityTooLargeException : Exception()
|
||||
|
||||
|
|
@ -188,4 +218,4 @@ class ApiService {
|
|||
return builder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ class BroadcastService(private val ctx: Context) {
|
|||
}
|
||||
|
||||
private fun send(ctx: Context, intent: Intent) {
|
||||
val api = ApiService()
|
||||
val api = ApiService(ctx)
|
||||
val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
|
||||
val topic = getStringExtra(intent, "topic") ?: return
|
||||
val message = getStringExtra(intent, "message") ?: return
|
||||
|
|
@ -128,4 +128,4 @@ class BroadcastService(private val ctx: Context) {
|
|||
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
|
||||
private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
|
||||
}
|
||||
}
|
||||
}
|
||||
198
app/src/main/java/io/heckel/ntfy/msg/CustomHeadersFragment.kt
Normal file
198
app/src/main/java/io/heckel/ntfy/msg/CustomHeadersFragment.kt
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -64,7 +64,7 @@ class SubscriberService : Service() {
|
|||
private val repository by lazy { (application as Application).repository }
|
||||
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
|
||||
private val connections = ConcurrentHashMap<ConnectionId, Connection>()
|
||||
private val api = ApiService()
|
||||
private val api by lazy { ApiService(this) }
|
||||
private var notificationManager: NotificationManager? = null
|
||||
private var serviceNotification: Notification? = null
|
||||
private val refreshMutex = Mutex() // Ensure refreshConnections() is only run one at a time
|
||||
|
|
@ -377,4 +377,4 @@ class SubscriberService : Service() {
|
|||
return ServiceState.valueOf(value!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
class AddFragment : DialogFragment() {
|
||||
private val api = ApiService()
|
||||
private val api by lazy { ApiService(requireContext()) }
|
||||
|
||||
private lateinit var repository: Repository
|
||||
private lateinit var subscribeListener: SubscribeListener
|
||||
|
|
@ -435,4 +435,4 @@ class AddFragment : DialogFragment() {
|
|||
const val TAG = "NtfyAddFragment"
|
||||
private val DISALLOWED_TOPICS = listOf("docs", "static", "file") // If updated, also update in server
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
DetailViewModelFactory((application as Application).repository)
|
||||
}
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val api by lazy { ApiService(this) }
|
||||
private val messenger = FirebaseMessenger()
|
||||
private var notifier: NotificationService? = null // Context-dependent
|
||||
private var appBaseUrl: String? = null // Context-dependent
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
SubscriptionsViewModelFactory((application as Application).repository)
|
||||
}
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val api by lazy { ApiService(this) }
|
||||
private val messenger = FirebaseMessenger()
|
||||
|
||||
// UI elements
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import kotlinx.coroutines.launch
|
|||
|
||||
class ShareActivity : AppCompatActivity() {
|
||||
private val repository by lazy { (application as Application).repository }
|
||||
private val api = ApiService()
|
||||
private val api by lazy { ApiService(this) }
|
||||
|
||||
// File to share
|
||||
private var fileUri: Uri? = null
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||
Log.d(TAG, "Polling for new notifications")
|
||||
val repository = Repository.getInstance(applicationContext)
|
||||
val dispatcher = NotificationDispatcher(applicationContext, repository)
|
||||
val api = ApiService()
|
||||
val api = ApiService(applicationContext) // FIXED: Pass context parameter
|
||||
|
||||
val baseUrl = inputData.getString(INPUT_DATA_BASE_URL)
|
||||
val topic = inputData.getString(INPUT_DATA_TOPIC)
|
||||
|
|
@ -72,4 +72,4 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||
const val INPUT_DATA_BASE_URL = "baseUrl"
|
||||
const val INPUT_DATA_TOPIC = "topic"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/src/main/res/layout/dialog_custom_header.xml
Normal file
37
app/src/main/res/layout/dialog_custom_header.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?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>
|
||||
|
|
@ -350,4 +350,20 @@
|
|||
<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>
|
||||
<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_add">Hinzufügen</string>
|
||||
<string name="custom_headers_save">Speichern</string>
|
||||
<string name="custom_headers_delete">Löschen</string>
|
||||
<string name="custom_headers_error_title">Ungültiger Header</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -356,6 +356,9 @@
|
|||
<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>
|
||||
|
|
@ -400,4 +403,19 @@
|
|||
<string name="user_dialog_button_cancel">Cancel</string>
|
||||
<string name="user_dialog_button_delete">Delete user</string>
|
||||
<string name="user_dialog_button_save">Save</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>
|
||||
<string name="custom_headers_add_title">Add Custom Header</string>
|
||||
<string name="custom_headers_edit_title">Edit Custom Header</string>
|
||||
<string name="custom_headers_delete_title">Delete Header</string>
|
||||
<string name="custom_headers_delete_message">Are you sure you want to delete the header "%1$s"?</string>
|
||||
<string name="custom_headers_add">Add</string>
|
||||
<string name="custom_headers_save">Save</string>
|
||||
<string name="custom_headers_delete">Delete</string>
|
||||
<string name="custom_headers_error_title">Invalid Header</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
7
app/src/main/res/xml/custom_headers_preferences.xml
Normal file
7
app/src/main/res/xml/custom_headers_preferences.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?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>
|
||||
|
|
@ -98,6 +98,11 @@
|
|||
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