From 23fd75cfc8705c3af8504940a69aa75a10e2c7df Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 2 Oct 2026 14:48:16 +0200 Subject: [PATCH 1/4] feat(android): Report cellular network technology The Android SDK reports the generation of the cellular network technology in device.connection_effective_type from sentry-java 8.60.0. The field reaches events through serializeScope, so this SDK only has to bundle that version. iOS already reports the field, because sentry-cocoa 9.30.0 puts it into the extra context that fetchNativeDeviceContexts merges into the device context. The tests guard the path from the native layer to the event. The version bump follows when sentry-java 8.60.0 is released. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ .../core/test/integrations/devicecontext.test.ts | 13 +++++++++++++ .../tests/captureMessage/captureMessage.test.ios.ts | 1 + 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe0a960f9..d2059d7fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +### Features + +- Report the generation of the cellular network technology in `device.connection_effective_type` on Android, for example `4g` or `5g`. iOS reports it since version `8.29.0` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) + ### Fixes - Don't crash the iOS app when Sentry is initialized with an invalid DSN ([#6825](https://github.com/getsentry/sentry-react-native/pull/6825)) diff --git a/packages/core/test/integrations/devicecontext.test.ts b/packages/core/test/integrations/devicecontext.test.ts index fd5352a143..2388786537 100644 --- a/packages/core/test/integrations/devicecontext.test.ts +++ b/packages/core/test/integrations/devicecontext.test.ts @@ -93,6 +93,19 @@ describe('Device Context Integration', () => { }); }); + it('adds the native connection type and technology to the device context', async () => { + const { processedEvent } = await processEventWith({ + nativeContexts: { + contexts: { device: { connection_type: 'cellular', connection_effective_type: '5g' } }, + }, + }); + expect(processedEvent).toStrictEqual({ + contexts: { + device: { connection_type: 'cellular', connection_effective_type: '5g' }, + }, + }); + }); + it('merge native tags', async () => { const { processedEvent } = await processEventWith({ nativeContexts: { tags: { duplicate: 'native-tag', native: 'tag' } }, diff --git a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.ios.ts b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.ios.ts index 7672dc8412..01951cd685 100644 --- a/samples/react-native/e2e/tests/captureMessage/captureMessage.test.ios.ts +++ b/samples/react-native/e2e/tests/captureMessage/captureMessage.test.ios.ts @@ -58,6 +58,7 @@ describe('Capture message', () => { contexts: expect.objectContaining({ device: expect.objectContaining({ arch: expect.any(String), + connection_type: expect.any(String), family: expect.any(String), free_memory: expect.any(Number), locale: expect.any(String), From c94e72990a0f5e8d894fd3296a49f19e8423923d Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 7 Oct 2026 09:50:19 +0200 Subject: [PATCH 2/4] feat(android): Read the cellular network technology in the React Native layer sentry-android 8.60.0 is not released yet. The module now fills device.connection_effective_type when sentry-android does not set it. After the bump, this fallback has no effect and can be removed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- .../RNSentryCellularNetworkTechnologyTest.kt | 136 +++++++++++ .../RNSentryCellularNetworkTechnology.java | 229 ++++++++++++++++++ .../io/sentry/react/RNSentryModuleImpl.java | 23 +- 4 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt create mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d2059d7fa1..d8a446696e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ ### Features -- Report the generation of the cellular network technology in `device.connection_effective_type` on Android, for example `4g` or `5g`. iOS reports it since version `8.29.0` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) +- Report the generation of the cellular network technology in `device.connection_effective_type` on Android and iOS, for example `4g` or `5g` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) + - On Android 11 and earlier, the app must have the `READ_PHONE_STATE` permission. The SDK does not request this permission. ### Fixes diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt new file mode 100644 index 0000000000..47c09de830 --- /dev/null +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt @@ -0,0 +1,136 @@ +package io.sentry.react + +import android.content.Context +import android.content.pm.PackageManager +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager +import io.sentry.ILogger +import io.sentry.android.core.BuildInfoProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class RNSentryCellularNetworkTechnologyTest { + private lateinit var context: Context + private lateinit var telephonyManager: TelephonyManager + private lateinit var buildInfo: BuildInfoProvider + private lateinit var sut: RNSentryCellularNetworkTechnology + + @Before + fun setUp() { + context = mock(Context::class.java) + telephonyManager = mock(TelephonyManager::class.java) + buildInfo = mock(BuildInfoProvider::class.java) + whenever(context.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(telephonyManager) + whenever(context.checkPermission(any(), anyInt(), anyInt())).thenReturn(PackageManager.PERMISSION_GRANTED) + whenever(buildInfo.sdkInfoVersion).thenReturn(30) + whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_LTE) + sut = RNSentryCellularNetworkTechnology(context, mock(ILogger::class.java), buildInfo) + } + + private fun scopeWithDevice(device: Map): MutableMap = + mutableMapOf("contexts" to mutableMapOf("device" to device.toMutableMap())) + + @Suppress("UNCHECKED_CAST") + private fun deviceOf(scope: Map): Map = + (scope["contexts"] as Map)["device"] as Map + + @Test + fun addsTechnologyToCellularDeviceContext() { + val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) + + sut.addToDeviceContext(scope) + + assertEquals("4g", deviceOf(scope)["connection_effective_type"]) + } + + @Test + fun keepsTechnologyFromNativeSdk() { + val scope = + scopeWithDevice(mapOf("connection_type" to "cellular", "connection_effective_type" to "5g")) + + sut.addToDeviceContext(scope) + + assertEquals("5g", deviceOf(scope)["connection_effective_type"]) + } + + @Test + fun skipsNonCellularConnection() { + val scope = scopeWithDevice(mapOf("connection_type" to "wifi")) + + sut.addToDeviceContext(scope) + + assertNull(deviceOf(scope)["connection_effective_type"]) + } + + @Test + fun skipsWithoutPhoneStatePermission() { + whenever(context.checkPermission(any(), anyInt(), anyInt())).thenReturn(PackageManager.PERMISSION_DENIED) + val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) + + sut.addToDeviceContext(scope) + + assertNull(deviceOf(scope)["connection_effective_type"]) + } + + @Test + fun skipsWhenTelephonyThrows() { + whenever(telephonyManager.dataNetworkType).thenThrow(SecurityException("revoked")) + val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) + + sut.addToDeviceContext(scope) + + assertNull(deviceOf(scope)["connection_effective_type"]) + } + + @Test + fun ignoresScopeWithoutDeviceContext() { + val scope = mutableMapOf("contexts" to mutableMapOf()) + + sut.addToDeviceContext(scope) + + assertEquals(mutableMapOf(), scope["contexts"]) + } + + @Test + fun returnsNullBeforeFirstDisplayInfoOnApi31() { + whenever(buildInfo.sdkInfoVersion).thenReturn(31) + + assertNull(sut.cellularNetworkTechnology) + } + + @Test + fun mapsNetworkTypesToGenerations() { + assertEquals("2g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_EDGE)) + assertEquals("3g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_HSPAP)) + assertEquals("4g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_LTE)) + assertEquals("5g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_NR)) + assertNull(RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_UNKNOWN)) + } + + @Test + fun prefersDisplayInfoOverrideType() { + val displayInfo = mock(TelephonyDisplayInfo::class.java) + whenever(displayInfo.networkType).thenReturn(TelephonyManager.NETWORK_TYPE_LTE) + whenever(displayInfo.overrideNetworkType).thenReturn(TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA) + + assertEquals("5g", RNSentryCellularNetworkTechnology.toGeneration(displayInfo)) + } + + @Test + fun fallsBackToDisplayInfoNetworkType() { + val displayInfo = mock(TelephonyDisplayInfo::class.java) + whenever(displayInfo.networkType).thenReturn(TelephonyManager.NETWORK_TYPE_UMTS) + whenever(displayInfo.overrideNetworkType).thenReturn(TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE) + + assertEquals("3g", RNSentryCellularNetworkTechnology.toGeneration(displayInfo)) + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java new file mode 100644 index 0000000000..b57d4137bb --- /dev/null +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java @@ -0,0 +1,229 @@ +package io.sentry.react; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Process; +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 java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Adds the generation of the cellular network technology to the device context, for example {@code + * 4g} or {@code 5g}. It only fills {@code connection_effective_type} when sentry-android did not + * set it, so it has no effect with sentry-android 8.60.0 and later. + */ +final class RNSentryCellularNetworkTechnology { + + static final @NotNull String GENERATION_2G = "2g"; + static final @NotNull String GENERATION_3G = "3g"; + static final @NotNull String GENERATION_4G = "4g"; + static final @NotNull String GENERATION_5G = "5g"; + + // Not in android.Manifest.permission before API 33, and the module compiles against API 31. + private static final @NotNull String READ_BASIC_PHONE_STATE = + "android.permission.READ_BASIC_PHONE_STATE"; + + private final @NotNull Context context; + private final @NotNull ILogger logger; + private final @NotNull BuildInfoProvider buildInfoProvider; + + private final @NotNull Object registrationLock = new Object(); + + // Object, so that the class loads on devices that do not have TelephonyCallback. + private @Nullable Object displayInfoCallback; + + private final @NotNull AtomicReference displayInfoTechnology = new AtomicReference<>(); + + RNSentryCellularNetworkTechnology( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = + context.getApplicationContext() != null ? context.getApplicationContext() : context; + this.logger = logger; + this.buildInfoProvider = buildInfoProvider; + } + + @SuppressWarnings("unchecked") + void addToDeviceContext(final @NotNull Map serializedScope) { + final @Nullable Object contexts = serializedScope.get("contexts"); + if (!(contexts instanceof Map)) { + return; + } + final @Nullable Object device = ((Map) contexts).get("device"); + if (!(device instanceof Map)) { + return; + } + final Map deviceContext = (Map) device; + if (!"cellular".equals(deviceContext.get("connection_type")) + || deviceContext.get("connection_effective_type") != null) { + return; + } + final @Nullable String technology = getCellularNetworkTechnology(); + if (technology != null) { + deviceContext.put("connection_effective_type", technology); + } + } + + @Nullable + String getCellularNetworkTechnology() { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { + return displayInfoTechnology.get(); + } + return getDataNetworkTechnology(); + } + + @SuppressLint("NewApi") + void register() { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.S) { + return; + } + synchronized (registrationLock) { + if (displayInfoCallback != null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + final DisplayInfoCallback callback = new DisplayInfoCallback(this); + telephonyManager.registerTelephonyCallback(Runnable::run, callback); + displayInfoCallback = callback; + } catch (Throwable e) { // NOPMD - Devices without telephony support throw here. + logger.log( + SentryLevel.INFO, "Could not listen for cellular network technology changes.", e); + } + } + } + + @SuppressLint("NewApi") + void unregister() { + synchronized (registrationLock) { + final @Nullable Object callback = displayInfoCallback; + if (callback == null) { + return; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return; + } + try { + telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); + displayInfoCallback = null; + } catch (Throwable e) { // NOPMD - Unregistering is best-effort. + logger.log( + SentryLevel.INFO, + "Could not stop listening for cellular network technology changes.", + e); + } + } + } + + @SuppressLint({"MissingPermission", "NewApi"}) + private @Nullable String getDataNetworkTechnology() { + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + return null; + } + // The SDK does not declare these permissions. Only apps that request one of them get a value. + if (!hasPermission(Manifest.permission.READ_PHONE_STATE) + && !hasPermission(READ_BASIC_PHONE_STATE)) { + return null; + } + final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); + if (telephonyManager == null) { + return null; + } + try { + return networkTypeToGeneration(telephonyManager.getDataNetworkType()); + } catch (Throwable e) { // NOPMD - The permission can be revoked after the check. + logger.log(SentryLevel.INFO, "Could not retrieve the cellular network technology.", e); + return null; + } + } + + private boolean hasPermission(final @NotNull String permission) { + return context.checkPermission(permission, Process.myPid(), Process.myUid()) + == PackageManager.PERMISSION_GRANTED; + } + + private @Nullable TelephonyManager getTelephonyManager() { + final @Nullable Object service = context.getSystemService(Context.TELEPHONY_SERVICE); + return service instanceof TelephonyManager ? (TelephonyManager) service : null; + } + + @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: + 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: + return GENERATION_4G; + default: + return networkTypeToGeneration(displayInfo.getNetworkType()); + } + } + + @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; + } + } + + @RequiresApi(api = Build.VERSION_CODES.S) + private static final class DisplayInfoCallback extends TelephonyCallback + implements TelephonyCallback.DisplayInfoListener { + + private final @NotNull RNSentryCellularNetworkTechnology provider; + + DisplayInfoCallback(final @NotNull RNSentryCellularNetworkTechnology provider) { + this.provider = provider; + } + + @Override + public void onDisplayInfoChanged(final @NonNull TelephonyDisplayInfo telephonyDisplayInfo) { + provider.displayInfoTechnology.set(toGeneration(telephonyDisplayInfo)); + } + } +} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index e61da008d2..f612d3cee4 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -148,6 +148,7 @@ public class RNSentryModuleImpl { private final @NotNull SentryDateProvider dateProvider; private final @NotNull LoadClass loadClass; + private final @NotNull RNSentryCellularNetworkTechnology cellularNetworkTechnology; public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { packageInfo = getPackageInfo(reactApplicationContext); @@ -155,6 +156,8 @@ public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { this.emitNewFrameEvent = createEmitNewFrameEvent(); this.dateProvider = new SentryAndroidDateProvider(); this.loadClass = new LoadClass(); + this.cellularNetworkTechnology = + new RNSentryCellularNetworkTechnology(reactApplicationContext, logger, buildInfo); } private ReactApplicationContext getReactApplicationContext() { @@ -210,6 +213,8 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { return; } + cellularNetworkTechnology.register(); + // Toggle the TurboModule perf-logger sink based on the JS option. The // sink lazy-installs the native `NativeModulePerfLogger` on first enable; // we therefore want this to run only after the native SDK has started @@ -1118,6 +1123,8 @@ public WritableMap stopProfiling() { * https://github.com/facebook/hermes/issues/1853. */ public void invalidate() { + cellularNetworkTechnology.unregister(); + // Atomic gate: only one caller (invalidate vs stopProfiling vs a re-entrant invalidate) // wins the right to clean up; the rest no-op. if (!isProfiling.getAndSet(false)) { @@ -1222,6 +1229,7 @@ protected void fetchNativeDeviceContexts( final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); + addCellularNetworkTechnology(serialized); final @Nullable Object serializedBreadcrumbs = serialized.get("breadcrumbs"); if (serializedBreadcrumbs instanceof List) { @@ -1242,6 +1250,14 @@ protected void fetchNativeDeviceContexts( promise.resolve(deviceContext); } + private void addCellularNetworkTechnology(final @NotNull Map serialized) { + try { + cellularNetworkTechnology.addToDeviceContext(serialized); + } catch (Throwable e) { // NOPMD - The technology is optional data. + logger.log(SentryLevel.INFO, "Could not add the cellular network technology.", e); + } + } + // Basically fetchNativeDeviceContexts but filtered to only get contexts info. protected void fetchNativeLogContexts( Promise promise, @@ -1253,9 +1269,10 @@ protected void fetchNativeLogContexts( return; } - Object contextsObj = - InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) - .get("contexts"); + final @NotNull Map serialized = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope); + addCellularNetworkTechnology(serialized); + Object contextsObj = serialized.get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); From f9b5d0787b3675bbc01487a4e6a4554b86686910 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 7 Oct 2026 10:03:04 +0200 Subject: [PATCH 3/4] Revert "feat(android): Read the cellular network technology in the React Native layer" This reverts commit c94e72990a0f5e8d894fd3296a49f19e8423923d. --- CHANGELOG.md | 3 +- .../RNSentryCellularNetworkTechnologyTest.kt | 136 ----------- .../RNSentryCellularNetworkTechnology.java | 229 ------------------ .../io/sentry/react/RNSentryModuleImpl.java | 23 +- 4 files changed, 4 insertions(+), 387 deletions(-) delete mode 100644 packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt delete mode 100644 packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a446696e..d2059d7fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,7 @@ ### Features -- Report the generation of the cellular network technology in `device.connection_effective_type` on Android and iOS, for example `4g` or `5g` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) - - On Android 11 and earlier, the app must have the `READ_PHONE_STATE` permission. The SDK does not request this permission. +- Report the generation of the cellular network technology in `device.connection_effective_type` on Android, for example `4g` or `5g`. iOS reports it since version `8.29.0` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) ### Fixes diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt deleted file mode 100644 index 47c09de830..0000000000 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/RNSentryCellularNetworkTechnologyTest.kt +++ /dev/null @@ -1,136 +0,0 @@ -package io.sentry.react - -import android.content.Context -import android.content.pm.PackageManager -import android.telephony.TelephonyDisplayInfo -import android.telephony.TelephonyManager -import io.sentry.ILogger -import io.sentry.android.core.BuildInfoProvider -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.Mockito.mock -import org.mockito.kotlin.any -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class RNSentryCellularNetworkTechnologyTest { - private lateinit var context: Context - private lateinit var telephonyManager: TelephonyManager - private lateinit var buildInfo: BuildInfoProvider - private lateinit var sut: RNSentryCellularNetworkTechnology - - @Before - fun setUp() { - context = mock(Context::class.java) - telephonyManager = mock(TelephonyManager::class.java) - buildInfo = mock(BuildInfoProvider::class.java) - whenever(context.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(telephonyManager) - whenever(context.checkPermission(any(), anyInt(), anyInt())).thenReturn(PackageManager.PERMISSION_GRANTED) - whenever(buildInfo.sdkInfoVersion).thenReturn(30) - whenever(telephonyManager.dataNetworkType).thenReturn(TelephonyManager.NETWORK_TYPE_LTE) - sut = RNSentryCellularNetworkTechnology(context, mock(ILogger::class.java), buildInfo) - } - - private fun scopeWithDevice(device: Map): MutableMap = - mutableMapOf("contexts" to mutableMapOf("device" to device.toMutableMap())) - - @Suppress("UNCHECKED_CAST") - private fun deviceOf(scope: Map): Map = - (scope["contexts"] as Map)["device"] as Map - - @Test - fun addsTechnologyToCellularDeviceContext() { - val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) - - sut.addToDeviceContext(scope) - - assertEquals("4g", deviceOf(scope)["connection_effective_type"]) - } - - @Test - fun keepsTechnologyFromNativeSdk() { - val scope = - scopeWithDevice(mapOf("connection_type" to "cellular", "connection_effective_type" to "5g")) - - sut.addToDeviceContext(scope) - - assertEquals("5g", deviceOf(scope)["connection_effective_type"]) - } - - @Test - fun skipsNonCellularConnection() { - val scope = scopeWithDevice(mapOf("connection_type" to "wifi")) - - sut.addToDeviceContext(scope) - - assertNull(deviceOf(scope)["connection_effective_type"]) - } - - @Test - fun skipsWithoutPhoneStatePermission() { - whenever(context.checkPermission(any(), anyInt(), anyInt())).thenReturn(PackageManager.PERMISSION_DENIED) - val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) - - sut.addToDeviceContext(scope) - - assertNull(deviceOf(scope)["connection_effective_type"]) - } - - @Test - fun skipsWhenTelephonyThrows() { - whenever(telephonyManager.dataNetworkType).thenThrow(SecurityException("revoked")) - val scope = scopeWithDevice(mapOf("connection_type" to "cellular")) - - sut.addToDeviceContext(scope) - - assertNull(deviceOf(scope)["connection_effective_type"]) - } - - @Test - fun ignoresScopeWithoutDeviceContext() { - val scope = mutableMapOf("contexts" to mutableMapOf()) - - sut.addToDeviceContext(scope) - - assertEquals(mutableMapOf(), scope["contexts"]) - } - - @Test - fun returnsNullBeforeFirstDisplayInfoOnApi31() { - whenever(buildInfo.sdkInfoVersion).thenReturn(31) - - assertNull(sut.cellularNetworkTechnology) - } - - @Test - fun mapsNetworkTypesToGenerations() { - assertEquals("2g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_EDGE)) - assertEquals("3g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_HSPAP)) - assertEquals("4g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_LTE)) - assertEquals("5g", RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_NR)) - assertNull(RNSentryCellularNetworkTechnology.networkTypeToGeneration(TelephonyManager.NETWORK_TYPE_UNKNOWN)) - } - - @Test - fun prefersDisplayInfoOverrideType() { - val displayInfo = mock(TelephonyDisplayInfo::class.java) - whenever(displayInfo.networkType).thenReturn(TelephonyManager.NETWORK_TYPE_LTE) - whenever(displayInfo.overrideNetworkType).thenReturn(TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA) - - assertEquals("5g", RNSentryCellularNetworkTechnology.toGeneration(displayInfo)) - } - - @Test - fun fallsBackToDisplayInfoNetworkType() { - val displayInfo = mock(TelephonyDisplayInfo::class.java) - whenever(displayInfo.networkType).thenReturn(TelephonyManager.NETWORK_TYPE_UMTS) - whenever(displayInfo.overrideNetworkType).thenReturn(TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE) - - assertEquals("3g", RNSentryCellularNetworkTechnology.toGeneration(displayInfo)) - } -} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java deleted file mode 100644 index b57d4137bb..0000000000 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryCellularNetworkTechnology.java +++ /dev/null @@ -1,229 +0,0 @@ -package io.sentry.react; - -import android.Manifest; -import android.annotation.SuppressLint; -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Process; -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 java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Adds the generation of the cellular network technology to the device context, for example {@code - * 4g} or {@code 5g}. It only fills {@code connection_effective_type} when sentry-android did not - * set it, so it has no effect with sentry-android 8.60.0 and later. - */ -final class RNSentryCellularNetworkTechnology { - - static final @NotNull String GENERATION_2G = "2g"; - static final @NotNull String GENERATION_3G = "3g"; - static final @NotNull String GENERATION_4G = "4g"; - static final @NotNull String GENERATION_5G = "5g"; - - // Not in android.Manifest.permission before API 33, and the module compiles against API 31. - private static final @NotNull String READ_BASIC_PHONE_STATE = - "android.permission.READ_BASIC_PHONE_STATE"; - - private final @NotNull Context context; - private final @NotNull ILogger logger; - private final @NotNull BuildInfoProvider buildInfoProvider; - - private final @NotNull Object registrationLock = new Object(); - - // Object, so that the class loads on devices that do not have TelephonyCallback. - private @Nullable Object displayInfoCallback; - - private final @NotNull AtomicReference displayInfoTechnology = new AtomicReference<>(); - - RNSentryCellularNetworkTechnology( - final @NotNull Context context, - final @NotNull ILogger logger, - final @NotNull BuildInfoProvider buildInfoProvider) { - this.context = - context.getApplicationContext() != null ? context.getApplicationContext() : context; - this.logger = logger; - this.buildInfoProvider = buildInfoProvider; - } - - @SuppressWarnings("unchecked") - void addToDeviceContext(final @NotNull Map serializedScope) { - final @Nullable Object contexts = serializedScope.get("contexts"); - if (!(contexts instanceof Map)) { - return; - } - final @Nullable Object device = ((Map) contexts).get("device"); - if (!(device instanceof Map)) { - return; - } - final Map deviceContext = (Map) device; - if (!"cellular".equals(deviceContext.get("connection_type")) - || deviceContext.get("connection_effective_type") != null) { - return; - } - final @Nullable String technology = getCellularNetworkTechnology(); - if (technology != null) { - deviceContext.put("connection_effective_type", technology); - } - } - - @Nullable - String getCellularNetworkTechnology() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { - return displayInfoTechnology.get(); - } - return getDataNetworkTechnology(); - } - - @SuppressLint("NewApi") - void register() { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.S) { - return; - } - synchronized (registrationLock) { - if (displayInfoCallback != null) { - return; - } - final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); - if (telephonyManager == null) { - return; - } - try { - final DisplayInfoCallback callback = new DisplayInfoCallback(this); - telephonyManager.registerTelephonyCallback(Runnable::run, callback); - displayInfoCallback = callback; - } catch (Throwable e) { // NOPMD - Devices without telephony support throw here. - logger.log( - SentryLevel.INFO, "Could not listen for cellular network technology changes.", e); - } - } - } - - @SuppressLint("NewApi") - void unregister() { - synchronized (registrationLock) { - final @Nullable Object callback = displayInfoCallback; - if (callback == null) { - return; - } - final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); - if (telephonyManager == null) { - return; - } - try { - telephonyManager.unregisterTelephonyCallback((TelephonyCallback) callback); - displayInfoCallback = null; - } catch (Throwable e) { // NOPMD - Unregistering is best-effort. - logger.log( - SentryLevel.INFO, - "Could not stop listening for cellular network technology changes.", - e); - } - } - } - - @SuppressLint({"MissingPermission", "NewApi"}) - private @Nullable String getDataNetworkTechnology() { - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { - return null; - } - // The SDK does not declare these permissions. Only apps that request one of them get a value. - if (!hasPermission(Manifest.permission.READ_PHONE_STATE) - && !hasPermission(READ_BASIC_PHONE_STATE)) { - return null; - } - final @Nullable TelephonyManager telephonyManager = getTelephonyManager(); - if (telephonyManager == null) { - return null; - } - try { - return networkTypeToGeneration(telephonyManager.getDataNetworkType()); - } catch (Throwable e) { // NOPMD - The permission can be revoked after the check. - logger.log(SentryLevel.INFO, "Could not retrieve the cellular network technology.", e); - return null; - } - } - - private boolean hasPermission(final @NotNull String permission) { - return context.checkPermission(permission, Process.myPid(), Process.myUid()) - == PackageManager.PERMISSION_GRANTED; - } - - private @Nullable TelephonyManager getTelephonyManager() { - final @Nullable Object service = context.getSystemService(Context.TELEPHONY_SERVICE); - return service instanceof TelephonyManager ? (TelephonyManager) service : null; - } - - @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: - 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: - return GENERATION_4G; - default: - return networkTypeToGeneration(displayInfo.getNetworkType()); - } - } - - @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; - } - } - - @RequiresApi(api = Build.VERSION_CODES.S) - private static final class DisplayInfoCallback extends TelephonyCallback - implements TelephonyCallback.DisplayInfoListener { - - private final @NotNull RNSentryCellularNetworkTechnology provider; - - DisplayInfoCallback(final @NotNull RNSentryCellularNetworkTechnology provider) { - this.provider = provider; - } - - @Override - public void onDisplayInfoChanged(final @NonNull TelephonyDisplayInfo telephonyDisplayInfo) { - provider.displayInfoTechnology.set(toGeneration(telephonyDisplayInfo)); - } - } -} diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index f612d3cee4..e61da008d2 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -148,7 +148,6 @@ public class RNSentryModuleImpl { private final @NotNull SentryDateProvider dateProvider; private final @NotNull LoadClass loadClass; - private final @NotNull RNSentryCellularNetworkTechnology cellularNetworkTechnology; public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { packageInfo = getPackageInfo(reactApplicationContext); @@ -156,8 +155,6 @@ public RNSentryModuleImpl(ReactApplicationContext reactApplicationContext) { this.emitNewFrameEvent = createEmitNewFrameEvent(); this.dateProvider = new SentryAndroidDateProvider(); this.loadClass = new LoadClass(); - this.cellularNetworkTechnology = - new RNSentryCellularNetworkTechnology(reactApplicationContext, logger, buildInfo); } private ReactApplicationContext getReactApplicationContext() { @@ -213,8 +210,6 @@ public void initNativeSdk(final ReadableMap rnOptions, Promise promise) { return; } - cellularNetworkTechnology.register(); - // Toggle the TurboModule perf-logger sink based on the JS option. The // sink lazy-installs the native `NativeModulePerfLogger` on first enable; // we therefore want this to run only after the native SDK has started @@ -1123,8 +1118,6 @@ public WritableMap stopProfiling() { * https://github.com/facebook/hermes/issues/1853. */ public void invalidate() { - cellularNetworkTechnology.unregister(); - // Atomic gate: only one caller (invalidate vs stopProfiling vs a re-entrant invalidate) // wins the right to clean up; the rest no-op. if (!isProfiling.getAndSet(false)) { @@ -1229,7 +1222,6 @@ protected void fetchNativeDeviceContexts( final @NotNull Map serialized = InternalSentrySdk.serializeScope(context, (SentryAndroidOptions) options, currentScope); - addCellularNetworkTechnology(serialized); final @Nullable Object serializedBreadcrumbs = serialized.get("breadcrumbs"); if (serializedBreadcrumbs instanceof List) { @@ -1250,14 +1242,6 @@ protected void fetchNativeDeviceContexts( promise.resolve(deviceContext); } - private void addCellularNetworkTechnology(final @NotNull Map serialized) { - try { - cellularNetworkTechnology.addToDeviceContext(serialized); - } catch (Throwable e) { // NOPMD - The technology is optional data. - logger.log(SentryLevel.INFO, "Could not add the cellular network technology.", e); - } - } - // Basically fetchNativeDeviceContexts but filtered to only get contexts info. protected void fetchNativeLogContexts( Promise promise, @@ -1269,10 +1253,9 @@ protected void fetchNativeLogContexts( return; } - final @NotNull Map serialized = - InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope); - addCellularNetworkTechnology(serialized); - Object contextsObj = serialized.get("contexts"); + Object contextsObj = + InternalSentrySdk.serializeScope(osContext, (SentryAndroidOptions) options, currentScope) + .get("contexts"); if (!(contextsObj instanceof Map)) { promise.resolve(null); From 7e74c85a1ce1b3f72fa34e20dac4b1fe385bd0b0 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Wed, 7 Oct 2026 10:04:13 +0200 Subject: [PATCH 4/4] docs(changelog): Report the cellular network technology on Android and iOS Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2059d7fa1..d8a446696e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ ### Features -- Report the generation of the cellular network technology in `device.connection_effective_type` on Android, for example `4g` or `5g`. iOS reports it since version `8.29.0` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) +- Report the generation of the cellular network technology in `device.connection_effective_type` on Android and iOS, for example `4g` or `5g` ([#6827](https://github.com/getsentry/sentry-react-native/pull/6827)) + - On Android 11 and earlier, the app must have the `READ_PHONE_STATE` permission. The SDK does not request this permission. ### Fixes