From 914b17bdd0f97c2d3d94cc54db78a4a926850897 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 23 Sep 2026 09:22:48 +0200 Subject: [PATCH 1/9] feat(android): Add cellular network technology to connection type Refine device.connection_type from cellular to cellular_4g or cellular_5g when the network technology is known. From Android 12 the generation comes from the display info listener, which needs no permission. On older versions it is read from TelephonyManager only when the hosting app already holds READ_PHONE_STATE or READ_BASIC_PHONE_STATE, which the SDK does not declare itself. Fixes #3886 --- .../util/AndroidConnectionStatusProvider.java | 31 +- .../CellularNetworkTechnologyProvider.java | 227 ++++++++++++++ .../AndroidConnectionStatusProviderTest.kt | 49 +++ .../CellularNetworkTechnologyProviderTest.kt | 289 ++++++++++++++++++ 4 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt 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 e908f392e1..1618548fb4 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 @@ -45,6 +45,7 @@ public final class AndroidConnectionStatusProvider private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull MonotonicTicker ticker; private final @NotNull List connectionStatusObservers; + private final @NotNull CellularNetworkTechnologyProvider cellularNetworkTechnologyProvider; private final @Nullable Handler handler; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); private volatile @Nullable NetworkCallback networkCallback; @@ -94,6 +95,12 @@ public AndroidConnectionStatusProvider( this.cacheFreshUntil = Deadline.passed(ticker); this.handler = handler; this.connectionStatusObservers = new ArrayList<>(); + this.cellularNetworkTechnologyProvider = + new CellularNetworkTechnologyProvider( + this.context, + options.getLogger(), + buildInfoProvider, + runnable -> options.getExecutorService().submit(runnable)); capabilities[0] = NetworkCapabilities.NET_CAPABILITY_INTERNET; if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { @@ -163,11 +170,28 @@ private boolean isNetworkEffectivelyConnected( private @Nullable String getConnectionTypeFromCache() { final NetworkCapabilities capabilities = cachedNetworkCapabilities; if (capabilities != null) { - return getConnectionType(capabilities); + return withCellularNetworkTechnology(getConnectionType(capabilities)); } // Fallback to legacy method when NetworkCapabilities not available - return getConnectionType(context, options.getLogger(), buildInfoProvider); + return withCellularNetworkTechnology( + getConnectionType(context, options.getLogger(), buildInfoProvider)); + } + + /** + * Refines a cellular connection type with the network technology, for example {@code + * cellular_5g}. Other connection types and unknown technologies are returned unchanged. + */ + private @Nullable String withCellularNetworkTechnology(final @Nullable String connectionType) { + if (!"cellular".equals(connectionType)) { + return connectionType; + } + final @Nullable String technology = + cellularNetworkTechnologyProvider.getCellularNetworkTechnology(); + if (technology == null) { + return connectionType; + } + return connectionType + "_" + technology; } private void ensureNetworkCallbackRegistered() { @@ -179,6 +203,8 @@ private void ensureNetworkCallbackRegistered() { return; // Already registered } + cellularNetworkTechnologyProvider.register(); + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (networkCallback != null) { return; @@ -459,6 +485,7 @@ private void unregisterNetworkCallback(final boolean clearObservers) { if (callbackRef != null) { unregisterNetworkCallback(context, options.getLogger(), callbackRef); } + cellularNetworkTechnologyProvider.unregister(); // Clear cached state cachedNetworkCapabilities = null; currentNetwork = null; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java new file mode 100644 index 0000000000..e1d4898ecf --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java @@ -0,0 +1,227 @@ +package io.sentry.android.core.internal.util; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.telephony.TelephonyCallback; +import android.telephony.TelephonyDisplayInfo; +import android.telephony.TelephonyManager; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import io.sentry.ILogger; +import io.sentry.SentryLevel; +import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; +import java.util.concurrent.Executor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Reports the generation of the cellular network technology the device currently uses for data, for + * example {@code 4g} or {@code 5g}. + * + *

