Merge branch 'main' of github.com:binwiederhier/ntfy-android into mtls

This commit is contained in:
Philipp Heckel 2026-01-03 13:19:16 -05:00
commit 5c8cef27ae
8 changed files with 137 additions and 28 deletions

View file

@ -6,7 +6,14 @@ interface Connection {
fun since(): String?
}
/**
* Represents a unique connection identifier that changes every time a
* connection needs to be re-established.
*/
data class ConnectionId(
val baseUrl: String,
val topicsToSubscriptionIds: Map<String, Long>
val topicsToSubscriptionIds: Map<String, Long>,
val connectionProtocol: String,
val credentialsHash: Int, // Hash of "username:password" or 0 if no user
val headersHash: Int // Hash of sorted headers or 0 if none
)

View file

@ -206,9 +206,27 @@ class SubscriberService : Service() {
val instantSubscriptions = repository.getSubscriptions()
.filter { s -> s.instant }
val activeConnectionIds = connections.keys().toList().toSet()
val connectionProtocol = repository.getConnectionProtocol()
val desiredConnectionIds = instantSubscriptions // Set<ConnectionId>
.groupBy { s -> ConnectionId(s.baseUrl, emptyMap()) }
.map { entry -> entry.key.copy(topicsToSubscriptionIds = entry.value.associate { s -> s.topic to s.id }) }
.groupBy { s -> s.baseUrl }
.map { (baseUrl, subs) ->
// Create a unique connection ID for each base URL. Each change in the connection ID will
// trigger a new connection, and close existing connections. We want to make sure that when the
// connection protocol (JSON/WS), the user or the custom headers are updated, that we kill existing
// connections and start new ones.
val credentialsHash = repository.getUser(baseUrl)?.let { "${it.username}:${it.password}".hashCode() } ?: 0
val headersHash = repository.getCustomHeadersForServer(baseUrl)
.sortedBy { "${it.name}:${it.value}" }
.joinToString(",") { "${it.name}:${it.value}" }
.hashCode()
ConnectionId(
baseUrl = baseUrl,
topicsToSubscriptionIds = subs.associate { s -> s.topic to s.id },
connectionProtocol = connectionProtocol,
credentialsHash = credentialsHash,
headersHash = headersHash
)
}
.toSet()
val newConnectionIds = desiredConnectionIds.subtract(activeConnectionIds)
val obsoleteConnectionIds = activeConnectionIds.subtract(desiredConnectionIds)
@ -237,7 +255,7 @@ class SubscriberService : Service() {
val since = sinceByBaseUrl[connectionId.baseUrl] ?: "none"
val serviceActive = { isServiceStarted }
val user = repository.getUser(connectionId.baseUrl)
val connection = if (repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS) {
val connection = if (connectionId.connectionProtocol == Repository.CONNECTION_PROTOCOL_WS) {
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
WsConnection(this, connectionId, repository, user, since, ::onStateChanged, ::onNotificationReceived, alarmManager)
} else {

View file

@ -26,12 +26,6 @@ class SubscriberServiceManager(private val context: Context) {
workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races!
}
fun restart() {
Intent(context, SubscriberService::class.java).also { intent ->
context.stopService(intent) // Service will auto-restart
}
}
/**
* Starts or stops the foreground service by figuring out how many instant delivery subscriptions
* exist. If there's > 0, then we need a foreground service.

View file

@ -225,7 +225,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
// Update battery banner + WebSocket banner + websocket reconnect banner
showHideBatteryBanner(subscriptions)
showHideWebSocketBanner(subscriptions)
showHideWebSocketReconnectBanner(subscriptions)
showHideWebSocketReconnectBanner()
}
}
@ -304,13 +304,13 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
wsEnableButton.setOnClickListener {
repository.setConnectionProtocol(Repository.CONNECTION_PROTOCOL_WS)
SubscriberServiceManager(this).restart()
SubscriberServiceManager(this).refresh()
wsBanner.visibility = View.GONE
// Maybe show WebSocketReconnectBanner
viewModel.list().observe(this) {
it?.let { subscriptions ->
showHideWebSocketReconnectBanner(subscriptions)
showHideWebSocketReconnectBanner()
}
}
}
@ -404,15 +404,14 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
}
}
private fun showHideWebSocketReconnectBanner(subscriptions: List<Subscription>) {
private fun showHideWebSocketReconnectBanner() {
val wsReconnectBanner = findViewById<View>(R.id.main_banner_websocket_reconnect)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasSelfHostedSubscriptions = subscriptions.count { it.baseUrl != appBaseUrl } > 0
val usingWebSockets = repository.getConnectionProtocol() == Repository.CONNECTION_PROTOCOL_WS
val wsReconnectRemindTimeReached = repository.getWebSocketReconnectRemindTime() < System.currentTimeMillis()
val canScheduleExactAlarms = (getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
val showBanner = hasSelfHostedSubscriptions && wsReconnectRemindTimeReached && usingWebSockets && !canScheduleExactAlarms
Log.d(TAG, "hasSelfHostedSubscriptions: ${hasSelfHostedSubscriptions}, wsReconnectRemindTimeReached: ${wsReconnectRemindTimeReached}, usingWebSockets: ${usingWebSockets}, canScheduleExactAlarms: ${canScheduleExactAlarms}")
val showBanner = wsReconnectRemindTimeReached && usingWebSockets && !canScheduleExactAlarms
Log.d(TAG, "wsReconnectRemindTimeReached: ${wsReconnectRemindTimeReached}, usingWebSockets: ${usingWebSockets}, canScheduleExactAlarms: ${canScheduleExactAlarms}")
wsReconnectBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
} else {
wsReconnectBanner.visibility = View.GONE

View file

@ -700,7 +700,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
override fun putString(key: String?, value: String?) {
val proto = value ?: repository.getConnectionProtocol()
repository.setConnectionProtocol(proto)
restartService()
serviceManager.refresh() // Refresh to switch connections between WS and JSON stream
}
override fun getString(key: String?, defValue: String?): String {
return repository.getConnectionProtocol()
@ -737,10 +737,6 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
repository.setAutoDownloadMaxSize(autoDownloadSelectionCopy)
}
private fun restartService() {
serviceManager.restart() // Service will auto-restart
}
private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
val context = context ?: return@launch
@ -845,7 +841,8 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
fun updateExactAlarmsPref() {
val exactAlarmsPrefId = context?.getString(R.string.settings_advanced_exact_alarms_key) ?: return
val context = context ?: return
val exactAlarmsPrefId = context.getString(R.string.settings_advanced_exact_alarms_key)
val exactAlarmsPref: Preference? = findPreference(exactAlarmsPrefId)
val canScheduleExactAlarms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
(activity?.getSystemService(ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms()
@ -859,6 +856,14 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
true
}
// Android doesn't show "ntfy" in the "Alarms & reminders" list if battery optimizations are disabled.
//
// In fact, if the user has granted the battery optimization exemption (see battery banner in MainActivity),
// the alarm manager's canScheduleExactAlarms() method will return true.
//
// This is undocumented behavior. See https://github.com/binwiederhier/ntfy/issues/1456#issuecomment-3707174262
exactAlarmsPref?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& !isIgnoringBatteryOptimizations(context)
}
@Keep
@ -1047,7 +1052,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
override fun onUpdateUser(dialog: DialogFragment, user: User) {
lifecycleScope.launch(Dispatchers.IO) {
repository.updateUser(user)
serviceManager.restart() // Editing does not change the user ID
serviceManager.refresh()
runOnUiThread {
if (this@SettingsActivity::userSettingsFragment.isInitialized) {
userSettingsFragment.reload()
@ -1059,7 +1064,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
override fun onDeleteUser(dialog: DialogFragment, baseUrl: String) {
lifecycleScope.launch(Dispatchers.IO) {
repository.deleteUser(baseUrl)
serviceManager.restart()
serviceManager.refresh()
runOnUiThread {
if (this@SettingsActivity::userSettingsFragment.isInitialized) {
userSettingsFragment.reload()
@ -1071,7 +1076,7 @@ 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
serviceManager.refresh() // Refresh to apply new headers
runOnUiThread {
if (this@SettingsActivity::customHeaderSettingsFragment.isInitialized) {
customHeaderSettingsFragment.reload()
@ -1083,7 +1088,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
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
serviceManager.refresh() // Refresh to apply header changes
runOnUiThread {
if (this@SettingsActivity::customHeaderSettingsFragment.isInitialized) {
customHeaderSettingsFragment.reload()
@ -1095,7 +1100,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
override fun onDeleteCustomHeader(dialog: DialogFragment, header: io.heckel.ntfy.db.CustomHeader) {
lifecycleScope.launch(Dispatchers.IO) {
repository.deleteCustomHeader(header)
serviceManager.restart()
serviceManager.refresh()
runOnUiThread {
if (this@SettingsActivity::customHeaderSettingsFragment.isInitialized) {
customHeaderSettingsFragment.reload()

View file

@ -346,4 +346,63 @@
<string name="settings_advanced_exact_alarms_title">התראות מדויקות</string>
<string name="settings_advanced_exact_alarms_true">ntfy יכול לתזמן התראות מדויקות, התראות מדויקות משמשות כדי לחבר את ה־WebSockets מחדש ברקע. לחיצה כאן תשלול את ההרשאה.</string>
<string name="settings_advanced_exact_alarms_false">ntfy לא יכול לתזמן התראות מדויקות. התראות מדויקות נחוצות כדי לחבר את ה־WebSockets מחדש ברקע. לחיצה תעניק את ההרשאה.</string>
<string name="publish_dialog_title">פרסום אל %1$s</string>
<string name="publish_dialog_title_hint">כותרת</string>
<string name="publish_dialog_title_placeholder">למשל: מישהו בדלת</string>
<string name="publish_dialog_message_hint">הודעה</string>
<string name="publish_dialog_tags_hint">תגיות</string>
<string name="publish_dialog_tags_placeholder">למשל: אזהרה, גולגולת</string>
<string name="publish_dialog_priority_hint">עדיפות</string>
<string name="publish_dialog_button_publish">פרסום</string>
<string name="publish_dialog_error_sending">לא ניתן לפרסם הודעה: %1$s</string>
<string name="publish_dialog_error_server">לא ניתן לפרסם הודעה: %1$s (קוד %2$d)</string>
<string name="publish_dialog_message_published">ההודעה פורסמה</string>
<string name="publish_dialog_uploading">העלאה: %1$s (%2$s / %3$s)</string>
<string name="publish_dialog_upload_cancelled">ההעלאה בוטלה</string>
<string name="publish_dialog_chip_title">כותרת</string>
<string name="publish_dialog_chip_tags">תגיות</string>
<string name="publish_dialog_chip_priority">עדיפות</string>
<string name="publish_dialog_chip_click_url">לחיצה על כתובת</string>
<string name="publish_dialog_chip_email">דוא״ל</string>
<string name="publish_dialog_chip_delay">השהיה</string>
<string name="publish_dialog_chip_markdown">Markdown</string>
<string name="publish_dialog_chip_attach_url">צירוף לפי כתובת</string>
<string name="publish_dialog_chip_attach_file">צירוף קובץ מקומי</string>
<string name="publish_dialog_chip_phone_call">שיחת טלפון</string>
<string name="publish_dialog_click_url_hint">לחיצה על כתובת</string>
<string name="publish_dialog_click_url_placeholder">למשל: https://example.com/alerts/1234</string>
<string name="publish_dialog_email_hint">דוא״ל</string>
<string name="publish_dialog_email_placeholder">למשל: phil@example.com</string>
<string name="publish_dialog_delay_hint">השהיית מסירה</string>
<string name="publish_dialog_attach_url_placeholder">למשל: https://example.com/flowers.jpg</string>
<string name="publish_dialog_attach_filename_placeholder">למשל: lilies.jpg</string>
<string name="publish_dialog_phone_call_hint">שיחת טלפון</string>
<string name="publish_dialog_phone_call_placeholder">למשל: +1234567890</string>
<string name="message_bar_hint">נא למלא הודעה כאן</string>
<string name="message_bar_publish_button_description">פרסום הודעה</string>
<string name="message_bar_expand_button_description">אפשרויות נוספות</string>
<string name="detail_fab_publish_description">התראת פרסום</string>
<string name="settings_general_language_title">שפה</string>
<string name="settings_general_language_summary_system">להשתמש בברירת המחדל של המערכת</string>
<string name="settings_general_language_system_default">ברירת המחדל של המערכת</string>
<string name="settings_general_dynamic_colors_title">צבעים דינמיים</string>
<string name="custom_headers_dialog_button_delete">מחיקה</string>
<string name="custom_headers_dialog_button_save">שמירה</string>
<string name="custom_headers_dialog_button_add">הוספה</string>
<string name="custom_headers_dialog_error_duplicate">כבר יש כותרת בשם הזה בשרת הזה</string>
<string name="custom_headers_dialog_error_reserved_name">הכותרת הזאת שמורה ומוגדרת על ידי ntfy</string>
<string name="custom_headers_dialog_error_invalid_name">שם הכותרת מכיל תווים שגויים</string>
<string name="custom_headers_dialog_value_hint">ערך כותרת (למשל: 9f3c2e4a1b2d4e)</string>
<string name="custom_headers_dialog_name_hint">שם כותרת (למשל: CF-Access-Client-Id)</string>
<string name="custom_headers_dialog_base_url_hint">כתובת שירות</string>
<string name="custom_headers_dialog_title_edit">עריכת כותרת מותאמת אישית</string>
<string name="custom_headers_dialog_title_add">הוספת כותרת מותאמת אישית</string>
<string name="settings_advanced_custom_headers_prefs_header_add_title">הוספת כותרת לשרת</string>
<string name="settings_advanced_custom_headers_prefs_header_add">הוספת כותרת</string>
<string name="settings_advanced_custom_headers_title">כותרות מותאמות אישית</string>
<string name="settings_general_message_bar_summary_disabled">כפתור הפרסום מופיע בתחתית תצוגת הנושאים</string>
<string name="settings_general_message_bar_summary_enabled">סרגל הודעות מופיע בתחתית תצוגת הנושאים</string>
<string name="settings_general_message_bar_title">הצגת סרגל הודעות</string>
<string name="settings_general_dynamic_colors_summary_enabled">להשתמש בצבעי המערכת הדינמיים</string>
<string name="settings_general_dynamic_colors_summary_disabled">להשתמש בצבעי ערכת העיצוב של ntfy</string>
</resources>

View file

@ -347,4 +347,28 @@
<string name="settings_general_dynamic_colors_title">Değişken renkler</string>
<string name="settings_general_dynamic_colors_summary_enabled">Değişken sistem renkleri kullanılıyor</string>
<string name="settings_general_dynamic_colors_summary_disabled">ntfy tema renkleri kullanılıyor</string>
<string name="publish_dialog_title">%1$s\'a yayınla</string>
<string name="publish_dialog_title_hint">Başlık</string>
<string name="publish_dialog_title_placeholder">Örneğin, \'Kapıda birisi var\'</string>
<string name="publish_dialog_message_hint">Mesaj</string>
<string name="publish_dialog_tags_hint">Etiketler</string>
<string name="publish_dialog_tags_placeholder">Orneğin, \'İkaz, Tehlike\'</string>
<string name="publish_dialog_priority_hint">Öncelik</string>
<string name="publish_dialog_button_publish">Yayınla</string>
<string name="publish_dialog_error_sending">Mesaj: %1$s yayınlanamadı</string>
<string name="publish_dialog_error_server">Mesaj: %1$s yayınlanamadı (kod %2$d)</string>
<string name="publish_dialog_message_published">Mesaj yayınlandı</string>
<string name="publish_dialog_uploading">Yüklenme: %1$s (%2$s / %3$s)</string>
<string name="publish_dialog_upload_cancelled">Yüklenme iptal edildi</string>
<string name="publish_dialog_chip_title">Başlık</string>
<string name="publish_dialog_chip_tags">Etiketler</string>
<string name="publish_dialog_chip_priority">Öncelik</string>
<string name="publish_dialog_chip_click_url">Tıklama URL\'i</string>
<string name="publish_dialog_chip_email">E-posta adresi</string>
<string name="publish_dialog_chip_delay">Erteleme</string>
<string name="publish_dialog_chip_markdown">\"Markdown\"</string>
<string name="publish_dialog_chip_attach_url">URL\'le ekle</string>
<string name="publish_dialog_chip_attach_file">Yerli dosya ekle</string>
<string name="publish_dialog_chip_phone_call">Telefon görüşmesi</string>
<string name="publish_dialog_click_url_hint">Tıklama URL\'i</string>
</resources>

View file

@ -0,0 +1,3 @@
Maintenance + bug fixes:
* Hide "Exact alarms" setting if battery optimization exemption has been granted (#1456, thanks for reporting @HappyLer)
* Fix ForegroundServiceDidNotStartInTimeException (#1520, attempt 3, d064e75)