diff --git a/CHANGELOG.md b/CHANGELOG.md index 456e6eb2fb..2f7fa0aae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542)) +### Fixes + +- Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560)) + ## 8.18.0 ### Features diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 8a564081ea..04d927ff79 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -28,6 +28,7 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator; import io.sentry.android.core.internal.modules.AssetsModulesLoader; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; @@ -157,7 +158,8 @@ static void initializeIntegrationsAndProcessors( if (options.getConnectionStatusProvider() instanceof NoOpConnectionStatusProvider) { options.setConnectionStatusProvider( - new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider)); + new AndroidConnectionStatusProvider( + context, options, buildInfoProvider, AndroidCurrentDateProvider.getInstance())); } if (options.getCacheDirPath() != null) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index ed8948e0a5..6308f70400 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -8,12 +8,15 @@ import android.net.Network; import android.net.NetworkCapabilities; import android.os.Build; +import androidx.annotation.NonNull; import io.sentry.IConnectionStatusProvider; import io.sentry.ILogger; import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; +import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.List; @@ -31,104 +34,406 @@ public final class AndroidConnectionStatusProvider implements IConnectionStatusProvider { private final @NotNull Context context; - private final @NotNull ILogger logger; + private final @NotNull SentryOptions options; private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull ICurrentDateProvider timeProvider; private final @NotNull List connectionStatusObservers; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private volatile @Nullable NetworkCallback networkCallback; + private static final @NotNull AutoClosableReentrantLock connectivityManagerLock = + new AutoClosableReentrantLock(); + private static volatile @Nullable ConnectivityManager connectivityManager; + + private static final int[] transports = { + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH + }; + + private static final int[] capabilities = new int[2]; + + private final @NotNull Thread initThread; + private volatile @Nullable NetworkCapabilities cachedNetworkCapabilities; + private volatile @Nullable Network currentNetwork; + private volatile long lastCacheUpdateTime = 0; + private static final long CACHE_TTL_MS = 2 * 60 * 1000L; // 2 minutes + + @SuppressLint("InlinedApi") public AndroidConnectionStatusProvider( @NotNull Context context, - @NotNull ILogger logger, - @NotNull BuildInfoProvider buildInfoProvider) { + @NotNull SentryOptions options, + @NotNull BuildInfoProvider buildInfoProvider, + @NotNull ICurrentDateProvider timeProvider) { this.context = ContextUtils.getApplicationContext(context); - this.logger = logger; + this.options = options; this.buildInfoProvider = buildInfoProvider; + this.timeProvider = timeProvider; this.connectionStatusObservers = new ArrayList<>(); + + capabilities[0] = NetworkCapabilities.NET_CAPABILITY_INTERNET; + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + capabilities[1] = NetworkCapabilities.NET_CAPABILITY_VALIDATED; + } + + // Register network callback immediately for caching + //noinspection Convert2MethodRef + initThread = new Thread(() -> ensureNetworkCallbackRegistered()); + initThread.start(); } - @Override - public @NotNull ConnectionStatus getConnectionStatus() { - final ConnectivityManager connectivityManager = getConnectivityManager(context, logger); - if (connectivityManager == null) { - return ConnectionStatus.UNKNOWN; + /** + * Enhanced network connectivity check for Android 15. Checks for NET_CAPABILITY_INTERNET, + * NET_CAPABILITY_VALIDATED, and proper transport types. + * https://medium.com/@doronkakuli/adapting-your-network-connectivity-checks-for-android-15-a-practical-guide-2b1850619294 + */ + @SuppressLint("InlinedApi") + private boolean isNetworkEffectivelyConnected( + final @Nullable NetworkCapabilities networkCapabilities) { + if (networkCapabilities == null) { + return false; + } + + // Check for general internet capability AND validated status + boolean hasInternetAndValidated = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { + hasInternetAndValidated = + hasInternetAndValidated + && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); } - return getConnectionStatus(context, connectivityManager, logger); - // getActiveNetworkInfo might return null if VPN doesn't specify its - // underlying network - // when min. API 24, use: - // connectivityManager.registerDefaultNetworkCallback(...) + if (!hasInternetAndValidated) { + return false; + } + + // Additionally, ensure it's a recognized transport type for general internet access + for (final int transport : transports) { + if (networkCapabilities.hasTransport(transport)) { + return true; + } + } + return false; } - @Override - public @Nullable String getConnectionType() { - return getConnectionType(context, logger, buildInfoProvider); + /** Get connection status from cached NetworkCapabilities or fallback to legacy method. */ + private @NotNull ConnectionStatus getConnectionStatusFromCache() { + if (cachedNetworkCapabilities != null) { + return isNetworkEffectivelyConnected(cachedNetworkCapabilities) + ? ConnectionStatus.CONNECTED + : ConnectionStatus.DISCONNECTED; + } + + // Fallback to legacy method when NetworkCapabilities not available + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + return getConnectionStatus(context, connectivityManager, options.getLogger()); + } + + return ConnectionStatus.UNKNOWN; } - @Override - public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - connectionStatusObservers.add(observer); + /** Get connection type from cached NetworkCapabilities or fallback to legacy method. */ + private @Nullable String getConnectionTypeFromCache() { + final NetworkCapabilities capabilities = cachedNetworkCapabilities; + if (capabilities != null) { + return getConnectionType(capabilities); } - if (networkCallback == null) { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (networkCallback == null) { - final @NotNull NetworkCallback newNetworkCallback = - new NetworkCallback() { - @Override - public void onAvailable(final @NotNull Network network) { - updateObservers(); - } + // Fallback to legacy method when NetworkCapabilities not available + return getConnectionType(context, options.getLogger(), buildInfoProvider); + } - @Override - public void onUnavailable() { - updateObservers(); - } + private void ensureNetworkCallbackRegistered() { + if (networkCallback != null) { + return; // Already registered + } - @Override - public void onLost(final @NotNull Network network) { - updateObservers(); - } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback != null) { + return; + } - public void updateObservers() { - final @NotNull ConnectionStatus status = getConnectionStatus(); - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - for (final @NotNull IConnectionStatusObserver observer : - connectionStatusObservers) { - observer.onConnectionStatusChanged(status); - } + final @NotNull NetworkCallback callback = + new NetworkCallback() { + @Override + public void onAvailable(final @NotNull Network network) { + currentNetwork = network; + } + + @Override + public void onUnavailable() { + clearCacheAndNotifyObservers(); + } + + @Override + public void onLost(final @NotNull Network network) { + if (!network.equals(currentNetwork)) { + return; + } + clearCacheAndNotifyObservers(); + } + + private void clearCacheAndNotifyObservers() { + // Clear cached capabilities and network reference atomically + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log(SentryLevel.DEBUG, "Cache cleared - network lost/unavailable"); + + // Notify all observers with DISCONNECTED status directly + // No need to query ConnectivityManager - we know the network is gone + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(ConnectionStatus.DISCONNECTED); + } + } + } + + @Override + public void onCapabilitiesChanged( + @NonNull Network network, @NonNull NetworkCapabilities networkCapabilities) { + if (!network.equals(currentNetwork)) { + return; + } + updateCacheAndNotifyObservers(network, networkCapabilities); + } + + private void updateCacheAndNotifyObservers( + @Nullable Network network, @Nullable NetworkCapabilities networkCapabilities) { + // Check if this change is meaningful before notifying observers + final boolean shouldUpdate = isSignificantChange(networkCapabilities); + + // Only notify observers if something meaningful changed + if (shouldUpdate) { + updateCache(networkCapabilities); + + final @NotNull ConnectionStatus status = getConnectionStatusFromCache(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + for (final @NotNull IConnectionStatusObserver observer : + connectionStatusObservers) { + observer.onConnectionStatusChanged(status); } } - }; + } + } + + /** + * Check if NetworkCapabilities change is significant for our observers. Only notify for + * changes that affect connectivity status or connection type. + */ + private boolean isSignificantChange(@Nullable NetworkCapabilities newCapabilities) { + final NetworkCapabilities oldCapabilities = cachedNetworkCapabilities; + + // Always significant if transitioning between null and non-null + if ((oldCapabilities == null) != (newCapabilities == null)) { + return true; + } + + // If both null, no change + if (oldCapabilities == null && newCapabilities == null) { + return false; + } + + // Check significant capability changes + if (hasSignificantCapabilityChanges(oldCapabilities, newCapabilities)) { + return true; + } + + // Check significant transport changes + if (hasSignificantTransportChanges(oldCapabilities, newCapabilities)) { + return true; + } + + return false; + } + + /** Check if capabilities that affect connectivity status changed. */ + private boolean hasSignificantCapabilityChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check capabilities we care about for connectivity determination + for (int capability : capabilities) { + if (capability != 0 + && old.hasCapability(capability) != new_.hasCapability(capability)) { + return true; + } + } + + return false; + } + + /** Check if transport types that affect connection type changed. */ + private boolean hasSignificantTransportChanges( + @NotNull NetworkCapabilities old, @NotNull NetworkCapabilities new_) { + // Check transports we care about for connection type determination + for (int transport : transports) { + if (old.hasTransport(transport) != new_.hasTransport(transport)) { + return true; + } + } + + return false; + } + }; + + if (registerNetworkCallback(context, options.getLogger(), buildInfoProvider, callback)) { + networkCallback = callback; + options.getLogger().log(SentryLevel.DEBUG, "Network callback registered successfully"); + } else { + options.getLogger().log(SentryLevel.WARNING, "Failed to register network callback"); + } + } + } + + @SuppressLint({"NewApi", "MissingPermission"}) + private void updateCache(@Nullable NetworkCapabilities networkCapabilities) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + try { + if (networkCapabilities != null) { + cachedNetworkCapabilities = networkCapabilities; + } else { + if (!Permissions.hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + options + .getLogger() + .log( + SentryLevel.INFO, + "No permission (ACCESS_NETWORK_STATE) to check network status."); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.M) { + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + return; + } + + // Fallback: query current active network + final ConnectivityManager connectivityManager = + getConnectivityManager(context, options.getLogger()); + if (connectivityManager != null) { + final Network activeNetwork = connectivityManager.getActiveNetwork(); - if (registerNetworkCallback(context, logger, buildInfoProvider, newNetworkCallback)) { - networkCallback = newNetworkCallback; - return true; + cachedNetworkCapabilities = + activeNetwork != null + ? connectivityManager.getNetworkCapabilities(activeNetwork) + : null; } else { - return false; + cachedNetworkCapabilities = + null; // Clear cached capabilities if connectivity manager is null } } + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); + + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Cache updated - Status: " + + getConnectionStatusFromCache() + + ", Type: " + + getConnectionTypeFromCache()); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.WARNING, "Failed to update connection status cache", t); + cachedNetworkCapabilities = null; + lastCacheUpdateTime = timeProvider.getCurrentTimeMillis(); } } - // networkCallback is already registered, so we can safely return true - return true; + } + + private boolean isCacheValid() { + return (timeProvider.getCurrentTimeMillis() - lastCacheUpdateTime) < CACHE_TTL_MS; + } + + @Override + public @NotNull ConnectionStatus getConnectionStatus() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionStatusFromCache(); + } + + @Override + public @Nullable String getConnectionType() { + if (!isCacheValid()) { + updateCache(null); + } + return getConnectionTypeFromCache(); + } + + @Override + public boolean addConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.add(observer); + } + ensureNetworkCallbackRegistered(); + + // Network callback is already registered during initialization + return networkCallback != null; } @Override public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObserver observer) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { connectionStatusObservers.remove(observer); - if (connectionStatusObservers.isEmpty()) { - if (networkCallback != null) { - unregisterNetworkCallback(context, logger, networkCallback); - networkCallback = null; - } - } + // Keep the callback registered for caching even if no observers } } + /** Clean up resources - should be called when the provider is no longer needed */ + @Override + public void close() { + try { + options + .getExecutorService() + .submit( + () -> { + final NetworkCallback callbackRef; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + connectionStatusObservers.clear(); + + callbackRef = networkCallback; + networkCallback = null; + + if (callbackRef != null) { + unregisterNetworkCallback(context, options.getLogger(), callbackRef); + } + // Clear cached state + cachedNetworkCapabilities = null; + currentNetwork = null; + lastCacheUpdateTime = 0; + } + try (final @NotNull ISentryLifecycleToken ignored = + connectivityManagerLock.acquire()) { + connectivityManager = null; + } + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, "Error submitting AndroidConnectionStatusProvider task", t); + } + } + + /** + * Get the cached NetworkCapabilities for advanced use cases. Returns null if cache is stale or no + * capabilities are available. + * + * @return cached NetworkCapabilities or null + */ + @TestOnly + @Nullable + public NetworkCapabilities getCachedNetworkCapabilities() { + return cachedNetworkCapabilities; + } + /** * Return the Connection status * @@ -295,12 +600,22 @@ public void removeConnectionStatusObserver(final @NotNull IConnectionStatusObser private static @Nullable ConnectivityManager getConnectivityManager( final @NotNull Context context, final @NotNull ILogger logger) { - final ConnectivityManager connectivityManager = - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (connectivityManager == null) { - logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); + if (connectivityManager != null) { + return connectivityManager; + } + + try (final @NotNull ISentryLifecycleToken ignored = connectivityManagerLock.acquire()) { + if (connectivityManager != null) { + return connectivityManager; // Double-checked locking + } + + connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) { + logger.log(SentryLevel.INFO, "ConnectivityManager is null and cannot check network status"); + } + return connectivityManager; } - return connectivityManager; } @SuppressLint({"MissingPermission", "NewApi"}) @@ -358,4 +673,15 @@ public List getStatusObservers() { public NetworkCallback getNetworkCallback() { return networkCallback; } + + @TestOnly + @NotNull + public Thread getInitThread() { + return initThread; + } + + @TestOnly + public static void setConnectivityManager(final @Nullable ConnectivityManager cm) { + connectivityManager = cm; + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt index b15ab4e605..a362885d63 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidConnectionStatusProviderTest.kt @@ -1,18 +1,27 @@ package io.sentry.android.core +import android.Manifest import android.content.Context import android.content.pm.PackageManager.PERMISSION_DENIED +import android.content.pm.PackageManager.PERMISSION_GRANTED import android.net.ConnectivityManager import android.net.ConnectivityManager.NetworkCallback import android.net.Network import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED import android.net.NetworkCapabilities.TRANSPORT_CELLULAR import android.net.NetworkCapabilities.TRANSPORT_ETHERNET import android.net.NetworkCapabilities.TRANSPORT_WIFI import android.net.NetworkInfo import android.os.Build import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.SentryOptions import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider +import io.sentry.test.ImmediateExecutorService +import io.sentry.transport.ICurrentDateProvider +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -21,11 +30,13 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never +import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever class AndroidConnectionStatusProviderTest { @@ -34,14 +45,24 @@ class AndroidConnectionStatusProviderTest { private lateinit var connectivityManager: ConnectivityManager private lateinit var networkInfo: NetworkInfo private lateinit var buildInfo: BuildInfoProvider + private lateinit var timeProvider: ICurrentDateProvider + private lateinit var options: SentryOptions private lateinit var network: Network private lateinit var networkCapabilities: NetworkCapabilities + private lateinit var logger: ILogger + + private var currentTime = 1000L @BeforeTest fun beforeTest() { contextMock = mock() connectivityManager = mock() - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) + whenever(contextMock.getSystemService(Context.CONNECTIVITY_SERVICE)) + .thenReturn(connectivityManager) + whenever( + contextMock.checkPermission(eq(Manifest.permission.ACCESS_NETWORK_STATE), any(), any()) + ) + .thenReturn(PERMISSION_GRANTED) networkInfo = mock() whenever(connectivityManager.activeNetworkInfo).thenReturn(networkInfo) @@ -54,13 +75,38 @@ class AndroidConnectionStatusProviderTest { networkCapabilities = mock() whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(networkCapabilities) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(networkCapabilities.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(networkCapabilities.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + + timeProvider = mock() + whenever(timeProvider.currentTimeMillis).thenAnswer { currentTime } + + logger = mock() + options = SentryOptions() + options.setLogger(logger) + options.executorService = ImmediateExecutorService() + + // Reset current time for each test to ensure cache isolation + currentTime = 1000L - connectionStatusProvider = AndroidConnectionStatusProvider(contextMock, mock(), buildInfo) + connectionStatusProvider = + AndroidConnectionStatusProvider(contextMock, options, buildInfo, timeProvider) + + // Wait for async callback registration to complete + connectionStatusProvider.initThread.join() + } + + @AfterTest + fun `tear down`() { + // clear the cache and ensure proper cleanup + connectionStatusProvider.close() } @Test fun `When network is active and connected with permission, return CONNECTED for isConnected`() { whenever(networkInfo.isConnected).thenReturn(true) + assertEquals( IConnectionStatusProvider.ConnectionStatus.CONNECTED, connectionStatusProvider.connectionStatus, @@ -89,6 +135,8 @@ class AndroidConnectionStatusProviderTest { @Test fun `When network is not active, return DISCONNECTED for isConnected`() { + whenever(connectivityManager.activeNetwork).thenReturn(null) + assertEquals( IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, connectionStatusProvider.connectionStatus, @@ -97,98 +145,86 @@ class AndroidConnectionStatusProviderTest { @Test fun `When ConnectivityManager is not available, return UNKNOWN for isConnected`() { - whenever(contextMock.getSystemService(any())).thenReturn(null) + // First close the existing provider to clean up static state + connectionStatusProvider.close() + + // Create a fresh context mock that returns null for ConnectivityManager + val nullConnectivityContext = mock() + whenever(nullConnectivityContext.getSystemService(any())).thenReturn(null) + whenever( + nullConnectivityContext.checkPermission( + eq(Manifest.permission.ACCESS_NETWORK_STATE), + any(), + any(), + ) + ) + .thenReturn(PERMISSION_GRANTED) + + // Create a new provider with the null connectivity manager + val providerWithNullConnectivity = + AndroidConnectionStatusProvider(nullConnectivityContext, options, buildInfo, timeProvider) + providerWithNullConnectivity.initThread.join() // Wait for async init to complete + assertEquals( IConnectionStatusProvider.ConnectionStatus.UNKNOWN, - connectionStatusProvider.connectionStatus, + providerWithNullConnectivity.connectionStatus, ) + + providerWithNullConnectivity.close() } @Test fun `When ConnectivityManager is not available, return null for getConnectionType`() { - assertNull(AndroidConnectionStatusProvider.getConnectionType(mock(), mock(), buildInfo)) + whenever(contextMock.getSystemService(any())).thenReturn(null) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When there's no permission, return null for getConnectionType`() { whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network is not active, return null for getConnectionType`() { - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) + whenever(connectivityManager.activeNetwork).thenReturn(null) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network capabilities are not available, return null for getConnectionType`() { - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(null) + + assertNull(connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_WIFI, return wifi`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(true) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) - assertEquals( - "wifi", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) + assertEquals("wifi", connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_ETHERNET, return ethernet`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(true) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(false) - assertEquals( - "ethernet", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) + assertEquals("ethernet", connectionStatusProvider.connectionType) } @Test fun `When network capabilities has TRANSPORT_CELLULAR, return cellular`() { + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(true) - assertEquals( - "cellular", - AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo), - ) - } - - @Test - fun `When there's no permission, do not register any NetworkCallback`() { - val buildInfo = mock() - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) - val registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - contextMock, - mock(), - buildInfo, - mock(), - ) - - assertFalse(registered) - verify(connectivityManager, never()).registerDefaultNetworkCallback(any()) - } - - @Test - fun `When sdkInfoVersion is not min N, do not register any NetworkCallback`() { - whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - val registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - contextMock, - mock(), - buildInfo, - mock(), - ) - - assertFalse(registered) - verify(connectivityManager, never()).registerDefaultNetworkCallback(any()) + assertEquals("cellular", connectionStatusProvider.connectionType) } @Test @@ -199,7 +235,7 @@ class AndroidConnectionStatusProviderTest { val registered = AndroidConnectionStatusProvider.registerNetworkCallback( contextMock, - mock(), + logger, buildInfo, mock(), ) @@ -211,17 +247,16 @@ class AndroidConnectionStatusProviderTest { @Test fun `unregisterNetworkCallback calls connectivityManager unregisterDefaultNetworkCallback`() { whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) - AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, mock(), mock()) + AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, logger, mock()) verify(connectivityManager).unregisterNetworkCallback(any()) } @Test fun `When connectivityManager getActiveNetwork throws an exception, getConnectionType returns null`() { - whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S) whenever(connectivityManager.activeNetwork).thenThrow(SecurityException("Android OS Bug")) - assertNull(AndroidConnectionStatusProvider.getConnectionType(contextMock, mock(), buildInfo)) + assertNull(connectionStatusProvider.connectionType) } @Test @@ -231,27 +266,13 @@ class AndroidConnectionStatusProviderTest { assertFalse( AndroidConnectionStatusProvider.registerNetworkCallback( contextMock, - mock(), + logger, buildInfo, mock(), ) ) } - @Test - fun `When connectivityManager unregisterDefaultCallback throws an exception, it gets swallowed`() { - whenever(connectivityManager.registerDefaultNetworkCallback(any())) - .thenThrow(SecurityException("Android OS Bug")) - - var failed = false - try { - AndroidConnectionStatusProvider.unregisterNetworkCallback(contextMock, mock(), mock()) - } catch (t: Throwable) { - failed = true - } - assertFalse(failed) - } - @Test fun `connectionStatus returns NO_PERMISSIONS when context does not hold the permission`() { whenever(contextMock.checkPermission(any(), any(), any())).thenReturn(PERMISSION_DENIED) @@ -261,12 +282,6 @@ class AndroidConnectionStatusProviderTest { ) } - @Test - fun `connectionStatus returns ethernet when underlying mechanism provides ethernet`() { - whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(true) - assertEquals("ethernet", connectionStatusProvider.connectionType) - } - @Test fun `adding and removing an observer works correctly`() { whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) @@ -279,25 +294,341 @@ class AndroidConnectionStatusProviderTest { connectionStatusProvider.removeConnectionStatusObserver(observer) assertTrue(connectionStatusProvider.statusObservers.isEmpty()) - assertNull(connectionStatusProvider.networkCallback) } @Test - fun `underlying callbacks correctly trigger update`() { + fun `cache TTL works correctly`() { + // Setup: Mock network info to return connected + whenever(networkInfo.isConnected).thenReturn(true) + + // For API level M, the code uses getActiveNetwork() and getNetworkCapabilities() + // Let's track calls to these methods to verify caching behavior + + // Make the first call to establish baseline + val firstResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, firstResult) + + // Count how many times getActiveNetwork was called so far (includes any initialization calls) + val initialCallCount = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Advance time by 1 minute (less than 2 minute TTL) + currentTime += 60 * 1000L + + // Second call should use cache - no additional calls to getActiveNetwork + val secondResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, secondResult) + + val callCountAfterSecond = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Verify no additional calls were made (cache was used) + assertEquals(initialCallCount, callCountAfterSecond, "Second call should use cache") + + // Advance time beyond TTL (total 3 minutes) + currentTime += 2 * 60 * 1000L + + // Third call should refresh cache - should make new calls to getActiveNetwork + val thirdResult = connectionStatusProvider.connectionStatus + assertEquals(IConnectionStatusProvider.ConnectionStatus.CONNECTED, thirdResult) + + val callCountAfterThird = + mockingDetails(connectivityManager).invocations.count { it.method.name == "getActiveNetwork" } + + // Verify that new calls were made (cache was refreshed) + assertTrue(callCountAfterThird > callCountAfterSecond, "Third call should refresh cache") + + // All results should be consistent + assertEquals(firstResult, secondResult) + assertEquals(secondResult, thirdResult) + } + + @Test + fun `observers are only notified for significant changes`() { whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + // Get the callback that was registered + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + // Create network capabilities for testing + val oldCaps = mock() + whenever(oldCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(oldCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(oldCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // First callback with capabilities - should notify + callback.onCapabilitiesChanged(network, oldCaps) + + // Second callback with same significant capabilities - should NOT notify additional times + callback.onCapabilitiesChanged(network, newCaps) + + // Only first change should trigger notification + verify(observer, times(1)).onConnectionStatusChanged(any()) + } - var callback: NetworkCallback? = null - whenever(connectivityManager.registerDefaultNetworkCallback(any())).then { invocation -> - callback = invocation.getArgument(0, NetworkCallback::class.java) as NetworkCallback - Unit - } + @Test + fun `observers are notified when significant capabilities change`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) val observer = mock() connectionStatusProvider.addConnectionStatusObserver(observer) - callback!!.onAvailable(mock()) - callback!!.onUnavailable() - callback!!.onLost(mock()) - connectionStatusProvider.removeConnectionStatusObserver(observer) - verify(observer, times(3)).onConnectionStatusChanged(any()) + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + val oldCaps = mock() + whenever(oldCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(oldCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(false) // Not validated + whenever(oldCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(oldCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(oldCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) // Now validated + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + callback.onCapabilitiesChanged(network, oldCaps) + callback.onCapabilitiesChanged(network, newCaps) + + // Should be notified for both changes (validation state changed) + verify(observer, times(2)).onConnectionStatusChanged(any()) + } + + @Test + fun `observers are notified when transport changes`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // IMPORTANT: Set network as current first + callback.onAvailable(network) + + val wifiCaps = mock() + whenever(wifiCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(wifiCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(wifiCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(wifiCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(wifiCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + val cellularCaps = mock() + whenever(cellularCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(cellularCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(cellularCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(false) + whenever(cellularCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(true) + whenever(cellularCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + callback.onCapabilitiesChanged(network, wifiCaps) + callback.onCapabilitiesChanged(network, cellularCaps) + + // Should be notified for both changes (transport changed) + verify(observer, times(2)).onConnectionStatusChanged(any()) + } + + @Test + fun `onLost clears cache and notifies with DISCONNECTED`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // Set current network + callback.onAvailable(network) + + // Lose the network + callback.onLost(network) + + assertNull(connectionStatusProvider.cachedNetworkCapabilities) + verify(observer) + .onConnectionStatusChanged(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + + @Test + fun `onUnavailable clears cache and notifies with DISCONNECTED`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + callback.onUnavailable() + + assertNull(connectionStatusProvider.cachedNetworkCapabilities) + verify(observer) + .onConnectionStatusChanged(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) + } + + @Test + fun `onLost for different network is ignored`() { + // Create a new provider with API level N for network callback support + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + val network1 = mock() + val network2 = mock() + + // Set current network + callback.onAvailable(network1) + + // Lose a different network - should be ignored + callback.onLost(network2) + + verifyNoInteractions(observer) + } + + @Test + fun `isNetworkEffectivelyConnected works correctly for Android 15`() { + // Test case: has internet and validated capabilities with good transport + val goodCaps = mock() + whenever(goodCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(goodCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(goodCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(goodCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(goodCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // Override the mock to return good capabilities + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(goodCaps) + + // Force cache invalidation by advancing time beyond TTL + currentTime += 3 * 60 * 1000L // 3 minutes + + // Should return CONNECTED for good capabilities + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + + // Test case: missing validated capability + val unvalidatedCaps = mock() + whenever(unvalidatedCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(unvalidatedCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(false) + whenever(unvalidatedCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + + whenever(connectivityManager.getNetworkCapabilities(any())).thenReturn(unvalidatedCaps) + + // Force cache invalidation again + currentTime += 3 * 60 * 1000L + + assertEquals( + IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, + connectionStatusProvider.connectionStatus, + ) + } + + @Test + fun `API level below M falls back to legacy method`() { + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.LOLLIPOP) + whenever(networkInfo.isConnected).thenReturn(true) + + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + } + + @Test + fun `onCapabilitiesChanged updates cache`() { + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + + val observer = mock() + connectionStatusProvider.addConnectionStatusObserver(observer) + + val callbackCaptor = argumentCaptor() + verify(connectivityManager).registerDefaultNetworkCallback(callbackCaptor.capture()) + val callback = callbackCaptor.firstValue + + // Set network as current first + callback.onAvailable(network) + + // Create initial capabilities - CONNECTED state (wifi + validated) + val initialCaps = mock() + whenever(initialCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(initialCaps.hasCapability(NET_CAPABILITY_VALIDATED)).thenReturn(true) + whenever(initialCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(true) + whenever(initialCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(false) + whenever(initialCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // First callback with initial capabilities + callback.onCapabilitiesChanged(network, initialCaps) + + // Verify cache contains the initial capabilities + assertEquals(initialCaps, connectionStatusProvider.cachedNetworkCapabilities) + + // Verify initial state - should be CONNECTED with wifi + assertEquals( + IConnectionStatusProvider.ConnectionStatus.CONNECTED, + connectionStatusProvider.connectionStatus, + ) + assertEquals("wifi", connectionStatusProvider.connectionType) + + // Create new capabilities - DISCONNECTED state (cellular but not validated) + val newCaps = mock() + whenever(newCaps.hasCapability(NET_CAPABILITY_INTERNET)).thenReturn(true) + whenever(newCaps.hasCapability(NET_CAPABILITY_VALIDATED)) + .thenReturn(false) // Not validated = DISCONNECTED + whenever(newCaps.hasTransport(TRANSPORT_WIFI)).thenReturn(false) + whenever(newCaps.hasTransport(TRANSPORT_CELLULAR)).thenReturn(true) + whenever(newCaps.hasTransport(TRANSPORT_ETHERNET)).thenReturn(false) + + // Second callback with changed capabilities + callback.onCapabilitiesChanged(network, newCaps) + + // Verify cache is updated with new capabilities + assertEquals(newCaps, connectionStatusProvider.cachedNetworkCapabilities) + + // Verify that subsequent calls use the updated cache + // Both connection status AND type should change + assertEquals( + IConnectionStatusProvider.ConnectionStatus.DISCONNECTED, + connectionStatusProvider.connectionStatus, + ) + assertEquals("cellular", connectionStatusProvider.connectionType) + + // Verify observer was notified of the changes (both calls should notify since capabilities + // changed significantly) + verify(observer, times(2)).onConnectionStatusChanged(any()) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index fd18e3d75b..768fe87fbb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -16,9 +16,12 @@ import io.sentry.SentryNanotimeDate import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback +import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider import io.sentry.test.DeferredExecutorService import io.sentry.test.ImmediateExecutorService import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -47,12 +50,6 @@ class NetworkBreadcrumbsIntegrationTest { var nowMs: Long = 0 val network = mock() - init { - whenever(mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))) - .thenReturn(connectivityManager) - } - fun getSut( enableNetworkEventBreadcrumbs: Boolean = true, buildInfo: BuildInfoProvider = mockBuildInfoProvider, @@ -73,6 +70,18 @@ class NetworkBreadcrumbsIntegrationTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + whenever(fixture.mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) + whenever(fixture.context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))) + .thenReturn(fixture.connectivityManager) + } + + @AfterTest + fun `tear down`() { + AndroidConnectionStatusProvider.setConnectivityManager(null) + } + @Test fun `When network events breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 5b707482b3..b08be88fc6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -738,7 +738,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun withScope (Lio/sentry/ScopeCallback;)V } -public abstract interface class io/sentry/IConnectionStatusProvider { +public abstract interface class io/sentry/IConnectionStatusProvider : java/io/Closeable { public abstract fun addConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)Z public abstract fun getConnectionStatus ()Lio/sentry/IConnectionStatusProvider$ConnectionStatus; public abstract fun getConnectionType ()Ljava/lang/String; @@ -1452,6 +1452,7 @@ public final class io/sentry/NoOpCompositePerformanceCollector : io/sentry/Compo public final class io/sentry/NoOpConnectionStatusProvider : io/sentry/IConnectionStatusProvider { public fun ()V public fun addConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)Z + public fun close ()V public fun getConnectionStatus ()Lio/sentry/IConnectionStatusProvider$ConnectionStatus; public fun getConnectionType ()Ljava/lang/String; public fun removeConnectionStatusObserver (Lio/sentry/IConnectionStatusProvider$IConnectionStatusObserver;)V diff --git a/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java b/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java index 1d75098e56..bd897882d7 100644 --- a/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java +++ b/sentry/src/main/java/io/sentry/IConnectionStatusProvider.java @@ -1,11 +1,12 @@ package io.sentry; +import java.io.Closeable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public interface IConnectionStatusProvider { +public interface IConnectionStatusProvider extends Closeable { enum ConnectionStatus { UNKNOWN, diff --git a/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java b/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java index a1d66c9115..765c2c0537 100644 --- a/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java +++ b/sentry/src/main/java/io/sentry/NoOpConnectionStatusProvider.java @@ -1,5 +1,6 @@ package io.sentry; +import java.io.IOException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -25,4 +26,9 @@ public boolean addConnectionStatusObserver(@NotNull IConnectionStatusObserver ob public void removeConnectionStatusObserver(@NotNull IConnectionStatusObserver observer) { // no-op } + + @Override + public void close() throws IOException { + // no-op + } } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index fa1b7c2a81..4b16d71b93 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -446,6 +446,7 @@ public void close(final boolean isRestarting) { getOptions().getTransactionProfiler().close(); getOptions().getContinuousProfiler().close(true); getOptions().getCompositePerformanceCollector().close(); + getOptions().getConnectionStatusProvider().close(); final @NotNull ISentryExecutorService executorService = getOptions().getExecutorService(); if (isRestarting) { executorService.submit( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d0983db825..469b2f4b16 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -592,6 +592,8 @@ class SentryOptionsTest { val options = SentryOptions() val customProvider = object : IConnectionStatusProvider { + override fun close() = Unit + override fun getConnectionStatus(): IConnectionStatusProvider.ConnectionStatus { return IConnectionStatusProvider.ConnectionStatus.UNKNOWN }