From Android 12 (API 31) the value comes from {@link TelephonyCallback.DisplayInfoListener}, + * which requires no permission and reports what the device shows to the user. On older versions it + * is read from {@link TelephonyManager#getDataNetworkType()}, which the SDK only calls when the + * hosting app already holds one of the required permissions. The SDK never declares those + * permissions itself, so apps without them simply get no technology reported. + */ +@ApiStatus.Internal +public final class CellularNetworkTechnologyProvider { + + public static final @NotNull String GENERATION_2G = "2g"; + public static final @NotNull String GENERATION_3G = "3g"; + public static final @NotNull String GENERATION_4G = "4g"; + public static final @NotNull String GENERATION_5G = "5g"; + + private final @NotNull Context context; + private final @NotNull ILogger logger; + private final @NotNull BuildInfoProvider buildInfoProvider; + private final @NotNull Executor executor; + + /** + * Set from {@link TelephonyCallback.DisplayInfoListener} on API 31 and above. Declared as {@link + * Object} so that loading this class on older devices never has to resolve a type that does not + * exist there. + */ + private volatile @Nullable Object displayInfoCallback; + + private volatile @Nullable String displayInfoTechnology; + + public CellularNetworkTechnologyProvider( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider, + final @NotNull Executor executor) { + this.context = ContextUtils.getApplicationContext(context); + this.logger = logger; + this.buildInfoProvider = buildInfoProvider; + this.executor = executor; + } + + /** + * The generation of the cellular network technology currently used for data, or {@code null} when + * it is unknown. + */ + public @Nullable String getCellularNetworkTechnology() { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { + return displayInfoTechnology; + } + return getDataNetworkTechnology(); + } + + /** + * Starts listening for display info changes on API 31 and above. Does nothing on older versions, + * where the technology is read on demand instead. + */ + @SuppressLint("NewApi") + public void register() { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.S) { + return; + } + if (displayInfoCallback != null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + final @NotNull DisplayInfoCallback callback = new DisplayInfoCallback(this); + telephonyManager.registerTelephonyCallback(executor, callback); + displayInfoCallback = callback; + logger.log(SentryLevel.DEBUG, "Started listening for cellular network technology changes."); + } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { + // Devices without telephony support and processes that are not allowed to listen throw + // here, and the technology is optional data. + logger.log(SentryLevel.INFO, "Could not listen for cellular network technology changes.", e); + } + } + + /** Stops listening for display info changes and forgets the last reported technology. */ + @SuppressLint("NewApi") + public void unregister() { + final @Nullable Object callback = displayInfoCallback; + displayInfoCallback = null; + displayInfoTechnology = null; + if (callback == null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); + logger.log(SentryLevel.DEBUG, "Stopped listening for cellular network technology changes."); + } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { + logger.log( + SentryLevel.INFO, "Could not stop listening for cellular network technology changes.", e); + } + } + + @SuppressLint({"MissingPermission", "InlinedApi", "NewApi"}) + private @Nullable String getDataNetworkTechnology() { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + // getDataNetworkType was added in API 24. + return null; + } + // The SDK doesn't declare these permissions, so the technology is only available to apps that + // already request one of them. READ_BASIC_PHONE_STATE only exists from API 31 on, where the + // display info listener is used instead, but checking it is harmless. + if (!Permissions.hasPermission(context, Manifest.permission.READ_PHONE_STATE) + && !Permissions.hasPermission(context, Manifest.permission.READ_BASIC_PHONE_STATE)) { + return null; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return null; + } + try { + return networkTypeToGeneration(telephonyManager.getDataNetworkType()); + } catch (SecurityException | UnsupportedOperationException e) { + // The permission can be revoked between the check above and the call, and devices without + // telephony hardware throw UnsupportedOperationException from API 36 on. + logger.log(SentryLevel.INFO, "Could not retrieve the cellular network technology.", e); + return null; + } + } + + private @Nullable TelephonyManager getTelephonyManager() { + final @Nullable Object service = context.getSystemService(Context.TELEPHONY_SERVICE); + if (!(service instanceof TelephonyManager)) { + logger.log(SentryLevel.INFO, "TelephonyManager is not available."); + return null; + } + return (TelephonyManager) service; + } + + /** + * Maps a {@link TelephonyDisplayInfo} to a generation, preferring the override type so that a + * device showing 5G to the user is also reported as 5G. + */ + @RequiresApi(api = Build.VERSION_CODES.S) + static @Nullable String toGeneration(final @NotNull TelephonyDisplayInfo displayInfo) { + switch (displayInfo.getOverrideNetworkType()) { + case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA: + case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED: + return GENERATION_5G; + case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA: + case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO: + return GENERATION_4G; + default: + return networkTypeToGeneration(displayInfo.getNetworkType()); + } + } + + /** Maps a {@code TelephonyManager.NETWORK_TYPE_*} constant to a generation. */ + @SuppressWarnings("deprecation") + static @Nullable String networkTypeToGeneration(final int networkType) { + switch (networkType) { + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_IDEN: + case TelephonyManager.NETWORK_TYPE_GSM: + return GENERATION_2G; + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + case TelephonyManager.NETWORK_TYPE_EHRPD: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_HSPAP: + case TelephonyManager.NETWORK_TYPE_TD_SCDMA: + return GENERATION_3G; + case TelephonyManager.NETWORK_TYPE_LTE: + case TelephonyManager.NETWORK_TYPE_IWLAN: + return GENERATION_4G; + case TelephonyManager.NETWORK_TYPE_NR: + return GENERATION_5G; + default: + return null; + } + } + + /** Caches the display info so that reading the technology never blocks on telephony. */ + @RequiresApi(api = Build.VERSION_CODES.S) + private static final class DisplayInfoCallback extends TelephonyCallback + implements TelephonyCallback.DisplayInfoListener { + + private final @NotNull CellularNetworkTechnologyProvider provider; + + DisplayInfoCallback(final @NotNull CellularNetworkTechnologyProvider provider) { + this.provider = provider; + } + + @Override + public void onDisplayInfoChanged(final @NonNull TelephonyDisplayInfo telephonyDisplayInfo) { + provider.displayInfoTechnology = toGeneration(telephonyDisplayInfo); + } + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 8ed3531781..6e4ed47cff 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -17,6 +17,7 @@ import android.net.NetworkInfo import android.os.Build import android.os.Handler import android.os.Looper +import android.telephony.TelephonyManager import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IConnectionStatusProvider import io.sentry.ILogger @@ -47,6 +48,7 @@ import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions @@ -252,6 +254,53 @@ class AndroidConnectionStatusProviderTest { assertEquals("cellular", connectionStatusProvider.connectionType) } + @Test + fun `When the cellular network technology is known, it refines the connection type`() { + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(true) + val telephonyManager = mock() + whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(telephonyManager) + whenever(contextMock.checkPermission(eq(Manifest.permission.READ_PHONE_STATE), any(), any())) + .thenReturn(PERMISSION_GRANTED) + whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_NR) + + assertEquals("cellular_5g", connectionStatusProvider.connectionType) + } + + @Test + fun `When the cellular network technology is unknown, the connection type stays 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) + val telephonyManager = mock() + whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(telephonyManager) + whenever(contextMock.checkPermission(eq(Manifest.permission.READ_PHONE_STATE), any(), any())) + .thenReturn(PERMISSION_DENIED) + whenever( + contextMock.checkPermission(eq(Manifest.permission.READ_BASIC_PHONE_STATE), any(), any()) + ) + .thenReturn(PERMISSION_DENIED) + + assertEquals("cellular", connectionStatusProvider.connectionType) + verify(telephonyManager, never()).dataNetworkType + } + + @Test + fun `The cellular network technology does not refine other connection types`() { + whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(true) + val telephonyManager = mock() + whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(telephonyManager) + whenever(contextMock.checkPermission(eq(Manifest.permission.READ_PHONE_STATE), any(), any())) + .thenReturn(PERMISSION_GRANTED) + whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_NR) + + assertEquals("wifi", connectionStatusProvider.connectionType) + } + @Test fun `unregisterNetworkCallback calls connectivityManager unregisterDefaultNetworkCallback`() { whenever(contextMock.getSystemService(any())).thenReturn(connectivityManager) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt new file mode 100644 index 0000000000..308d76c270 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt @@ -0,0 +1,289 @@ +package io.sentry.android.core.internal.util + +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_DENIED +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import android.telephony.TelephonyCallback +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import io.sentry.android.core.BuildInfoProvider +import java.util.concurrent.Executor +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +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.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.S]) +class CellularNetworkTechnologyProviderTest { + private class Fixture { + val context = mock() + val logger = mock() + val buildInfo = mock() + val telephonyManager = mock() + + /** Runs the display info callback inline, so tests don't have to wait for another thread. */ + val executor = Executor { it.run() } + + fun getSut( + sdkVersion: Int = Build.VERSION_CODES.S, + hasPermission: Boolean = false, + hasTelephonyManager: Boolean = true, + ): CellularNetworkTechnologyProvider { + whenever(buildInfo.sdkInfoVersion).thenReturn(sdkVersion) + whenever(context.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(if (hasTelephonyManager) telephonyManager else null) + whenever(context.checkPermission(any(), any(), any())) + .thenReturn(if (hasPermission) PERMISSION_GRANTED else PERMISSION_DENIED) + return CellularNetworkTechnologyProvider(context, logger, buildInfo, executor) + } + + fun displayInfo(networkType: Int, overrideNetworkType: Int): TelephonyDisplayInfo { + val displayInfo = mock() + whenever(displayInfo.networkType).thenReturn(networkType) + whenever(displayInfo.overrideNetworkType).thenReturn(overrideNetworkType) + return displayInfo + } + + /** Registers the provider and returns the callback the telephony manager received. */ + fun registerAndCaptureCallback( + provider: CellularNetworkTechnologyProvider + ): TelephonyCallback.DisplayInfoListener { + provider.register() + val captor = argumentCaptor() + verify(telephonyManager).registerTelephonyCallback(eq(executor), captor.capture()) + return captor.firstValue as TelephonyCallback.DisplayInfoListener + } + } + + private lateinit var fixture: Fixture + + @BeforeTest + fun beforeTest() { + fixture = Fixture() + } + + @Test + fun `networkTypeToGeneration maps second generation network types`() { + val networkTypes = + listOf( + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_EDGE, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT, + TelephonyManager.NETWORK_TYPE_IDEN, + TelephonyManager.NETWORK_TYPE_GSM, + ) + + networkTypes.forEach { networkType -> + assertThat(CellularNetworkTechnologyProvider.networkTypeToGeneration(networkType)) + .isEqualTo("2g") + } + } + + @Test + fun `networkTypeToGeneration maps third generation network types`() { + val networkTypes = + listOf( + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_EVDO_B, + TelephonyManager.NETWORK_TYPE_EHRPD, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSPAP, + TelephonyManager.NETWORK_TYPE_TD_SCDMA, + ) + + networkTypes.forEach { networkType -> + assertThat(CellularNetworkTechnologyProvider.networkTypeToGeneration(networkType)) + .isEqualTo("3g") + } + } + + @Test + fun `networkTypeToGeneration maps fourth and fifth generation network types`() { + assertThat( + CellularNetworkTechnologyProvider.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_LTE) + ) + .isEqualTo("4g") + assertThat( + CellularNetworkTechnologyProvider.networkTypeToGeneration( + TelephonyManager.NETWORK_TYPE_IWLAN + ) + ) + .isEqualTo("4g") + assertThat( + CellularNetworkTechnologyProvider.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_NR) + ) + .isEqualTo("5g") + } + + @Test + fun `networkTypeToGeneration returns null for unknown network types`() { + assertThat( + CellularNetworkTechnologyProvider.networkTypeToGeneration( + TelephonyManager.NETWORK_TYPE_UNKNOWN + ) + ) + .isNull() + } + + @Test + fun `toGeneration prefers the override network type`() { + val displayInfo = + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA, + ) + + assertThat(CellularNetworkTechnologyProvider.toGeneration(displayInfo)).isEqualTo("5g") + } + + @Test + fun `toGeneration falls back to the network type without an override`() { + val displayInfo = + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE, + ) + + assertThat(CellularNetworkTechnologyProvider.toGeneration(displayInfo)).isEqualTo("4g") + } + + @Test + fun `on Android 12 and above the technology comes from the display info listener`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + val listener = fixture.registerAndCaptureCallback(provider) + + assertThat(provider.cellularNetworkTechnology).isNull() + + listener.onDisplayInfoChanged( + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_NR, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE, + ) + ) + + assertThat(provider.cellularNetworkTechnology).isEqualTo("5g") + } + + @Test + fun `on Android 12 and above the technology is read without any permission`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S, hasPermission = false) + val listener = fixture.registerAndCaptureCallback(provider) + + listener.onDisplayInfoChanged( + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE, + ) + ) + + assertThat(provider.cellularNetworkTechnology).isEqualTo("4g") + verify(fixture.telephonyManager, never()).dataNetworkType + } + + @Test + fun `registering twice only registers one callback`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + + provider.register() + provider.register() + + verify(fixture.telephonyManager).registerTelephonyCallback(eq(fixture.executor), any()) + } + + @Test + fun `unregistering stops the listener and forgets the technology`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + val listener = fixture.registerAndCaptureCallback(provider) + listener.onDisplayInfoChanged( + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE, + ) + ) + + provider.unregister() + + verify(fixture.telephonyManager).unregisterTelephonyCallback(listener as TelephonyCallback) + assertThat(provider.cellularNetworkTechnology).isNull() + } + + @Test + fun `unregistering without registering does nothing`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + + provider.unregister() + + verifyNoInteractions(fixture.telephonyManager) + } + + @Test + fun `below Android 12 the technology is not read without permission`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.R, hasPermission = false) + + assertThat(provider.cellularNetworkTechnology).isNull() + verify(fixture.telephonyManager, never()).dataNetworkType + } + + @Test + fun `below Android 12 the technology is read with permission`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.R, hasPermission = true) + whenever(fixture.telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_LTE) + + assertThat(provider.cellularNetworkTechnology).isEqualTo("4g") + } + + @Test + fun `below Android 12 a security exception is swallowed`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.R, hasPermission = true) + whenever(fixture.telephonyManager.dataNetworkType).thenThrow(SecurityException("denied")) + + assertThat(provider.cellularNetworkTechnology).isNull() + } + + @Test + fun `below Android 7 the technology is not read because getDataNetworkType is unavailable`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.M, hasPermission = true) + + assertThat(provider.cellularNetworkTechnology).isNull() + verify(fixture.telephonyManager, never()).dataNetworkType + } + + @Test + fun `below Android 12 registering does nothing`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.R) + + provider.register() + + verifyNoInteractions(fixture.telephonyManager) + } + + @Test + fun `without a telephony manager no technology is reported`() { + val provider = + fixture.getSut( + sdkVersion = Build.VERSION_CODES.R, + hasPermission = true, + hasTelephonyManager = false, + ) + + assertThat(provider.cellularNetworkTechnology).isNull() + } +} From 239eb9ab6d985bae2a7cf3562e3cae81aa8a73d1 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 23 Sep 2026 09:24:22 +0200 Subject: [PATCH 2/9] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59606e335..eaf4cfff02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,6 +120,8 @@ See the [Data Collection documentation](https://docs.sentry.io/platforms/java/configuration/options/#dataCollection) for all configuration keys, supported integrations, and migration guidance. +- Report the cellular network technology in `device.connection_type`, for example `cellular_4g` or `cellular_5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) + ### Fixes - Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124) From 68775a265f935c974b372cf71327b246ad0989de Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 23 Sep 2026 09:54:38 +0200 Subject: [PATCH 3/9] fix(android): Tie telephony listener to the network callback The display info listener was started before the network callback was registered. Unregistering is skipped while there is no network callback, so a failed registration left the listener running for the rest of the process, including in the background. Start it only after the network callback registered, and guard the check-then-act in register and unregister with a lock so concurrent callers cannot register a second listener that is never stored. --- .../util/AndroidConnectionStatusProvider.java | 5 +- .../CellularNetworkTechnologyProvider.java | 77 +++++++++++-------- .../AndroidConnectionStatusProviderTest.kt | 20 +++++ .../CellularNetworkTechnologyProviderTest.kt | 23 ++++++ 4 files changed, 91 insertions(+), 34 deletions(-) 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 1618548fb4..ff2db88741 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 @@ -203,8 +203,6 @@ private void ensureNetworkCallbackRegistered() { return; // Already registered } - cellularNetworkTechnologyProvider.register(); - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (networkCallback != null) { return; @@ -370,6 +368,9 @@ private boolean hasSignificantTransportChanges( if (registerNetworkCallback( context, options.getLogger(), buildInfoProvider, handler, callback)) { networkCallback = callback; + // Only start listening once the network callback is registered, because unregistering is + // skipped while there is no network callback, which would leave the listener running. + cellularNetworkTechnologyProvider.register(); options.getLogger().log(SentryLevel.DEBUG, "Network callback registered successfully"); } else { options.getLogger().log(SentryLevel.WARNING, "Failed to register network callback"); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java index e1d4898ecf..e2fcf4591a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java @@ -10,9 +10,11 @@ import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; +import io.sentry.util.AutoClosableReentrantLock; import java.util.concurrent.Executor; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -40,6 +42,7 @@ public final class CellularNetworkTechnologyProvider { private final @NotNull ILogger logger; private final @NotNull BuildInfoProvider buildInfoProvider; private final @NotNull Executor executor; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); /** * Set from {@link TelephonyCallback.DisplayInfoListener} on API 31 and above. Declared as {@link @@ -81,44 +84,54 @@ public void register() { if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.S) { return; } - if (displayInfoCallback != null) { - return; - } - final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); - if (telephonyManager == null) { - return; - } - try { - final @NotNull DisplayInfoCallback callback = new DisplayInfoCallback(this); - telephonyManager.registerTelephonyCallback(executor, callback); - displayInfoCallback = callback; - logger.log(SentryLevel.DEBUG, "Started listening for cellular network technology changes."); - } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { - // Devices without telephony support and processes that are not allowed to listen throw - // here, and the technology is optional data. - logger.log(SentryLevel.INFO, "Could not listen for cellular network technology changes.", e); + // Checking and storing the callback has to be atomic, otherwise concurrent callers can each + // register a listener while only the last one is kept and can ever be unregistered. + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (displayInfoCallback != null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + final @NotNull DisplayInfoCallback callback = new DisplayInfoCallback(this); + telephonyManager.registerTelephonyCallback(executor, callback); + displayInfoCallback = callback; + logger.log(SentryLevel.DEBUG, "Started listening for cellular network technology changes."); + } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { + // Devices without telephony support and processes that are not allowed to listen throw + // here, and the technology is optional data. + logger.log( + SentryLevel.INFO, "Could not listen for cellular network technology changes.", e); + } } } /** Stops listening for display info changes and forgets the last reported technology. */ @SuppressLint("NewApi") public void unregister() { - final @Nullable Object callback = displayInfoCallback; - displayInfoCallback = null; - displayInfoTechnology = null; - if (callback == null) { - return; - } - final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); - if (telephonyManager == null) { - return; - } - try { - telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); - logger.log(SentryLevel.DEBUG, "Stopped listening for cellular network technology changes."); - } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { - logger.log( - SentryLevel.INFO, "Could not stop listening for cellular network technology changes.", e); + final @Nullable Object callback; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + callback = displayInfoCallback; + displayInfoCallback = null; + displayInfoTechnology = null; + if (callback == null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); + logger.log(SentryLevel.DEBUG, "Stopped listening for cellular network technology changes."); + } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { + logger.log( + SentryLevel.INFO, + "Could not stop listening for cellular network technology changes.", + e); + } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 6e4ed47cff..21309a82c6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -254,6 +254,26 @@ class AndroidConnectionStatusProviderTest { assertEquals("cellular", connectionStatusProvider.connectionType) } + @Test + @Config(sdk = [Build.VERSION_CODES.S]) + fun `When the network callback cannot be registered, the telephony listener is not started`() { + // Without ACCESS_NETWORK_STATE the network callback registration fails, and onBackground skips + // unregistering while there is no network callback, so the listener must not be started here. + val telephonyManager = mock() + whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(telephonyManager) + whenever( + contextMock.checkPermission(eq(Manifest.permission.ACCESS_NETWORK_STATE), any(), any()) + ) + .thenReturn(PERMISSION_DENIED) + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S) + + val provider = AndroidConnectionStatusProvider(contextMock, options, buildInfo, ticker) + + verify(telephonyManager, never()).registerTelephonyCallback(any(), any()) + provider.close() + } + @Test fun `When the cellular network technology is known, it refines the connection type`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt index 308d76c270..5b27698cf6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt @@ -11,7 +11,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.android.core.BuildInfoProvider +import java.util.concurrent.CountDownLatch import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import org.junit.runner.RunWith @@ -208,6 +210,27 @@ class CellularNetworkTechnologyProviderTest { verify(fixture.telephonyManager).registerTelephonyCallback(eq(fixture.executor), any()) } + @Test + fun `concurrent registration only registers one callback`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + val threadCount = 8 + val start = CountDownLatch(1) + val done = CountDownLatch(threadCount) + repeat(threadCount) { + Thread { + start.await() + provider.register() + done.countDown() + } + .start() + } + + start.countDown() + assertThat(done.await(10, TimeUnit.SECONDS)).isTrue() + + verify(fixture.telephonyManager).registerTelephonyCallback(eq(fixture.executor), any()) + } + @Test fun `unregistering stops the listener and forgets the technology`() { val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) From 4090d4e8886e1de9f0fcf8bfde26f7a9bcf6b581 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 23 Sep 2026 16:04:01 +0200 Subject: [PATCH 4/9] ref(android): Report the network technology as its own attribute device.connection_type is deprecated in favour of network.connection.type, and the conventions already have network.connection.effective_type for the generation, so the technology is no longer folded into the connection type. Device gains connection_effective_type with 2g, 3g, 4g or 5g, mirroring how connection_type aliases network.connection.type. --- CHANGELOG.md | 2 +- .../sentry/android/core/DeviceInfoUtil.java | 13 ++++++++++ .../util/AndroidConnectionStatusProvider.java | 24 ++++++++---------- .../AndroidConnectionStatusProviderTest.kt | 12 ++++++--- sentry/api/sentry.api | 3 +++ .../main/java/io/sentry/protocol/Device.java | 25 +++++++++++++++++++ 6 files changed, 61 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf4cfff02..6bc1943937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -120,7 +120,7 @@ See the [Data Collection documentation](https://docs.sentry.io/platforms/java/configuration/options/#dataCollection) for all configuration keys, supported integrations, and migration guidance. -- Report the cellular network technology in `device.connection_type`, for example `cellular_4g` or `cellular_5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) +- Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index d96dc5cb38..739641b12d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -15,8 +15,10 @@ import android.os.SystemClock; import android.util.DisplayMetrics; import io.sentry.DateUtils; +import io.sentry.IConnectionStatusProvider; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; import io.sentry.android.core.internal.util.CpuInfoUtils; import io.sentry.android.core.internal.util.DeviceOrientations; import io.sentry.android.core.internal.util.RootChecker; @@ -231,6 +233,17 @@ private void setDeviceIO( // wifi, ethernet or cellular, null if none device.setConnectionType(options.getConnectionStatusProvider().getConnectionType()); } + + if (device.getConnectionEffectiveType() == null) { + final @NotNull IConnectionStatusProvider connectionStatusProvider = + options.getConnectionStatusProvider(); + if (connectionStatusProvider instanceof AndroidConnectionStatusProvider) { + // 2g, 3g, 4g or 5g, null unless the device is on a known cellular technology + device.setConnectionEffectiveType( + ((AndroidConnectionStatusProvider) connectionStatusProvider) + .getConnectionEffectiveType()); + } + } } @SuppressWarnings("NewApi") 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 ff2db88741..7c10b506e6 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 @@ -170,28 +170,26 @@ private boolean isNetworkEffectivelyConnected( private @Nullable String getConnectionTypeFromCache() { final NetworkCapabilities capabilities = cachedNetworkCapabilities; if (capabilities != null) { - return withCellularNetworkTechnology(getConnectionType(capabilities)); + return getConnectionType(capabilities); } // Fallback to legacy method when NetworkCapabilities not available - return withCellularNetworkTechnology( - getConnectionType(context, options.getLogger(), buildInfoProvider)); + return getConnectionType(context, options.getLogger(), buildInfoProvider); } /** - * Refines a cellular connection type with the network technology, for example {@code - * cellular_5g}. Other connection types and unknown technologies are returned unchanged. + * The generation of the cellular network technology currently used for data, for example {@code + * 5g}, or {@code null} when the connection is not cellular or the technology is unknown. Maps to + * the {@code network.connection.effective_type} attribute. */ - private @Nullable String withCellularNetworkTechnology(final @Nullable String connectionType) { - if (!"cellular".equals(connectionType)) { - return connectionType; + public @Nullable String getConnectionEffectiveType() { + if (!isCacheValid()) { + updateCache(null); } - final @Nullable String technology = - cellularNetworkTechnologyProvider.getCellularNetworkTechnology(); - if (technology == null) { - return connectionType; + if (!"cellular".equals(getConnectionTypeFromCache())) { + return null; } - return connectionType + "_" + technology; + return cellularNetworkTechnologyProvider.getCellularNetworkTechnology(); } private void ensureNetworkCallbackRegistered() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 21309a82c6..7300ab5061 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -275,7 +275,7 @@ class AndroidConnectionStatusProviderTest { } @Test - fun `When the cellular network technology is known, it refines the connection type`() { + fun `When on cellular with a known technology, the effective type is the generation`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(true) @@ -286,11 +286,13 @@ class AndroidConnectionStatusProviderTest { .thenReturn(PERMISSION_GRANTED) whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_NR) - assertEquals("cellular_5g", connectionStatusProvider.connectionType) + // The connection type keeps its documented values, the generation is reported separately. + assertEquals("cellular", connectionStatusProvider.connectionType) + assertEquals("5g", connectionStatusProvider.connectionEffectiveType) } @Test - fun `When the cellular network technology is unknown, the connection type stays cellular`() { + fun `When the cellular network technology is unknown, the effective type is null`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_ETHERNET))).thenReturn(false) whenever(networkCapabilities.hasTransport(eq(TRANSPORT_CELLULAR))).thenReturn(true) @@ -305,11 +307,12 @@ class AndroidConnectionStatusProviderTest { .thenReturn(PERMISSION_DENIED) assertEquals("cellular", connectionStatusProvider.connectionType) + assertNull(connectionStatusProvider.connectionEffectiveType) verify(telephonyManager, never()).dataNetworkType } @Test - fun `The cellular network technology does not refine other connection types`() { + fun `When not on cellular, the effective type is null`() { whenever(networkCapabilities.hasTransport(eq(TRANSPORT_WIFI))).thenReturn(true) val telephonyManager = mock() whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) @@ -319,6 +322,7 @@ class AndroidConnectionStatusProviderTest { whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_NR) assertEquals("wifi", connectionStatusProvider.connectionType) + assertNull(connectionStatusProvider.connectionEffectiveType) } @Test diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 53452c4c3b..6b1f02f5ee 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6082,6 +6082,7 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public fun getBootTime ()Ljava/util/Date; public fun getBrand ()Ljava/lang/String; public fun getChipset ()Ljava/lang/String; + public fun getConnectionEffectiveType ()Ljava/lang/String; public fun getConnectionType ()Ljava/lang/String; public fun getCpuDescription ()Ljava/lang/String; public fun getExternalFreeStorage ()Ljava/lang/Long; @@ -6120,6 +6121,7 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public fun setBrand (Ljava/lang/String;)V public fun setCharging (Ljava/lang/Boolean;)V public fun setChipset (Ljava/lang/String;)V + public fun setConnectionEffectiveType (Ljava/lang/String;)V public fun setConnectionType (Ljava/lang/String;)V public fun setCpuDescription (Ljava/lang/String;)V public fun setExternalFreeStorage (Ljava/lang/Long;)V @@ -6178,6 +6180,7 @@ public final class io/sentry/protocol/Device$JsonKeys { public static final field BRAND Ljava/lang/String; public static final field CHARGING Ljava/lang/String; public static final field CHIPSET Ljava/lang/String; + public static final field CONNECTION_EFFECTIVE_TYPE Ljava/lang/String; public static final field CONNECTION_TYPE Ljava/lang/String; public static final field CPU_DESCRIPTION Ljava/lang/String; public static final field EXTERNAL_FREE_STORAGE Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index 5b765640a3..8704c2220f 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -129,6 +129,7 @@ public final class Device implements JsonUnknown, JsonSerializable { private @Nullable String locale; private @Nullable String connectionType; + private @Nullable String connectionEffectiveType; /** battery's temperature in celsius */ private @Nullable Float batteryTemperature; @@ -179,6 +180,7 @@ public Device() {} this.bootTime = device.bootTime; this.id = device.id; this.connectionType = device.connectionType; + this.connectionEffectiveType = device.connectionEffectiveType; this.batteryTemperature = device.batteryTemperature; this.batteryLevel = device.batteryLevel; final String[] archsRef = device.archs; @@ -421,6 +423,20 @@ public void setConnectionType(final @Nullable String connectionType) { this.connectionType = connectionType; } + /** + * The generation of the cellular network technology, for example {@code 4g} or {@code 5g}. Maps + * to the {@code network.connection.effective_type} attribute. + * + * @return the effective connection type or {@code null} if not set + */ + public @Nullable String getConnectionEffectiveType() { + return connectionEffectiveType; + } + + public void setConnectionEffectiveType(final @Nullable String connectionEffectiveType) { + this.connectionEffectiveType = connectionEffectiveType; + } + public @Nullable Float getBatteryTemperature() { return batteryTemperature; } @@ -494,6 +510,7 @@ public boolean equals(Object o) { && Objects.equals(id, device.id) && Objects.equals(locale, device.locale) && Objects.equals(connectionType, device.connectionType) + && Objects.equals(connectionEffectiveType, device.connectionEffectiveType) && Objects.equals(batteryTemperature, device.batteryTemperature) && Objects.equals(processorCount, device.processorCount) && Objects.equals(processorFrequency, device.processorFrequency) @@ -533,6 +550,7 @@ public int hashCode() { id, locale, connectionType, + connectionEffectiveType, batteryTemperature, processorCount, processorFrequency, @@ -596,6 +614,7 @@ public static final class JsonKeys { public static final String TIMEZONE = "timezone"; public static final String ID = "id"; public static final String CONNECTION_TYPE = "connection_type"; + public static final String CONNECTION_EFFECTIVE_TYPE = "connection_effective_type"; public static final String BATTERY_TEMPERATURE = "battery_temperature"; public static final String LOCALE = "locale"; public static final String PROCESSOR_COUNT = "processor_count"; @@ -692,6 +711,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (connectionType != null) { writer.name(JsonKeys.CONNECTION_TYPE).value(connectionType); } + if (connectionEffectiveType != null) { + writer.name(JsonKeys.CONNECTION_EFFECTIVE_TYPE).value(connectionEffectiveType); + } if (batteryTemperature != null) { writer.name(JsonKeys.BATTERY_TEMPERATURE).value(batteryTemperature); } @@ -840,6 +862,9 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.CONNECTION_TYPE: device.connectionType = reader.nextStringOrNull(); break; + case JsonKeys.CONNECTION_EFFECTIVE_TYPE: + device.connectionEffectiveType = reader.nextStringOrNull(); + break; case JsonKeys.BATTERY_TEMPERATURE: device.batteryTemperature = reader.nextFloatOrNull(); break; From c56405c9ff0ba20fe415122f72f5ef6b7f8db9b7 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 23 Sep 2026 16:34:18 +0200 Subject: [PATCH 5/9] fix(android): Read the connection type and technology together Reading the connection type and the network technology separately let a connectivity change land between them, so an event could report a wifi connection with a cellular technology. Both now come from one accessor that derives them from a single cache read. --- .../sentry/android/core/DeviceInfoUtil.java | 16 ++++----- .../util/AndroidConnectionStatusProvider.java | 34 +++++++++++++++---- .../AndroidConnectionStatusProviderTest.kt | 6 ++-- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 739641b12d..17d505640b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -230,18 +230,18 @@ private void setDeviceIO( } if (device.getConnectionType() == null) { - // wifi, ethernet or cellular, null if none - device.setConnectionType(options.getConnectionStatusProvider().getConnectionType()); - } - - if (device.getConnectionEffectiveType() == null) { final @NotNull IConnectionStatusProvider connectionStatusProvider = options.getConnectionStatusProvider(); if (connectionStatusProvider instanceof AndroidConnectionStatusProvider) { + // Reading both at once keeps the type and the technology describing the same network. + final AndroidConnectionStatusProvider.Connection connection = + ((AndroidConnectionStatusProvider) connectionStatusProvider).getConnection(); + // wifi, ethernet or cellular, null if none + device.setConnectionType(connection.type); // 2g, 3g, 4g or 5g, null unless the device is on a known cellular technology - device.setConnectionEffectiveType( - ((AndroidConnectionStatusProvider) connectionStatusProvider) - .getConnectionEffectiveType()); + device.setConnectionEffectiveType(connection.effectiveType); + } else { + device.setConnectionType(connectionStatusProvider.getConnectionType()); } } } 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 7c10b506e6..f431927e66 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 @@ -178,18 +178,38 @@ private boolean isNetworkEffectivelyConnected( } /** - * The generation of the cellular network technology currently used for data, for example {@code - * 5g}, or {@code null} when the connection is not cellular or the technology is unknown. Maps to - * the {@code network.connection.effective_type} attribute. + * The connection type and, for cellular connections, the generation of the network technology. + * + *

Both are derived from one cache read, so they always describe the same network instead of + * straddling a connectivity change. */ - public @Nullable String getConnectionEffectiveType() { + public @NotNull Connection getConnection() { if (!isCacheValid()) { updateCache(null); } - if (!"cellular".equals(getConnectionTypeFromCache())) { - return null; + final @Nullable String connectionType = getConnectionTypeFromCache(); + if (!"cellular".equals(connectionType)) { + return new Connection(connectionType, null); + } + return new Connection( + connectionType, cellularNetworkTechnologyProvider.getCellularNetworkTechnology()); + } + + /** The connection type and the generation of the cellular network technology, if any. */ + public static final class Connection { + /** Maps to the {@code network.connection.type} attribute, {@code null} when unknown. */ + public final @Nullable String type; + + /** + * Maps to the {@code network.connection.effective_type} attribute, for example {@code 5g}. + * {@code null} when the connection is not cellular or the technology is unknown. + */ + public final @Nullable String effectiveType; + + Connection(final @Nullable String type, final @Nullable String effectiveType) { + this.type = type; + this.effectiveType = effectiveType; } - return cellularNetworkTechnologyProvider.getCellularNetworkTechnology(); } private void ensureNetworkCallbackRegistered() { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 7300ab5061..5c8d02a384 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -288,7 +288,7 @@ class AndroidConnectionStatusProviderTest { // The connection type keeps its documented values, the generation is reported separately. assertEquals("cellular", connectionStatusProvider.connectionType) - assertEquals("5g", connectionStatusProvider.connectionEffectiveType) + assertEquals("5g", connectionStatusProvider.connection.effectiveType) } @Test @@ -307,7 +307,7 @@ class AndroidConnectionStatusProviderTest { .thenReturn(PERMISSION_DENIED) assertEquals("cellular", connectionStatusProvider.connectionType) - assertNull(connectionStatusProvider.connectionEffectiveType) + assertNull(connectionStatusProvider.connection.effectiveType) verify(telephonyManager, never()).dataNetworkType } @@ -322,7 +322,7 @@ class AndroidConnectionStatusProviderTest { whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_NR) assertEquals("wifi", connectionStatusProvider.connectionType) - assertNull(connectionStatusProvider.connectionEffectiveType) + assertNull(connectionStatusProvider.connection.effectiveType) } @Test From dc31c22c742716c09160ae3c0253aa05d984e4ca Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 24 Sep 2026 10:19:34 +0200 Subject: [PATCH 6/9] fix(android): Report 5G mmWave and keep the callback on failure The NSA mmWave override network type fell through to the network type, which is LTE for NSA, so a 5G mmWave connection reported 4g. Unregistering also dropped the callback reference before it was unregistered, orphaning a still-registered callback when the telephony manager was gone. --- .../CellularNetworkTechnologyProvider.java | 10 +++-- .../CellularNetworkTechnologyProviderTest.kt | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java index e2fcf4591a..49de0935d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java @@ -111,20 +111,20 @@ public void register() { /** Stops listening for display info changes and forgets the last reported technology. */ @SuppressLint("NewApi") public void unregister() { - final @Nullable Object callback; try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - callback = displayInfoCallback; - displayInfoCallback = null; + final @Nullable Object callback = displayInfoCallback; displayInfoTechnology = null; if (callback == null) { return; } final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); if (telephonyManager == null) { + // Keep the reference so a later unregister can still reach the registered callback. return; } try { telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); + displayInfoCallback = null; logger.log(SentryLevel.DEBUG, "Stopped listening for cellular network technology changes."); } catch (SecurityException | IllegalStateException | UnsupportedOperationException e) { logger.log( @@ -176,10 +176,14 @@ public void unregister() { * device showing 5G to the user is also reported as 5G. */ @RequiresApi(api = Build.VERSION_CODES.S) + @SuppressWarnings("deprecation") static @Nullable String toGeneration(final @NotNull TelephonyDisplayInfo displayInfo) { switch (displayInfo.getOverrideNetworkType()) { case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA: case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED: + // Deprecated in favour of NR_ADVANCED, but still reported on Android 11. Its underlying + // network type is LTE, so without this case a 5G mmWave connection reports 4g. + case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE: return GENERATION_5G; case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA: case TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO: diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt index 5b27698cf6..cc0f95fcdd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt @@ -156,6 +156,45 @@ class CellularNetworkTechnologyProviderTest { assertThat(CellularNetworkTechnologyProvider.toGeneration(displayInfo)).isEqualTo("5g") } + @Test + fun `toGeneration maps the deprecated NSA mmWave override to 5g`() { + // Its underlying network type is LTE, so falling through to the network type reports 4g. + val displayInfo = + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE, + ) + + assertThat(CellularNetworkTechnologyProvider.toGeneration(displayInfo)).isEqualTo("5g") + } + + @Test + fun `toGeneration maps the NR advanced override to 5g`() { + val displayInfo = + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_LTE, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED, + ) + + assertThat(CellularNetworkTechnologyProvider.toGeneration(displayInfo)).isEqualTo("5g") + } + + @Test + fun `unregistering without a telephony manager keeps the callback for a later attempt`() { + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + val listener = fixture.registerAndCaptureCallback(provider) + // The telephony manager disappearing must not orphan the still-registered callback. + whenever(fixture.context.getSystemService(eq(Context.TELEPHONY_SERVICE))).thenReturn(null) + + provider.unregister() + + whenever(fixture.context.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(fixture.telephonyManager) + provider.unregister() + + verify(fixture.telephonyManager).unregisterTelephonyCallback(listener as TelephonyCallback) + } + @Test fun `toGeneration falls back to the network type without an override`() { val displayInfo = From d035a85fa71f38ef697747ce1a86b512649eddc9 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 24 Sep 2026 12:07:49 +0200 Subject: [PATCH 7/9] fix(android): Keep the last cellular technology after backgrounding Monitoring stops when the app goes to the background, and from API 31 on the display info listener is the only source, so clearing the cached generation left every background event without a technology while the connection type still resolved. --- .../CellularNetworkTechnologyProvider.java | 10 +++++++-- .../CellularNetworkTechnologyProviderTest.kt | 21 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java index 49de0935d8..c9d43963a7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProvider.java @@ -108,12 +108,18 @@ public void register() { } } - /** Stops listening for display info changes and forgets the last reported technology. */ + /** + * Stops listening for display info changes. + * + *

The last reported technology is kept. Monitoring stops when the app goes to the background, + * and from API 31 on the listener is the only source, so discarding it would leave every + * background event without a technology even though the connection type still resolves. It is + * only ever paired with a connection that is cellular at the time the event is captured. + */ @SuppressLint("NewApi") public void unregister() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final @Nullable Object callback = displayInfoCallback; - displayInfoTechnology = null; if (callback == null) { return; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt index cc0f95fcdd..0644631096 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/CellularNetworkTechnologyProviderTest.kt @@ -271,7 +271,7 @@ class CellularNetworkTechnologyProviderTest { } @Test - fun `unregistering stops the listener and forgets the technology`() { + fun `unregistering stops the listener`() { val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) val listener = fixture.registerAndCaptureCallback(provider) listener.onDisplayInfoChanged( @@ -284,7 +284,24 @@ class CellularNetworkTechnologyProviderTest { provider.unregister() verify(fixture.telephonyManager).unregisterTelephonyCallback(listener as TelephonyCallback) - assertThat(provider.cellularNetworkTechnology).isNull() + } + + @Test + fun `unregistering keeps the last reported technology`() { + // Monitoring stops when the app backgrounds, and from API 31 the listener is the only source, + // so dropping the value would leave background events without a technology. + val provider = fixture.getSut(sdkVersion = Build.VERSION_CODES.S) + val listener = fixture.registerAndCaptureCallback(provider) + listener.onDisplayInfoChanged( + fixture.displayInfo( + networkType = TelephonyManager.NETWORK_TYPE_NR, + overrideNetworkType = TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE, + ) + ) + + provider.unregister() + + assertThat(provider.cellularNetworkTechnology).isEqualTo("5g") } @Test From 663a626cb5896dd4d4840e350e2d2b824219b435 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 24 Sep 2026 12:24:32 +0200 Subject: [PATCH 8/9] fix(android): Don't throw from the telephony executor The SDK executor rejects work once it is shut down, and its queue check doesn't cover that, so a display info change arriving after Sentry.close threw RejectedExecutionException on a thread owned by the telephony framework. --- .../util/AndroidConnectionStatusProvider.java | 17 +++++++- .../AndroidConnectionStatusProviderTest.kt | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) 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 f431927e66..c78b7c92a2 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 @@ -24,6 +24,7 @@ import io.sentry.util.AutoClosableReentrantLock; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.ApiStatus; @@ -100,7 +101,21 @@ public AndroidConnectionStatusProvider( this.context, options.getLogger(), buildInfoProvider, - runnable -> options.getExecutorService().submit(runnable)); + runnable -> { + try { + options.getExecutorService().submit(runnable); + } catch (RejectedExecutionException e) { + // The telephony framework calls this executor, so an exception would be thrown on + // one of its threads. The SDK's executor rejects work once it is shut down, which + // can happen before the listener is unregistered. + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Dropping a cellular network technology update, the executor rejected it.", + e); + } + }); capabilities[0] = NetworkCapabilities.NET_CAPABILITY_INTERNET; if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.M) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 5c8d02a384..0e5c72c00b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -21,6 +21,7 @@ import android.telephony.TelephonyManager import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IConnectionStatusProvider import io.sentry.ILogger +import io.sentry.ISentryExecutorService import io.sentry.SentryOptions import io.sentry.android.core.AppState import io.sentry.android.core.BuildInfoProvider @@ -28,6 +29,9 @@ import io.sentry.android.core.ContextUtils import io.sentry.android.core.SystemEventsBreadcrumbsIntegration import io.sentry.test.ImmediateExecutorService import io.sentry.time.TestMonotonicTicker +import java.util.concurrent.Executor +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.TimeUnit.MINUTES import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -254,6 +258,41 @@ class AndroidConnectionStatusProviderTest { assertEquals("cellular", connectionStatusProvider.connectionType) } + /// Lets a test reject work only after the setup that needs a working executor is done. + private class RejectingExecutorService(private val delegate: ISentryExecutorService) : + ISentryExecutorService by delegate { + var reject = false + + override fun submit(runnable: Runnable): Future<*> { + if (reject) { + throw RejectedExecutionException("closed") + } + return delegate.submit(runnable) + } + } + + @Test + @Config(sdk = [Build.VERSION_CODES.S]) + fun `When the executor rejects work, a technology update does not throw`() { + // The telephony framework calls the executor, so throwing there would surface on one of its + // threads. The SDK executor rejects work once it is shut down. + val telephonyManager = mock() + whenever(contextMock.getSystemService(eq(Context.TELEPHONY_SERVICE))) + .thenReturn(telephonyManager) + whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S) + val executor = RejectingExecutorService(ImmediateExecutorService()) + options.executorService = executor + + val provider = AndroidConnectionStatusProvider(contextMock, options, buildInfo, ticker) + val captor = argumentCaptor() + verify(telephonyManager).registerTelephonyCallback(captor.capture(), any()) + executor.reject = true + + captor.firstValue.execute {} + + provider.close() + } + @Test @Config(sdk = [Build.VERSION_CODES.S]) fun `When the network callback cannot be registered, the telephony listener is not started`() { From 5a32881bce6e20ab4f4f6d2653db19cc58e3e2bb Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Thu, 24 Sep 2026 12:39:09 +0200 Subject: [PATCH 9/9] test(android): Restore the executor before teardown The rejecting executor is shared through the options, and teardown closes the provider through it, so leaving it rejecting skipped clearing the static connectivity manager for later tests. --- .../core/internal/util/AndroidConnectionStatusProviderTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt index 0e5c72c00b..864d8ff9c7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProviderTest.kt @@ -290,6 +290,9 @@ class AndroidConnectionStatusProviderTest { captor.firstValue.execute {} + // Cleanup runs through the same executor, and connectivityManager is static, so a still + // rejecting executor would leak a stale manager into later tests. + executor.reject = false provider.close() }