diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt index 4cce58e3..70d3da64 100644 --- a/app/src/main/java/io/heckel/ntfy/app/Application.kt +++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt @@ -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) } 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 06b4a7c1..54b1c02e 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberService.kt @@ -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 } } diff --git a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt index f577b10c..246b52e5 100644 --- a/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt +++ b/app/src/main/java/io/heckel/ntfy/service/SubscriberServiceManager.kt @@ -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) 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 6b6ff1fb..da872998 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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 } diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index 77d5278f..c954222e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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 { 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) diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index 3c857c8e..e97adc8f 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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 -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5979fb69..555a7ec4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,8 @@ Snooze %1$dh Never show + + Connection lost alert snoozed for %1$dh %1$d notification(s) received diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index a120c699..b83a1251 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -2,5 +2,5 @@ - + diff --git a/fastlane/metadata/android/en-US/changelog/61.txt b/fastlane/metadata/android/en-US/changelog/61.txt index 0c0333ce..b4e61192 100644 --- a/fastlane/metadata/android/en-US/changelog/61.txt +++ b/fastlane/metadata/android/en-US/changelog/61.txt @@ -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)