Restart service when network returns, even when app was killed

This commit is contained in:
Philipp Heckel 2026-04-07 16:46:48 -04:00
parent 9137d94d46
commit 139f586be6
9 changed files with 57 additions and 10 deletions

View file

@ -33,8 +33,17 @@ class Application : Application() {
private fun registerNetworkCallback() {
val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
// If there's already a default network at registration time, registerDefaultNetworkCallback
// will synchronously deliver an initial onAvailable for it. That's not a real transition, so
// skip the first onAvailable in that case to avoid a spurious reconnect on every cold start.
var skipInitialAvailable = connectivityManager.activeNetwork != null
connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (skipInitialAvailable) {
skipInitialAvailable = false
Log.d(TAG, "Skipping initial onAvailable for pre-existing default network ($network)")
return
}
// Force reconnect of all WebSocket/JSON connections so they're rebound to the new
// default network. This catches Wi-Fi <-> cellular handoffs and similar transitions
// where the underlying socket is bound to a network that's no longer the default.
@ -50,6 +59,8 @@ class Application : Application() {
}
}
override fun onLost(network: Network) {
// Once we've observed a loss, any subsequent onAvailable is a real transition.
skipInitialAvailable = false
Log.i(TAG, "Default network lost ($network); refreshing subscriber service")
SubscriberServiceManager.refresh(this@Application)
}

View file

@ -15,6 +15,7 @@ import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.widget.Toast
import androidx.core.app.NotificationCompat
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
@ -358,18 +359,18 @@ class SubscriberService : Service() {
getString(R.string.connection_alert_text_multiple, disconnectedUrls.size, thresholdMinutes)
}
val contentIntent = PendingIntent.getActivity(this, 0,
val contentIntent = PendingIntent.getActivity(this, REQUEST_CODE_CONTENT,
Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
val snoozeShortIntent = PendingIntent.getBroadcast(this, 0,
val snoozeShortIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SNOOZE_SHORT,
Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply { action = CONNECTION_ALERT_ACTION_SNOOZE_SHORT },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val snoozeIntent = PendingIntent.getBroadcast(this, 0,
val snoozeIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SNOOZE_LONG,
Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply { action = CONNECTION_ALERT_ACTION_SNOOZE_LONG },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val neverAlertIntent = PendingIntent.getBroadcast(this, 0,
val neverAlertIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_NEVER,
Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply { action = CONNECTION_ALERT_ACTION_NEVER },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val deleteIntent = PendingIntent.getBroadcast(this, 0,
val deleteIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_DISMISS,
Intent(this, ConnectionAlertBroadcastReceiver::class.java).apply { action = CONNECTION_ALERT_ACTION_DISMISS },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
@ -505,9 +506,11 @@ class SubscriberService : Service() {
when (intent.action) {
CONNECTION_ALERT_ACTION_DISMISS, CONNECTION_ALERT_ACTION_SNOOZE_SHORT -> {
repository.setConnectionAlertSnoozeUntilTime(System.currentTimeMillis() + CONNECTION_ALERT_SNOOZE_SHORT_MILLIS)
Toast.makeText(context, context.getString(R.string.connection_alert_snoozed_toast, CONNECTION_ALERT_SNOOZE_SHORT_HOURS), Toast.LENGTH_LONG).show()
}
CONNECTION_ALERT_ACTION_SNOOZE_LONG -> {
repository.setConnectionAlertSnoozeUntilTime(System.currentTimeMillis() + CONNECTION_ALERT_SNOOZE_LONG_MILLIS)
Toast.makeText(context, context.getString(R.string.connection_alert_snoozed_toast, CONNECTION_ALERT_SNOOZE_LONG_HOURS), Toast.LENGTH_LONG).show()
}
CONNECTION_ALERT_ACTION_NEVER -> {
repository.setConnectionAlertSeconds(Repository.CONNECTION_ALERT_NEVER)
@ -547,5 +550,13 @@ class SubscriberService : Service() {
private const val CONNECTION_ALERT_ACTION_SNOOZE_SHORT = "io.heckel.ntfy.CONNECTION_ALERT_SNOOZE_SHORT"
private const val CONNECTION_ALERT_ACTION_SNOOZE_LONG = "io.heckel.ntfy.CONNECTION_ALERT_SNOOZE_LONG"
private const val CONNECTION_ALERT_ACTION_NEVER = "io.heckel.ntfy.CONNECTION_ALERT_NEVER"
// Unique request codes for connection alert PendingIntents. These must be distinct so
// that each PendingIntent is treated as a separate entry by the system.
private const val REQUEST_CODE_CONTENT = 0
private const val REQUEST_CODE_SNOOZE_SHORT = 1
private const val REQUEST_CODE_SNOOZE_LONG = 2
private const val REQUEST_CODE_NEVER = 3
private const val REQUEST_CODE_DISMISS = 4
}
}

View file

@ -41,6 +41,7 @@ class SubscriberServiceManager(private val context: Context) {
}
withContext(Dispatchers.IO) {
val app = context.applicationContext as Application
val workManager = WorkManager.getInstance(context)
val subscriptionIdsWithInstantStatus = app.repository.getSubscriptionIdsWithInstantStatus()
val hasNetwork = isNetworkAvailable(context)
val instantSubscriptions = subscriptionIdsWithInstantStatus.toList().filter { (_, instant) -> instant }.size
@ -51,6 +52,9 @@ class SubscriberServiceManager(private val context: Context) {
it.action = SubscriberService.Action.START.name
try {
ContextCompat.startForegroundService(context, it)
// Service started successfully: cancel any pending "wait for network"
// worker that may have been scheduled during an earlier network outage.
workManager.cancelUniqueWork(WORK_NAME_ON_NETWORK_AVAILABLE)
} catch (e: Exception) {
// ForegroundServiceDidNotStartInTimeException or other exceptions can occur
// due to race conditions or system constraints. We log and continue;
@ -66,6 +70,23 @@ class SubscriberServiceManager(private val context: Context) {
app.repository.clearConnectionDetails()
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(SubscriberService.NOTIFICATION_CONNECTION_ALERT_ID)
// Schedule a one-time, network-constrained worker that restarts the service
// as soon as network comes back. This works even if the process has been
// killed by the OS while the service was stopped, because WorkManager /
// JobScheduler is tied to the platform's connectivity monitor. Without this,
// recovery when offline->online happens after process death would only occur
// via the periodic NtfyAutoRestartWorkerPeriodic worker (up to ~30 minutes).
// Doze mode may still defer the job until the next maintenance window, but
// users with instant delivery are prompted to disable battery optimization.
if (instantSubscriptions > 0) {
val networkConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val onNetworkRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java)
.setConstraints(networkConstraints)
.build()
workManager.enqueueUniqueWork(WORK_NAME_ON_NETWORK_AVAILABLE, ExistingWorkPolicy.REPLACE, onNetworkRequest)
}
}
Intent(context, SubscriberService::class.java).also {
context.stopService(it)
@ -79,6 +100,7 @@ class SubscriberServiceManager(private val context: Context) {
companion object {
const val TAG = "NtfySubscriberMgr"
const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
const val WORK_NAME_ON_NETWORK_AVAILABLE = "ServiceStartWorkerOnNetworkAvailable"
fun refresh(context: Context) {
val manager = SubscriberServiceManager(context)

View file

@ -461,7 +461,7 @@ class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, Notific
val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
} catch (e: Exception) {
Log.d(TAG, "Failed to unregister network callback: ${e.message}")
Log.w(TAG, "Failed to unregister network callback: ${e.message}", e)
}
networkCallback = null
}

View file

@ -331,7 +331,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
connectionAlert?.value = repository.getConnectionAlertSeconds().toString()
connectionAlert?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val seconds = value?.toLongOrNull() ?:return
val seconds = value?.toLongOrNull() ?: return
repository.setConnectionAlertSeconds(seconds)
}
override fun getString(key: String?, defValue: String?): String {
@ -339,7 +339,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
connectionAlert?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (pref.value.toLongOrNull() ?: repository.getConnectionAlertSeconds()) {
when (pref.value?.toLongOrNull() ?: repository.getConnectionAlertSeconds()) {
Repository.CONNECTION_ALERT_NEVER -> getString(R.string.settings_advanced_connection_alert_summary_never)
Repository.CONNECTION_ALERT_FIVE_MINUTES_SECONDS -> getString(R.string.settings_advanced_connection_alert_summary_five_minutes)
Repository.CONNECTION_ALERT_FIFTEEN_MINUTES_SECONDS -> getString(R.string.settings_advanced_connection_alert_summary_fifteen_minutes)

View file

@ -553,4 +553,4 @@ fun deriveNotificationId(baseUrl: String, topic: String, sequenceId: String): In
fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return connectivityManager.activeNetwork != null
}
}

View file

@ -55,6 +55,8 @@
<!-- %1$d is the snooze duration in hours, currently set to 8 -->
<string name="connection_alert_action_snooze">Snooze %1$dh</string>
<string name="connection_alert_action_never">Never show</string>
<!-- %1$d is the snooze duration in hours -->
<string name="connection_alert_snoozed_toast">Connection lost alert snoozed for %1$dh</string>
<!-- Common refresh toasts -->
<string name="refresh_message_result">%1$d notification(s) received</string>

View file

@ -2,5 +2,5 @@
<paths>
<external-path name="external_files" path="."/>
<cache-path name="cache_files" path="."/>
<files-path name="files" path="."/>
<files-path name="subscription_icons" path="subscriptionIcons/"/>
</paths>

View file

@ -1,6 +1,7 @@
Features:
* Add configurable "Alert when connection is lost" setting (#1665, #1662, #1652, #1655, thanks to @tintamarre, @sjozs, @TheRealOne78, and @DAE51D for reporting)
* Suppress connection alerts and stop foreground service when there is no network (ntfy-android#165, thanks to @tintamarre for the contribution)
* Restart the foreground service immediately when network returns, even if the app process was killed while offline
* Improve battery life by increasing WebSocket client ping interval from 1 min to 5 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions (ntfy-android#113, thanks to @ftilde for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings (ntfy-android#168, thanks to @p1gp1g for the contribution)