diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 15f76db7..4601798f 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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 { + val json = sharedPrefs.getString(SHARED_PREFS_CUSTOM_HEADERS, null) + return if (json != null) { + try { + val type = object : TypeToken>() {}.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) { + 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 diff --git a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt index 54c48ea9..bbcabf80 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/ApiService.kt @@ -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 } } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt index fb7f6d47..9ccabad2 100644 --- a/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt +++ b/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt @@ -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" } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/msg/CustomHeadersFragment.kt b/app/src/main/java/io/heckel/ntfy/msg/CustomHeadersFragment.kt new file mode 100644 index 00000000..3582d241 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/msg/CustomHeadersFragment.kt @@ -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("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() + 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(R.id.header_name) + val headerValueEdit = dialogView.findViewById(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(R.id.header_name) + val headerValueEdit = dialogView.findViewById(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" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt index 60fbb477..eedba576 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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() - 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!!) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 8e56bab9..944c9478 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -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 } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 851f43ac..bf27fcb9 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 1c4b0f52..06e63baf 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt index 984f6afe..dc219ed1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index 582d0714..cc5cb6e1 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -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" } -} +} \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_custom_header.xml b/app/src/main/res/layout/dialog_custom_header.xml new file mode 100644 index 00000000..92e06328 --- /dev/null +++ b/app/src/main/res/layout/dialog_custom_header.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aceed750..177194c1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -350,4 +350,20 @@ Genaue Alarme ntfy kann genaue Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Hier tippen, um die Berechtigung zu widerrufen. ntfy kann keine genauen Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Tippe hier, um die Berechtigung zu erteilen. + BenutzerdefinierteHeader + Benutzerdefinierte Header + Benutzerdefinierte HTTP-Header zu allen Anfragen hinzufügen + Header hinzufügen + Neuen benutzerdefinierten HTTP-Header hinzufügen + Neuen Header hinzufügen + Header bearbeiten + Header löschen + Sind Sie sicher, dass Sie den Header „%1$s“ löschen möchten? + Hinzufügen + Speichern + Löschen + Ungültiger Header + Ungültige Zeichen im Header-Namen + Header-Name (z. B. CF-Access-Client-Id) + Header-Wert diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49f9082b..19d10943 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -356,6 +356,9 @@ Exact alarms ntfy can schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to revoke the permission. ntfy cannot schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to grant the permission. + CustomHeaders + Custom Headers + Add custom HTTP headers to all requests About Version ntfy %1$s (%2$s) @@ -400,4 +403,19 @@ Cancel Delete user Save + + + Add Header + Add a new custom HTTP header + Add Custom Header + Edit Custom Header + Delete Header + Are you sure you want to delete the header "%1$s"? + Add + Save + Delete + Invalid Header + Header name contains invalid characters + Header name (e.g., CF-Access-Client-Id) + Header value diff --git a/app/src/main/res/xml/custom_headers_preferences.xml b/app/src/main/res/xml/custom_headers_preferences.xml new file mode 100644 index 00000000..08f981ec --- /dev/null +++ b/app/src/main/res/xml/custom_headers_preferences.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index 0ad3cec1..9e766b6d 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -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"/> +