From 4886911667f0e70730d14974d881869f425e9d31 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 13:01:41 -0400 Subject: [PATCH 01/10] Fix background ANR from connectivity broadcast receiver Replace the deprecated CONNECTIVITY_ACTION BroadcastReceiver (ConnectionChangeReceiver) with a ConnectivityManager.NetworkCallback (NetworkConnectionMonitor) registered on a background thread. Connectivity checks and the EventBus dispatch no longer run on the main thread, and a NetworkCallback is not a broadcast so it cannot trip the background-broadcast ANR timeout. The monitor is registered once for the process lifetime, dropping the unreliable foreground/background register/unregister workaround. The ConnectionChangeEvent contract is preserved, so subscribers are unchanged apart from the renamed enclosing type. Sentry: JETPACK-ANDROID-PB9 --- .../org/wordpress/android/AppInitializer.kt | 46 +++-------- .../networking/ConnectionChangeReceiver.java | 64 --------------- .../networking/NetworkConnectionMonitor.kt | 79 +++++++++++++++++++ .../android/ui/main/WPMainActivity.java | 4 +- .../android/ui/posts/EditPostActivity.kt | 2 +- .../ui/prefs/BlogPreferencesActivity.java | 4 +- .../ui/reader/ReaderPostListFragment.kt | 2 +- .../ui/suggestion/SuggestionActivity.kt | 2 +- .../ui/suggestion/SuggestionViewModel.kt | 2 +- .../ui/suggestion/SuggestionViewModelTest.kt | 2 +- 10 files changed, 97 insertions(+), 110 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/networking/ConnectionChangeReceiver.java create mode 100644 WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt diff --git a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt index 300566324748..5f176e95dff1 100644 --- a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt +++ b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt @@ -11,12 +11,9 @@ import android.app.NotificationManager import android.app.SyncNotedAppOp import android.content.ComponentCallbacks2 import android.content.Context -import android.content.ContextWrapper -import android.content.IntentFilter import android.content.res.Configuration import android.database.SQLException import android.database.sqlite.SQLiteException -import android.net.ConnectivityManager import android.net.http.HttpResponseCache import android.os.Build import android.os.Build.VERSION_CODES @@ -67,7 +64,7 @@ import org.wordpress.android.fluxc.store.StatsStore import org.wordpress.android.fluxc.tools.FluxCImageLoader import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError import org.wordpress.android.modules.APPLICATION_SCOPE -import org.wordpress.android.networking.ConnectionChangeReceiver +import org.wordpress.android.networking.NetworkConnectionMonitor import org.wordpress.android.networking.OAuthAuthenticator import org.wordpress.android.networking.RestClientUtils import org.wordpress.android.push.GCMRegistrationScheduler @@ -92,7 +89,6 @@ import org.wordpress.android.ui.uploads.UploadService import org.wordpress.android.ui.uploads.UploadStarter import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T -import org.wordpress.android.util.AppLog.T.MAIN import org.wordpress.android.util.AppThemeUtils import org.wordpress.android.util.BitmapLruCache import org.wordpress.android.util.BuildConfigWrapper @@ -148,6 +144,9 @@ class AppInitializer @Inject constructor( @Inject lateinit var uploadStarter: UploadStarter + @Inject + lateinit var networkConnectionMonitor: NetworkConnectionMonitor + @Inject lateinit var statsWidgetUpdaters: StatsWidgetUpdaters @@ -339,6 +338,11 @@ class AppInitializer @Inject constructor( // Make the UploadStarter observe the app process so it can auto-start uploads uploadStarter.activateAutoUploading(ProcessLifecycleOwner.get() as ProcessLifecycleOwner) + // Monitor default-network connectivity for the whole process lifetime. Uses a NetworkCallback on a + // background thread instead of the deprecated CONNECTIVITY_ACTION broadcast, so it can't cause a + // background ANR. start() is idempotent, so it's safe if init() runs more than once. + networkConnectionMonitor.start(application) + initAnalytics(SystemClock.elapsedRealtime() - startDate) updateNotificationSettings() @@ -792,7 +796,6 @@ class AppInitializer @Inject constructor( inner class ApplicationLifecycleMonitor { private var lastPingDate: Date? = null private var applicationOpenedDate: Date? = null - private var connectionReceiverRegistered = false var firstActivityResumed = true private val isPushNotificationPingNeeded: Boolean @@ -845,18 +848,6 @@ class AppInitializer @Inject constructor( AnalyticsTracker.track(Stat.APPLICATION_CLOSED, properties) AnalyticsTracker.endSession(false) - // Methods onAppComesFromBackground and onAppGoesToBackground are only workarounds to track when the app - // goes to or comes from background. The workarounds are not 100% reliable, so avoid unregistering the - // receiver twice. - if (connectionReceiverRegistered) { - connectionReceiverRegistered = false - try { - application.unregisterReceiver(ConnectionChangeReceiver.getInstance()) - AppLog.d(MAIN, "ConnectionChangeReceiver successfully unregistered") - } catch (e: IllegalArgumentException) { - AppLog.e(MAIN, "ConnectionChangeReceiver was already unregistered") - } - } // Disable the widgets if needed disableWidgetReceiversIfNeeded() @@ -876,25 +867,6 @@ class AppInitializer @Inject constructor( } WordPress.appIsInTheBackground = false - // https://developer.android.com/reference/android/net/ConnectivityManager.html - // Apps targeting Android 7.0 (API level 24) and higher do not receive this broadcast if the broadcast - // receiver is declared in their manifest. Apps will still receive broadcasts if BroadcastReceiver is - // registered with Context.registerReceiver() and that context is still valid. - if (!connectionReceiverRegistered) { - connectionReceiverRegistered = true - if (Build.VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { - application.registerReceiver( - ConnectionChangeReceiver.getInstance(), - IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION), - ContextWrapper.RECEIVER_EXPORTED - ) - } else { - application.registerReceiver( - ConnectionChangeReceiver.getInstance(), - IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) - ) - } - } AnalyticsUtils.refreshMetadata(accountStore, siteStore) applicationOpenedDate = Date() // This stat is part of a funnel that provides critical information. Before making ANY modification to this diff --git a/WordPress/src/main/java/org/wordpress/android/networking/ConnectionChangeReceiver.java b/WordPress/src/main/java/org/wordpress/android/networking/ConnectionChangeReceiver.java deleted file mode 100644 index e70f019062b8..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/networking/ConnectionChangeReceiver.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.wordpress.android.networking; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.greenrobot.eventbus.EventBus; -import org.wordpress.android.util.AppLog; -import org.wordpress.android.util.AppLog.T; -import org.wordpress.android.util.NetworkUtils; - -/* - * global network connection change receiver - declared in the manifest to monitor - * android.net.conn.CONNECTIVITY_CHANGE - */ -public class ConnectionChangeReceiver extends BroadcastReceiver { - private static boolean mIsFirstReceive = true; - private static boolean mWasConnected = true; - private static ConnectionChangeReceiver sInstance; - - public static class ConnectionChangeEvent { - private final boolean mIsConnected; - - public ConnectionChangeEvent(boolean isConnected) { - mIsConnected = isConnected; - } - - public boolean isConnected() { - return mIsConnected; - } - } - - /* - * note that onReceive occurs when anything about the connection has changed, not just - * when the connection has been lost or restated, so it can happen quite often when the - * user is on the move. for this reason we only fire the event the first time onReceive - * is called, and afterwards only when we know connection availability has changed - */ - @Override - public void onReceive(Context context, Intent intent) { - boolean isConnected = NetworkUtils.isNetworkAvailable(context); - if (mIsFirstReceive || isConnected != mWasConnected) { - postConnectionChangeEvent(isConnected); - } - } - - private static void postConnectionChangeEvent(boolean isConnected) { - AppLog.i(T.UTILS, "Connection status changed, isConnected=" + isConnected); - mWasConnected = isConnected; - mIsFirstReceive = false; - EventBus.getDefault().post(new ConnectionChangeEvent(isConnected)); - } - - public static ConnectionChangeReceiver getInstance() { - if (sInstance == null) { - sInstance = new ConnectionChangeReceiver(); - } - return sInstance; - } - - public static EventBus getEventBus() { - return EventBus.getDefault(); - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt new file mode 100644 index 000000000000..8834e02226ca --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -0,0 +1,79 @@ +package org.wordpress.android.networking + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.os.Handler +import android.os.HandlerThread +import org.greenrobot.eventbus.EventBus +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Global monitor for changes to the device's default network connectivity. + * + * Uses [ConnectivityManager.NetworkCallback] registered on a background [HandlerThread] rather than the + * deprecated CONNECTIVITY_ACTION broadcast. Because a NetworkCallback is not a broadcast it is not subject to + * background-broadcast ANR timeouts, and all connectivity work runs off the main thread. A + * [ConnectionChangeEvent] is posted on EventBus whenever the connected state changes; EventBus marshals the + * `onEventMainThread` subscribers back onto the main thread. + */ +@Singleton +class NetworkConnectionMonitor @Inject constructor() { + private var started = false + private var wasConnected = false + private var isFirstCallback = true + + private var connectivityManager: ConnectivityManager? = null + private var handlerThread: HandlerThread? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + class ConnectionChangeEvent(val isConnected: Boolean) + + @Synchronized + fun start(context: Context) { + if (started) return + val manager = context.applicationContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return + connectivityManager = manager + + val thread = HandlerThread("NetworkConnectionMonitor").apply { start() } + handlerThread = thread + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = onConnectivityChanged(true) + override fun onLost(network: Network) = onConnectivityChanged(false) + } + networkCallback = callback + + manager.registerDefaultNetworkCallback(callback, Handler(thread.looper)) + started = true + } + + @Synchronized + fun stop() { + if (!started) return + networkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) } + handlerThread?.quitSafely() + networkCallback = null + handlerThread = null + connectivityManager = null + started = false + } + + /** + * Called on the monitor's background thread. Posts a [ConnectionChangeEvent] on the first callback and + * whenever the connected state actually changes, so subscribers aren't spammed when a network's + * capabilities change without a change in availability. + */ + private fun onConnectivityChanged(isConnected: Boolean) { + if (isFirstCallback || isConnected != wasConnected) { + isFirstCallback = false + wasConnected = isConnected + AppLog.i(T.UTILS, "Connection status changed, isConnected=$isConnected") + EventBus.getDefault().post(ConnectionChangeEvent(isConnected)) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 543665906fee..748e47ed03c7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -62,7 +62,7 @@ import org.wordpress.android.inappupdate.IInAppUpdateManager; import org.wordpress.android.inappupdate.InAppUpdateListener; import org.wordpress.android.ui.accounts.login.LoginAnalyticsListener; -import org.wordpress.android.networking.ConnectionChangeReceiver; +import org.wordpress.android.networking.NetworkConnectionMonitor; import org.wordpress.android.push.GCMMessageHandler; import org.wordpress.android.push.GCMMessageService; import org.wordpress.android.push.GCMRegistrationScheduler; @@ -1490,7 +1490,7 @@ public void onEventMainThread(NotificationEvents.NotificationsUnseenStatus event @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(ConnectionChangeReceiver.ConnectionChangeEvent event) { + public void onEventMainThread(NetworkConnectionMonitor.ConnectionChangeEvent event) { updateConnectionBar(event.isConnected()); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt index 23bad72bf636..8b76154185c7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt @@ -119,7 +119,7 @@ import org.wordpress.android.fluxc.store.bloggingprompts.BloggingPromptsStore import org.wordpress.android.fluxc.tools.FluxCImageLoader import org.wordpress.android.imageeditor.preview.PreviewImageFragment import org.wordpress.android.imageeditor.preview.PreviewImageFragment.Companion.EditImageData.InputData -import org.wordpress.android.networking.ConnectionChangeReceiver.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.support.ZendeskHelper import org.wordpress.android.ui.ActivityId import org.wordpress.android.ui.ActivityLauncher diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java index 1573d8dd2960..1d6d826dedcd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java @@ -22,7 +22,7 @@ import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged; import org.wordpress.android.fluxc.store.SiteStore.OnSiteDeleted; import org.wordpress.android.fluxc.store.SiteStore.OnSiteRemoved; -import org.wordpress.android.networking.ConnectionChangeReceiver; +import org.wordpress.android.networking.NetworkConnectionMonitor; import org.wordpress.android.ui.main.BaseAppCompatActivity; import org.wordpress.android.util.SiteUtils; import org.wordpress.android.util.ToastUtils; @@ -119,7 +119,7 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { @SuppressWarnings("unused") @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(ConnectionChangeReceiver.ConnectionChangeEvent event) { + public void onEventMainThread(NetworkConnectionMonitor.ConnectionChangeEvent event) { SiteSettingsFragment siteSettingsFragment = getSettingsFragment(); if (siteSettingsFragment != null) { if (!event.isConnected()) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt index 399f7a270220..d4e20b7da355 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt @@ -69,7 +69,7 @@ import org.wordpress.android.models.ReaderPost import org.wordpress.android.models.ReaderPostDiscoverData import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagType -import org.wordpress.android.networking.ConnectionChangeReceiver.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.ActionableEmptyView import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.EmptyViewMessageType diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt index 1a93d896e43f..400c521ec4fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt @@ -16,7 +16,7 @@ import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.SuggestUsersActivityBinding import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.ConnectionChangeReceiver.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt index c59b4257ef43..96b044c47aef 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.ViewModel import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.ConnectionChangeReceiver.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable import org.wordpress.android.ui.suggestion.SuggestionType.Users diff --git a/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt index cc3eb2667444..e23c94020e43 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.whenever import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.ConnectionChangeReceiver.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable import org.wordpress.android.ui.suggestion.SuggestionType.Users From eb05a3a0508757b57525562e42d29b0f4ed164bf Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 13:24:35 -0400 Subject: [PATCH 02/10] Address review: keep connectivity subscribers on-main and fix handover - SuggestionActivity's ConnectionChangeEvent subscriber used a bare @Subscribe (POSTING). Since the event is now posted from a background thread, its updateEmptyView() touched Views off the main thread. Pin it to ThreadMode.MAIN. - NetworkConnectionMonitor.onLost no longer assumes the device is offline: during a network handover (e.g. Wi-Fi -> cellular) the replacement is already the default, so re-query the active network's capabilities instead of posting a spurious disconnected event. --- .../networking/NetworkConnectionMonitor.kt | 16 +++++++++++++++- .../android/ui/suggestion/SuggestionActivity.kt | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index 8834e02226ca..4ecfcc522daf 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -3,6 +3,7 @@ package org.wordpress.android.networking import android.content.Context import android.net.ConnectivityManager import android.net.Network +import android.net.NetworkCapabilities import android.os.Handler import android.os.HandlerThread import org.greenrobot.eventbus.EventBus @@ -44,7 +45,9 @@ class NetworkConnectionMonitor @Inject constructor() { val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) = onConnectivityChanged(true) - override fun onLost(network: Network) = onConnectivityChanged(false) + // onLost fires for the network that was lost, but during a handover (e.g. Wi-Fi -> cellular) the + // new default has already arrived via onAvailable, so re-query rather than assuming we're offline. + override fun onLost(network: Network) = onConnectivityChanged(hasActiveConnection()) } networkCallback = callback @@ -76,4 +79,15 @@ class NetworkConnectionMonitor @Inject constructor() { EventBus.getDefault().post(ConnectionChangeEvent(isConnected)) } } + + /** + * Whether there is currently a default network capable of reaching the internet. Used to distinguish a + * true disconnection from a handover between networks, where the replacement is already the default. + */ + private fun hasActiveConnection(): Boolean { + val manager = connectivityManager ?: return false + val activeNetwork = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt index 400c521ec4fb..9f85be6c80dc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt @@ -12,6 +12,7 @@ import android.view.inputmethod.EditorInfo import androidx.activity.addCallback import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.SuggestUsersActivityBinding @@ -260,7 +261,7 @@ class SuggestionActivity : BaseAppCompatActivity() { super.onPause() } - @Subscribe + @Subscribe(threadMode = ThreadMode.MAIN) fun onEventMainThread(event: ConnectionChangeEvent) { viewModel.onConnectionChanged(event) updateEmptyView() From 32a6852430127ee8acb8a7d9f1aac8a221b68ae0 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 13:37:56 -0400 Subject: [PATCH 03/10] Simplify: drop unused stop() from NetworkConnectionMonitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitor is registered once for the whole process lifetime and never torn down, so stop() had no callers. Removing it also lets the handlerThread and networkCallback fields go — they were only read by stop(). connectivityManager stays since hasActiveConnection() uses it. --- .../networking/NetworkConnectionMonitor.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index 4ecfcc522daf..26b003f2d4e9 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -28,8 +28,6 @@ class NetworkConnectionMonitor @Inject constructor() { private var isFirstCallback = true private var connectivityManager: ConnectivityManager? = null - private var handlerThread: HandlerThread? = null - private var networkCallback: ConnectivityManager.NetworkCallback? = null class ConnectionChangeEvent(val isConnected: Boolean) @@ -41,31 +39,16 @@ class NetworkConnectionMonitor @Inject constructor() { connectivityManager = manager val thread = HandlerThread("NetworkConnectionMonitor").apply { start() } - handlerThread = thread - val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) = onConnectivityChanged(true) // onLost fires for the network that was lost, but during a handover (e.g. Wi-Fi -> cellular) the // new default has already arrived via onAvailable, so re-query rather than assuming we're offline. override fun onLost(network: Network) = onConnectivityChanged(hasActiveConnection()) } - networkCallback = callback - manager.registerDefaultNetworkCallback(callback, Handler(thread.looper)) started = true } - @Synchronized - fun stop() { - if (!started) return - networkCallback?.let { connectivityManager?.unregisterNetworkCallback(it) } - handlerThread?.quitSafely() - networkCallback = null - handlerThread = null - connectivityManager = null - started = false - } - /** * Called on the monitor's background thread. Posts a [ConnectionChangeEvent] on the first callback and * whenever the connected state actually changes, so subscribers aren't spammed when a network's From c95717d69e427287798d053992a82ea6d1e0a413 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 13:39:58 -0400 Subject: [PATCH 04/10] Fix detekt ReturnCount in NetworkConnectionMonitor Collapse hasActiveConnection's guard clauses into a safe-call chain so it has two returns instead of four. No behavior change. --- .../wordpress/android/networking/NetworkConnectionMonitor.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index 26b003f2d4e9..b746b590509e 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -69,8 +69,7 @@ class NetworkConnectionMonitor @Inject constructor() { */ private fun hasActiveConnection(): Boolean { val manager = connectivityManager ?: return false - val activeNetwork = manager.activeNetwork ?: return false - val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val capabilities = manager.activeNetwork?.let { manager.getNetworkCapabilities(it) } + return capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true } } From cadb8046516db5381ee386ed34ca9ef85f8579b4 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 13:49:18 -0400 Subject: [PATCH 05/10] Fix connection bar not updating live on connectivity change Fix 2 re-queried ConnectivityManager.getActiveNetwork() in onLost to avoid misreporting a network handover as a disconnect. On real devices getActiveNetwork() can still return the network being torn down at that instant, so a genuine disconnect was suppressed by the de-dupe, leaving wasConnected stuck true. The "No connection" bar then only updated on the next onResume poll, not live. Track the set of available internet-capable networks instead (registerNetworkCallback with a NET_CAPABILITY_INTERNET request): a handover adds the replacement before removing the old network so the set never empties (no false disconnect), while a real disconnect empties it reliably. Removes the fragile getActiveNetwork() query. --- .../networking/NetworkConnectionMonitor.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index b746b590509e..b0dad5c77814 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.os.Handler import android.os.HandlerThread import org.greenrobot.eventbus.EventBus @@ -13,21 +14,25 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Global monitor for changes to the device's default network connectivity. + * Global monitor for changes to the device's network connectivity. * * Uses [ConnectivityManager.NetworkCallback] registered on a background [HandlerThread] rather than the * deprecated CONNECTIVITY_ACTION broadcast. Because a NetworkCallback is not a broadcast it is not subject to * background-broadcast ANR timeouts, and all connectivity work runs off the main thread. A * [ConnectionChangeEvent] is posted on EventBus whenever the connected state changes; EventBus marshals the * `onEventMainThread` subscribers back onto the main thread. + * + * Connectivity is tracked as the set of currently-available internet-capable networks rather than a single + * network. During a handover (e.g. Wi-Fi -> cellular) the replacement is added before the old one is removed, + * so the set never empties and the handover is not misreported as a disconnection; a genuine disconnect + * empties the set and is reported reliably. */ @Singleton class NetworkConnectionMonitor @Inject constructor() { private var started = false private var wasConnected = false private var isFirstCallback = true - - private var connectivityManager: ConnectivityManager? = null + private val availableNetworks = mutableSetOf() class ConnectionChangeEvent(val isConnected: Boolean) @@ -36,23 +41,28 @@ class NetworkConnectionMonitor @Inject constructor() { if (started) return val manager = context.applicationContext .getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return - connectivityManager = manager val thread = HandlerThread("NetworkConnectionMonitor").apply { start() } val callback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) = onConnectivityChanged(true) - // onLost fires for the network that was lost, but during a handover (e.g. Wi-Fi -> cellular) the - // new default has already arrived via onAvailable, so re-query rather than assuming we're offline. - override fun onLost(network: Network) = onConnectivityChanged(hasActiveConnection()) + override fun onAvailable(network: Network) { + availableNetworks.add(network) + onConnectivityChanged(availableNetworks.isNotEmpty()) + } + override fun onLost(network: Network) { + availableNetworks.remove(network) + onConnectivityChanged(availableNetworks.isNotEmpty()) + } } - manager.registerDefaultNetworkCallback(callback, Handler(thread.looper)) + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + manager.registerNetworkCallback(request, callback, Handler(thread.looper)) started = true } /** * Called on the monitor's background thread. Posts a [ConnectionChangeEvent] on the first callback and - * whenever the connected state actually changes, so subscribers aren't spammed when a network's - * capabilities change without a change in availability. + * whenever the connected state actually changes, so subscribers aren't spammed while connectivity churns. */ private fun onConnectivityChanged(isConnected: Boolean) { if (isFirstCallback || isConnected != wasConnected) { @@ -62,14 +72,4 @@ class NetworkConnectionMonitor @Inject constructor() { EventBus.getDefault().post(ConnectionChangeEvent(isConnected)) } } - - /** - * Whether there is currently a default network capable of reaching the internet. Used to distinguish a - * true disconnection from a handover between networks, where the replacement is already the default. - */ - private fun hasActiveConnection(): Boolean { - val manager = connectivityManager ?: return false - val capabilities = manager.activeNetwork?.let { manager.getNetworkCapabilities(it) } - return capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - } } From 5f85a772ed4c2ed2c972497d0c46bf5b20866765 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 14:03:59 -0400 Subject: [PATCH 06/10] Soften handover doc comment in NetworkConnectionMonitor Clarify that the available-networks set only stays non-empty during a handover when the replacement network is already up; no code change. --- .../android/networking/NetworkConnectionMonitor.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index b0dad5c77814..97da4d4bde55 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -23,9 +23,9 @@ import javax.inject.Singleton * `onEventMainThread` subscribers back onto the main thread. * * Connectivity is tracked as the set of currently-available internet-capable networks rather than a single - * network. During a handover (e.g. Wi-Fi -> cellular) the replacement is added before the old one is removed, - * so the set never empties and the handover is not misreported as a disconnection; a genuine disconnect - * empties the set and is reported reliably. + * network. During a handover (e.g. Wi-Fi -> cellular) where the replacement is already up, it is added before + * the old one is removed, so the set doesn't empty and the handover isn't misreported as a disconnection; a + * genuine disconnect empties the set and is reported reliably. */ @Singleton class NetworkConnectionMonitor @Inject constructor() { From 195933e8a95d34ddf70abd6db775344b90f2b9a4 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 14:30:10 -0400 Subject: [PATCH 07/10] Replace connectivity EventBus with LiveData Drop the ConnectionChangeEvent-over-EventBus delivery introduced with NetworkConnectionMonitor and expose the connected state as a LiveData that subscribers observe via their lifecycle owner. Removes EventBus entirely from SuggestionActivity and BlogPreferencesActivity; the other subscribers keep EventBus for unrelated events. --- .../networking/NetworkConnectionMonitor.kt | 18 +++++++------ .../android/ui/main/WPMainActivity.java | 10 +++----- .../android/ui/posts/EditPostActivity.kt | 12 ++++----- .../ui/prefs/BlogPreferencesActivity.java | 19 ++++++++------ .../ui/reader/ReaderPostListFragment.kt | 25 +++++++++++-------- .../ui/suggestion/SuggestionActivity.kt | 25 +++++++------------ .../ui/suggestion/SuggestionViewModel.kt | 5 ++-- .../ui/suggestion/SuggestionViewModelTest.kt | 5 ++-- 8 files changed, 59 insertions(+), 60 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index 97da4d4bde55..7fb80fe93450 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -7,7 +7,8 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Handler import android.os.HandlerThread -import org.greenrobot.eventbus.EventBus +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import javax.inject.Inject @@ -18,9 +19,9 @@ import javax.inject.Singleton * * Uses [ConnectivityManager.NetworkCallback] registered on a background [HandlerThread] rather than the * deprecated CONNECTIVITY_ACTION broadcast. Because a NetworkCallback is not a broadcast it is not subject to - * background-broadcast ANR timeouts, and all connectivity work runs off the main thread. A - * [ConnectionChangeEvent] is posted on EventBus whenever the connected state changes; EventBus marshals the - * `onEventMainThread` subscribers back onto the main thread. + * background-broadcast ANR timeouts, and all connectivity work runs off the main thread. The connected state + * is exposed as [isConnected]; observers are notified on the main thread via LiveData whenever the connected + * state changes. * * Connectivity is tracked as the set of currently-available internet-capable networks rather than a single * network. During a handover (e.g. Wi-Fi -> cellular) where the replacement is already up, it is added before @@ -34,7 +35,8 @@ class NetworkConnectionMonitor @Inject constructor() { private var isFirstCallback = true private val availableNetworks = mutableSetOf() - class ConnectionChangeEvent(val isConnected: Boolean) + private val _isConnected = MutableLiveData() + val isConnected: LiveData = _isConnected @Synchronized fun start(context: Context) { @@ -61,15 +63,15 @@ class NetworkConnectionMonitor @Inject constructor() { } /** - * Called on the monitor's background thread. Posts a [ConnectionChangeEvent] on the first callback and - * whenever the connected state actually changes, so subscribers aren't spammed while connectivity churns. + * Called on the monitor's background thread. Updates [isConnected] on the first callback and whenever the + * connected state actually changes, so observers aren't spammed while connectivity churns. */ private fun onConnectivityChanged(isConnected: Boolean) { if (isFirstCallback || isConnected != wasConnected) { isFirstCallback = false wasConnected = isConnected AppLog.i(T.UTILS, "Connection status changed, isConnected=$isConnected") - EventBus.getDefault().post(ConnectionChangeEvent(isConnected)) + _isConnected.postValue(isConnected) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 748e47ed03c7..388077b97b14 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -279,6 +279,8 @@ public class WPMainActivity extends BaseAppCompatActivity implements @Inject WpAppNotifierHandler mWpAppNotifierHandler; + @Inject NetworkConnectionMonitor mNetworkConnectionMonitor; + /* * fragments implement this if their contents can be scrolled, called when user * requests to scroll to the top @@ -414,6 +416,8 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) { mDispatcher.register(this); EventBus.getDefault().register(this); + mNetworkConnectionMonitor.isConnected().observe(this, this::updateConnectionBar); + if (authTokenToSet != null) { // Save Token to the AccountStore. This will trigger a onAuthenticationChanged. UpdateTokenPayload payload = new UpdateTokenPayload(authTokenToSet); @@ -1488,12 +1492,6 @@ public void onEventMainThread(NotificationEvents.NotificationsUnseenStatus event if (mBottomNav != null) mBottomNav.showNoteBadge(event.hasUnseenNotes); } - @SuppressWarnings("unused") - @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(NetworkConnectionMonitor.ConnectionChangeEvent event) { - updateConnectionBar(event.isConnected()); - } - private void checkConnection() { updateConnectionBar(NetworkUtils.isNetworkAvailable(this)); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt index 8b76154185c7..bc55d8463056 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.kt @@ -119,7 +119,7 @@ import org.wordpress.android.fluxc.store.bloggingprompts.BloggingPromptsStore import org.wordpress.android.fluxc.tools.FluxCImageLoader import org.wordpress.android.imageeditor.preview.PreviewImageFragment import org.wordpress.android.imageeditor.preview.PreviewImageFragment.Companion.EditImageData.InputData -import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor import org.wordpress.android.support.ZendeskHelper import org.wordpress.android.ui.ActivityId import org.wordpress.android.ui.ActivityLauncher @@ -412,6 +412,8 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor @Inject lateinit var storageUtilsViewModel: StorageUtilsViewModel @Inject lateinit var editorBloggingPromptsViewModel: EditorBloggingPromptsViewModel @Inject lateinit var editorJetpackSocialViewModel: EditorJetpackSocialViewModel + + @Inject lateinit var networkConnectionMonitor: NetworkConnectionMonitor private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -558,6 +560,9 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor } editorMedia.start(siteModel, this) startObserving() + networkConnectionMonitor.isConnected.observe(this) { connected -> + (editorFragment as? GutenbergNetworkConnectionListener)?.onConnectionStatusChange(connected) + } editorFragment?.let { hasSetPostContent = true it.setImageLoader(imageLoader) @@ -3858,11 +3863,6 @@ class EditPostActivity : BaseAppCompatActivity(), EditorFragmentActivity, Editor } } - @Subscribe(threadMode = ThreadMode.MAIN) - fun onEventMainThread(event: ConnectionChangeEvent) { - (editorFragment as? GutenbergNetworkConnectionListener)?.onConnectionStatusChange(event.isConnected) - } - private fun refreshEditorTheme() { val payload = FetchEditorThemePayload(siteModel, gssEnabled = true) dispatcher.dispatch(EditorThemeActionBuilder.newFetchEditorThemeAction(payload)) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java index 1d6d826dedcd..b9a60757c34c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java @@ -10,7 +10,6 @@ import androidx.appcompat.widget.Toolbar; import org.apache.commons.text.StringEscapeUtils; -import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.wordpress.android.R; @@ -42,6 +41,9 @@ public class BlogPreferencesActivity extends BaseAppCompatActivity { @Inject AccountStore mAccountStore; @Inject SiteStore mSiteStore; @Inject Dispatcher mDispatcher; + @Inject NetworkConnectionMonitor mNetworkConnectionMonitor; + + private Boolean mLastConnected; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -84,19 +86,19 @@ public void onCreate(@Nullable Bundle savedInstanceState) { .replace(R.id.fragment_container, siteSettingsFragment, KEY_SETTINGS_FRAGMENT) .commit(); } + + mNetworkConnectionMonitor.isConnected().observe(this, this::onConnectionChanged); } @Override protected void onStart() { super.onStart(); - EventBus.getDefault().register(this); mDispatcher.register(this); } @Override protected void onStop() { mDispatcher.unregister(this); - EventBus.getDefault().unregister(this); super.onStop(); } @@ -117,21 +119,22 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { return super.onOptionsItemSelected(item); } - @SuppressWarnings("unused") - @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(NetworkConnectionMonitor.ConnectionChangeEvent event) { + private void onConnectionChanged(boolean connected) { SiteSettingsFragment siteSettingsFragment = getSettingsFragment(); if (siteSettingsFragment != null) { - if (!event.isConnected()) { + // Toast only on a genuine transition to disconnected, so LiveData replay on re-foregrounding + // doesn't spuriously toast. + if (!connected && mLastConnected != null && mLastConnected) { ToastUtils.showToast(this, getString(R.string.site_settings_disconnected_toast), Duration.LONG); } - siteSettingsFragment.setEditingEnabled(event.isConnected()); + siteSettingsFragment.setEditingEnabled(connected); // TODO: add this back when delete blog is back // https://github.com/wordpress-mobile/WordPress-Android/commit/6a90e3fe46e24ee40abdc4a7f8f0db06f157900c // Checks for stats widgets that were synched with a blog that could be gone now. // StatsWidgetProvider.updateWidgetsOnLogout(this); } + mLastConnected = connected; } @SuppressWarnings("unused") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt index d4e20b7da355..194212e10afa 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt @@ -69,7 +69,7 @@ import org.wordpress.android.models.ReaderPost import org.wordpress.android.models.ReaderPostDiscoverData import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagType -import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor import org.wordpress.android.ui.ActionableEmptyView import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.EmptyViewMessageType @@ -205,6 +205,11 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo @Inject lateinit var displayUtilsWrapper: DisplayUtilsWrapper + @Inject + lateinit var networkConnectionMonitor: NetworkConnectionMonitor + + private var lastConnected: Boolean? = null + private var readerPostAdapter: ReaderPostAdapter? = null private var siteSearchAdapter: ReaderSiteSearchAdapter? = null private var suggestionAdapter: ReaderSearchSuggestionAdapter? = null @@ -572,6 +577,15 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo } } } + + networkConnectionMonitor.isConnected.observe(viewLifecycleOwner) { connected -> + // Only resubmit the search when connectivity actually transitions to connected, so LiveData replay + // on re-foregrounding doesn't re-fire the search. + if (connected && lastConnected != connected) { + currentSearchQuery?.let { submitSearchQuery(it) } + } + lastConnected = connected + } } private fun toggleJetpackBannerIfEnabled(showIfEnabled: Boolean, animateOnScroll: Boolean) { @@ -1370,15 +1384,6 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo dispatcher.dispatch(ReaderActionBuilder.newReaderSearchSitesAction(payload)) } - @Subscribe(threadMode = ThreadMode.MAIN) - fun onEventMainThread(event: ConnectionChangeEvent) { - if (event.isConnected) { - if (currentSearchQuery != null) { - submitSearchQuery(currentSearchQuery!!) - } - } - } - private fun submitSearchQuery(query: String) { if (!isAdded) { return diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt index 9f85be6c80dc..161ce545bb91 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt @@ -10,14 +10,11 @@ import android.text.TextWatcher import android.view.KeyEvent import android.view.inputmethod.EditorInfo import androidx.activity.addCallback -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.SuggestUsersActivityBinding import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent +import org.wordpress.android.networking.NetworkConnectionMonitor import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable @@ -36,6 +33,9 @@ class SuggestionActivity : BaseAppCompatActivity() { @Inject lateinit var viewModel: SuggestionViewModel + + @Inject + lateinit var networkConnectionMonitor: NetworkConnectionMonitor private lateinit var binding: SuggestUsersActivityBinding override fun onCreate(savedInstanceState: Bundle?) { @@ -178,6 +178,11 @@ class SuggestionActivity : BaseAppCompatActivity() { updateEmptyView() }) + + networkConnectionMonitor.isConnected.observe(this) { connected -> + viewModel.onConnectionChanged(connected) + updateEmptyView() + } } private fun exitIfOnlyOneMatchingUser() { @@ -250,23 +255,11 @@ class SuggestionActivity : BaseAppCompatActivity() { override fun onResume() { super.onResume() - EventBus.getDefault().register(this) if (binding.autocompleteText.isAttachedToWindow) { binding.autocompleteText.showDropDown() } } - override fun onPause() { - EventBus.getDefault().unregister(this) - super.onPause() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onEventMainThread(event: ConnectionChangeEvent) { - viewModel.onConnectionChanged(event) - updateEmptyView() - } - companion object { const val SELECTED_VALUE = "SELECTED_VALUE" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt index 96b044c47aef..0999f70c4a43 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModel import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable import org.wordpress.android.ui.suggestion.SuggestionType.Users @@ -59,8 +58,8 @@ class SuggestionViewModel @Inject constructor( private fun supportsSuggestions(site: SiteModel): Boolean = SiteUtils.isAccessedViaWPComRest(site) - fun onConnectionChanged(event: ConnectionChangeEvent) { - if (event.isConnected) { + fun onConnectionChanged(isConnected: Boolean) { + if (isConnected) { suggestionSource.refreshSuggestions() } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt index e23c94020e43..fc4b6137233b 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/suggestion/SuggestionViewModelTest.kt @@ -18,7 +18,6 @@ import org.mockito.kotlin.whenever import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.networking.NetworkConnectionMonitor.ConnectionChangeEvent import org.wordpress.android.ui.suggestion.FinishAttempt.NotExactlyOneAvailable import org.wordpress.android.ui.suggestion.FinishAttempt.OnlyOneAvailable import org.wordpress.android.ui.suggestion.SuggestionType.Users @@ -88,14 +87,14 @@ class SuggestionViewModelTest { @Test fun `onConnectionChanged not connected`() { initViewModel() - viewModel.onConnectionChanged(ConnectionChangeEvent(false)) + viewModel.onConnectionChanged(false) verify(mockSuggestionSource, never()).refreshSuggestions() } @Test fun `onConnectionChanged connected`() { initViewModel() - viewModel.onConnectionChanged(ConnectionChangeEvent(true)) + viewModel.onConnectionChanged(true) verify(mockSuggestionSource).refreshSuggestions() } From 622e7d7226c51fe51aa6a27e62e9b00f9c1b6cf3 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 14:57:10 -0400 Subject: [PATCH 08/10] Simplify connectivity transition guards to primitive booleans --- .../wordpress/android/ui/prefs/BlogPreferencesActivity.java | 4 ++-- .../org/wordpress/android/ui/reader/ReaderPostListFragment.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java index b9a60757c34c..d6ed50a4377d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/BlogPreferencesActivity.java @@ -43,7 +43,7 @@ public class BlogPreferencesActivity extends BaseAppCompatActivity { @Inject Dispatcher mDispatcher; @Inject NetworkConnectionMonitor mNetworkConnectionMonitor; - private Boolean mLastConnected; + private boolean mLastConnected; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -124,7 +124,7 @@ private void onConnectionChanged(boolean connected) { if (siteSettingsFragment != null) { // Toast only on a genuine transition to disconnected, so LiveData replay on re-foregrounding // doesn't spuriously toast. - if (!connected && mLastConnected != null && mLastConnected) { + if (!connected && mLastConnected) { ToastUtils.showToast(this, getString(R.string.site_settings_disconnected_toast), Duration.LONG); } siteSettingsFragment.setEditingEnabled(connected); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt index 194212e10afa..696c0dec71fe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt @@ -208,7 +208,7 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo @Inject lateinit var networkConnectionMonitor: NetworkConnectionMonitor - private var lastConnected: Boolean? = null + private var lastConnected = false private var readerPostAdapter: ReaderPostAdapter? = null private var siteSearchAdapter: ReaderSiteSearchAdapter? = null @@ -581,7 +581,7 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo networkConnectionMonitor.isConnected.observe(viewLifecycleOwner) { connected -> // Only resubmit the search when connectivity actually transitions to connected, so LiveData replay // on re-foregrounding doesn't re-fire the search. - if (connected && lastConnected != connected) { + if (connected && !lastConnected) { currentSearchQuery?.let { submitSearchQuery(it) } } lastConnected = connected From 8d1b21e7f1d242e6e61d5a4b3d9e67beb5d2d71c Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Thu, 23 Jul 2026 15:29:11 -0400 Subject: [PATCH 09/10] Add unit tests for NetworkConnectionMonitor Extract the NetworkCallback logic into @VisibleForTesting onNetworkAvailable/ onNetworkLost so the connected-state model can be tested without the Android framework wiring in start(). Add NetworkConnectionMonitorTest covering: connected on first network, disconnected when the last network is lost, no false disconnect during a handover, de-dupe when unchanged, and re-emit on reconnect. Satisfies the Danger check requiring tests for the new class. --- .../networking/NetworkConnectionMonitor.kt | 23 ++++-- .../NetworkConnectionMonitorTest.kt | 73 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 WordPress/src/test/java/org/wordpress/android/networking/NetworkConnectionMonitorTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt index 7fb80fe93450..d32995851773 100644 --- a/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt +++ b/WordPress/src/main/java/org/wordpress/android/networking/NetworkConnectionMonitor.kt @@ -7,6 +7,7 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Handler import android.os.HandlerThread +import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import org.wordpress.android.util.AppLog @@ -46,14 +47,8 @@ class NetworkConnectionMonitor @Inject constructor() { val thread = HandlerThread("NetworkConnectionMonitor").apply { start() } val callback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - availableNetworks.add(network) - onConnectivityChanged(availableNetworks.isNotEmpty()) - } - override fun onLost(network: Network) { - availableNetworks.remove(network) - onConnectivityChanged(availableNetworks.isNotEmpty()) - } + override fun onAvailable(network: Network) = onNetworkAvailable(network) + override fun onLost(network: Network) = onNetworkLost(network) } val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) @@ -62,6 +57,18 @@ class NetworkConnectionMonitor @Inject constructor() { started = true } + @VisibleForTesting + internal fun onNetworkAvailable(network: Network) { + availableNetworks.add(network) + onConnectivityChanged(availableNetworks.isNotEmpty()) + } + + @VisibleForTesting + internal fun onNetworkLost(network: Network) { + availableNetworks.remove(network) + onConnectivityChanged(availableNetworks.isNotEmpty()) + } + /** * Called on the monitor's background thread. Updates [isConnected] on the first callback and whenever the * connected state actually changes, so observers aren't spammed while connectivity churns. diff --git a/WordPress/src/test/java/org/wordpress/android/networking/NetworkConnectionMonitorTest.kt b/WordPress/src/test/java/org/wordpress/android/networking/NetworkConnectionMonitorTest.kt new file mode 100644 index 000000000000..a340a4524e3d --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/networking/NetworkConnectionMonitorTest.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.networking + +import android.net.Network +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.wordpress.android.BaseUnitTest + +@ExperimentalCoroutinesApi +class NetworkConnectionMonitorTest : BaseUnitTest() { + private lateinit var monitor: NetworkConnectionMonitor + private val emissions = mutableListOf() + + @Before + fun setUp() { + monitor = NetworkConnectionMonitor() + monitor.isConnected.observeForever { emissions.add(it) } + } + + @Test + fun `emits connected when the first network becomes available`() { + monitor.onNetworkAvailable(mock()) + + assertThat(monitor.isConnected.value).isTrue + assertThat(emissions).containsExactly(true) + } + + @Test + fun `emits disconnected when the only network is lost`() { + val network = mock() + monitor.onNetworkAvailable(network) + monitor.onNetworkLost(network) + + assertThat(monitor.isConnected.value).isFalse + assertThat(emissions).containsExactly(true, false) + } + + @Test + fun `does not report a disconnection during a network handover`() { + val wifi = mock() + val cellular = mock() + + monitor.onNetworkAvailable(wifi) + // the replacement network arrives before the old one is torn down + monitor.onNetworkAvailable(cellular) + monitor.onNetworkLost(wifi) + + assertThat(monitor.isConnected.value).isTrue + // only the initial connected emission; the handover is not a change + assertThat(emissions).containsExactly(true) + } + + @Test + fun `does not emit again while the connected state is unchanged`() { + monitor.onNetworkAvailable(mock()) + monitor.onNetworkAvailable(mock()) + + assertThat(emissions).containsExactly(true) + } + + @Test + fun `emits again when reconnecting after a full disconnect`() { + val first = mock() + monitor.onNetworkAvailable(first) + monitor.onNetworkLost(first) + monitor.onNetworkAvailable(mock()) + + assertThat(monitor.isConnected.value).isTrue + assertThat(emissions).containsExactly(true, false, true) + } +} From 32930f25365e8ec9a3b4019cee537253d0ee1a21 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Fri, 24 Jul 2026 07:26:54 -0400 Subject: [PATCH 10/10] Guard connectivity observers against LiveData replay side effects LiveData replays the current value to each new observer, so a side effect that should only run on an offline->online transition also fired on open. - SuggestionActivity: only call viewModel.onConnectionChanged (which refreshes suggestions) on a genuine offline->online transition. The initial replay was triggering a redundant fetch on top of the one started in viewModel.init(). - ReaderPostListFragment: initialise lastConnected to true so the first replayed "connected" value doesn't re-run the search (e.g. on a restored fragment with an active search); only a real transition resubmits. --- .../android/ui/reader/ReaderPostListFragment.kt | 4 +++- .../android/ui/suggestion/SuggestionActivity.kt | 9 ++++++++- gradle.properties | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt index 696c0dec71fe..534f9b35f295 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.kt @@ -208,7 +208,9 @@ class ReaderPostListFragment : ViewPagerFragment(), OnPostSelectedListener, OnFo @Inject lateinit var networkConnectionMonitor: NetworkConnectionMonitor - private var lastConnected = false + // Initialised to true so the first LiveData replay of "connected" (e.g. on a freshly restored fragment + // with an active search) doesn't re-fire the search; only a genuine offline->online transition should. + private var lastConnected = true private var readerPostAdapter: ReaderPostAdapter? = null private var siteSearchAdapter: ReaderSiteSearchAdapter? = null diff --git a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt index 161ce545bb91..5e019477c475 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/suggestion/SuggestionActivity.kt @@ -38,6 +38,8 @@ class SuggestionActivity : BaseAppCompatActivity() { lateinit var networkConnectionMonitor: NetworkConnectionMonitor private lateinit var binding: SuggestUsersActivityBinding + private var lastConnected = true + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (application as WordPress).component().inject(this) @@ -180,7 +182,12 @@ class SuggestionActivity : BaseAppCompatActivity() { }) networkConnectionMonitor.isConnected.observe(this) { connected -> - viewModel.onConnectionChanged(connected) + // Only refresh on a genuine offline->online transition; the initial LiveData replay would + // otherwise trigger a redundant fetch on top of the one already started in viewModel.init(). + if (connected && !lastConnected) { + viewModel.onConnectionChanged(connected) + } + lastConnected = connected updateEmptyView() } } diff --git a/gradle.properties b/gradle.properties index 3487d45aafa4..853c22d90f77 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,3 +6,6 @@ org.gradle.configuration-cache=true # Dependency Analysis Plugin dependency.analysis.android.ignored.variants=release,wordpressRelease,jetpackRelease dependency.analysis.test.analysis=false + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true