diff --git a/measure-android/measure/build.gradle.kts b/measure-android/measure/build.gradle.kts index e662c6b34..48e26201e 100644 --- a/measure-android/measure/build.gradle.kts +++ b/measure-android/measure/build.gradle.kts @@ -80,9 +80,9 @@ dependencies { testImplementation("androidx.test.ext:junit-ktx:1.1.5") testImplementation("org.robolectric:robolectric:4.9.2") testImplementation("androidx.fragment:fragment-testing:1.2.5") + testImplementation("androidx.test:rules:1.5.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test:runner:1.5.2") - androidTestImplementation("androidx.test:rules:1.5.0") } \ No newline at end of file diff --git a/measure-android/measure/src/androidTest/java/sh/measure/android/fakes/FakeEventTracker.kt b/measure-android/measure/src/androidTest/java/sh/measure/android/fakes/FakeEventTracker.kt index 78a7e8eca..da362aaf8 100644 --- a/measure-android/measure/src/androidTest/java/sh/measure/android/fakes/FakeEventTracker.kt +++ b/measure-android/measure/src/androidTest/java/sh/measure/android/fakes/FakeEventTracker.kt @@ -12,6 +12,7 @@ import sh.measure.android.gestures.ScrollEvent import sh.measure.android.lifecycle.ActivityLifecycleEvent import sh.measure.android.lifecycle.ApplicationLifecycleEvent import sh.measure.android.lifecycle.FragmentLifecycleEvent +import sh.measure.android.network_change.NetworkChangeEvent @Suppress("MemberVisibilityCanBePrivate") internal class FakeEventTracker: EventTracker { @@ -25,6 +26,7 @@ internal class FakeEventTracker: EventTracker { val trackedApplicationLifecycleEvents = mutableListOf() val trackedColdLaunchEvents = mutableListOf() val trackedWarmLaunchEvents = mutableListOf() + val trackedNetworkChangeEvents = mutableListOf() val trackedHotLaunchEvents = mutableListOf() val trackedAttachments = mutableListOf() @@ -72,6 +74,10 @@ internal class FakeEventTracker: EventTracker { trackedHotLaunchEvents.add(event) } + override fun trackNetworkChange(event: NetworkChangeEvent) { + trackedNetworkChangeEvents.add(event) + } + override fun storeAttachment(attachmentInfo: AttachmentInfo) { trackedAttachments.add(attachmentInfo) } diff --git a/measure-android/measure/src/main/java/sh/measure/android/Measure.kt b/measure-android/measure/src/main/java/sh/measure/android/Measure.kt index 2c3081979..ebe58fc35 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/Measure.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/Measure.kt @@ -19,6 +19,9 @@ import sh.measure.android.network.HttpClient import sh.measure.android.network.HttpClientOkHttp import sh.measure.android.network.Transport import sh.measure.android.network.TransportImpl +import sh.measure.android.network_change.NetworkChangesCollector +import sh.measure.android.network_change.NetworkInfoProvider +import sh.measure.android.network_change.NetworkInfoProviderImpl import sh.measure.android.session.ResourceFactoryImpl import sh.measure.android.session.SessionController import sh.measure.android.session.SessionControllerImpl @@ -30,6 +33,8 @@ import sh.measure.android.utils.AndroidTimeProvider import sh.measure.android.utils.CurrentThread import sh.measure.android.utils.PidProvider import sh.measure.android.utils.PidProviderImpl +import sh.measure.android.utils.SystemServiceProvider +import sh.measure.android.utils.SystemServiceProviderImpl import sh.measure.android.utils.UUIDProvider object Measure { @@ -46,9 +51,13 @@ object Measure { val timeProvider = AndroidTimeProvider() val idProvider = UUIDProvider() val config = Config - val resourceFactory = ResourceFactoryImpl(logger, context, config) + val systemServiceProvider: SystemServiceProvider = SystemServiceProviderImpl(context) + val networkInfoProvider: NetworkInfoProvider = + NetworkInfoProviderImpl(context, logger, systemServiceProvider) + val resourceFactory = ResourceFactoryImpl(logger, context, config, networkInfoProvider) val currentThread = CurrentThread() - val appExitProvider: AppExitProvider = AppExitProviderImpl(context, logger, currentThread) + val appExitProvider: AppExitProvider = + AppExitProviderImpl(logger, currentThread, systemServiceProvider) val pidProvider: PidProvider = PidProviderImpl() val sessionReportGenerator = SessionReportGenerator(logger, storage, appExitProvider) val sessionProvider = @@ -67,13 +76,18 @@ object Measure { ).apply { start() } // Register data collectors - UnhandledExceptionCollector(logger, eventTracker, timeProvider).register() - AnrCollector(logger, context, timeProvider, eventTracker).register() + UnhandledExceptionCollector(logger, eventTracker, timeProvider, networkInfoProvider) + .register() + AnrCollector(logger, systemServiceProvider, networkInfoProvider, timeProvider, eventTracker) + .register() AppLaunchCollector( logger, application, timeProvider, coldLaunchTrace, eventTracker, coldLaunchListener = { LifecycleCollector(context, eventTracker, timeProvider, currentThread).register() GestureCollector(logger, eventTracker, timeProvider, currentThread).register() + NetworkChangesCollector( + context, systemServiceProvider, logger, eventTracker, timeProvider, currentThread + ).register() sessionController.syncAllSessions() }, ).register() diff --git a/measure-android/measure/src/main/java/sh/measure/android/anr/ANRWatchDog.kt b/measure-android/measure/src/main/java/sh/measure/android/anr/ANRWatchDog.kt index 441877295..a8cd1c447 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/anr/ANRWatchDog.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/anr/ANRWatchDog.kt @@ -24,18 +24,18 @@ package sh.measure.android.anr import android.app.ActivityManager import android.app.ActivityManager.ProcessErrorStateInfo -import android.content.Context import android.os.Debug import android.os.Handler import android.os.Looper import android.os.Process +import sh.measure.android.utils.SystemServiceProvider import sh.measure.android.utils.TimeProvider /** * A watchdog timer thread that detects when the UI thread has frozen. */ internal class ANRWatchDog( - private val context: Context, + private val systemServiceProvider: SystemServiceProvider, private val timeoutInterval: Int, private val timeProvider: TimeProvider, private val anrListener: ANRListener, @@ -85,8 +85,7 @@ internal class ANRWatchDog( // Verify ANR state by checking activity manager ProcessErrorStateInfo. // Don't report an ANR if the process error state is not ANR. - val activityManager = - context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val activityManager = systemServiceProvider.activityManager val pid = Process.myPid() val processErrorState = captureProcessErrorState(activityManager, pid) if (processErrorState != null && processErrorState.condition != ProcessErrorStateInfo.NOT_RESPONDING) { diff --git a/measure-android/measure/src/main/java/sh/measure/android/anr/AnrCollector.kt b/measure-android/measure/src/main/java/sh/measure/android/anr/AnrCollector.kt index 334a5b5a9..421a68508 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/anr/AnrCollector.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/anr/AnrCollector.kt @@ -1,24 +1,26 @@ package sh.measure.android.anr -import android.content.Context import sh.measure.android.events.EventTracker import sh.measure.android.exceptions.ExceptionFactory import sh.measure.android.exceptions.MeasureException import sh.measure.android.logger.LogLevel import sh.measure.android.logger.Logger +import sh.measure.android.network_change.NetworkInfoProvider +import sh.measure.android.utils.SystemServiceProvider import sh.measure.android.utils.TimeProvider private const val ANR_TIMEOUT_MILLIS = 5000 internal class AnrCollector( private val logger: Logger, - private val context: Context, + private val systemServiceProvider: SystemServiceProvider, + private val networkInfoProvider: NetworkInfoProvider, private val timeProvider: TimeProvider, private val tracker: EventTracker ) : ANRWatchDog.ANRListener { fun register() { ANRWatchDog( - context = context, + systemServiceProvider = systemServiceProvider, timeoutInterval = ANR_TIMEOUT_MILLIS, timeProvider = timeProvider, anrListener = this @@ -31,11 +33,15 @@ internal class AnrCollector( } private fun toMeasureException(anr: AnrError): MeasureException { + val networkType = networkInfoProvider.getNetworkType() return ExceptionFactory.createMeasureException( throwable = anr, handled = false, timestamp = anr.timestamp, thread = anr.thread, + networkType = networkType, + networkGeneration = networkInfoProvider.getNetworkGeneration(networkType), + networkProvider = networkInfoProvider.getNetworkProvider(networkType), isAnr = true ) } diff --git a/measure-android/measure/src/main/java/sh/measure/android/appexit/AppExitProvider.kt b/measure-android/measure/src/main/java/sh/measure/android/appexit/AppExitProvider.kt index 62ffbf399..8da48d2a2 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/appexit/AppExitProvider.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/appexit/AppExitProvider.kt @@ -2,7 +2,6 @@ package sh.measure.android.appexit import android.app.ActivityManager import android.app.ApplicationExitInfo -import android.content.Context import android.os.Build import androidx.annotation.RequiresApi import okio.Buffer @@ -12,6 +11,7 @@ import okio.source import sh.measure.android.logger.LogLevel import sh.measure.android.logger.Logger import sh.measure.android.utils.CurrentThread +import sh.measure.android.utils.SystemServiceProvider import sh.measure.android.utils.iso8601Timestamp import java.io.InputStream @@ -20,23 +20,19 @@ internal interface AppExitProvider { } internal class AppExitProviderImpl( - private val context: Context, private val logger: Logger, val currentThread: CurrentThread + private val logger: Logger, + private val currentThread: CurrentThread, + private val systemServiceProvider: SystemServiceProvider ) : AppExitProvider { override fun get(pid: Int): AppExit? { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { return null } - return try { - val activityManager = - context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val historicalExitReason = - activityManager.getHistoricalProcessExitReasons(null, pid, 1).firstOrNull() - historicalExitReason?.toAppExit(currentThread.name) - } catch (e: Exception) { - logger.log(LogLevel.Error, "Failed to get exit info for pid: $pid", e) - null - } + return systemServiceProvider.activityManager?.runCatching { + getHistoricalProcessExitReasons(null, pid, 1).firstOrNull() + ?.toAppExit(currentThread.name) + }?.getOrNull() } @RequiresApi(Build.VERSION_CODES.R) diff --git a/measure-android/measure/src/main/java/sh/measure/android/events/Event.kt b/measure-android/measure/src/main/java/sh/measure/android/events/Event.kt index 8e52180bf..841bbe28c 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/events/Event.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/events/Event.kt @@ -9,6 +9,7 @@ import sh.measure.android.app_launch.ColdLaunchEvent import sh.measure.android.app_launch.HotLaunchEvent import sh.measure.android.app_launch.WarmLaunchEvent import sh.measure.android.appexit.AppExit +import sh.measure.android.network_change.NetworkChangeEvent import sh.measure.android.exceptions.MeasureException import sh.measure.android.gestures.ClickEvent import sh.measure.android.gestures.LongClickEvent @@ -139,4 +140,12 @@ internal fun HotLaunchEvent.toEvent(): Event { data = Json.encodeToJsonElement(HotLaunchEvent.serializer(), this), thread_name = thread_name ) +} +internal fun NetworkChangeEvent.toEvent() : Event { + return Event( + type = EventType.NETWORK_CHANGE, + timestamp = timestamp.iso8601Timestamp(), + data = Json.encodeToJsonElement(NetworkChangeEvent.serializer(), this), + thread_name = thread_name + ) } \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/events/EventTracker.kt b/measure-android/measure/src/main/java/sh/measure/android/events/EventTracker.kt index 0fca9962f..809b22eb8 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/events/EventTracker.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/events/EventTracker.kt @@ -13,6 +13,7 @@ import sh.measure.android.lifecycle.ApplicationLifecycleEvent import sh.measure.android.lifecycle.FragmentLifecycleEvent import sh.measure.android.logger.LogLevel import sh.measure.android.logger.Logger +import sh.measure.android.network_change.NetworkChangeEvent import sh.measure.android.session.SessionController internal interface EventTracker { @@ -27,6 +28,7 @@ internal interface EventTracker { fun trackColdLaunch(event: ColdLaunchEvent) fun trackWarmLaunchEvent(event: WarmLaunchEvent) fun trackHotLaunchEvent(event: HotLaunchEvent) + fun trackNetworkChange(event: NetworkChangeEvent) fun storeAttachment(attachmentInfo: AttachmentInfo) } @@ -98,6 +100,11 @@ internal class MeasureEventTracker( sessionController.storeEvent(event.toEvent()) } + override fun trackNetworkChange(event: NetworkChangeEvent) { + logger.log(LogLevel.Error, "Tracking network change ${event.network_type}") + sessionController.storeEvent(event.toEvent()) + } + override fun storeAttachment(attachmentInfo: AttachmentInfo) { logger.log(LogLevel.Debug, "Storing attachment ${attachmentInfo.name}") sessionController.storeAttachment(attachmentInfo) diff --git a/measure-android/measure/src/main/java/sh/measure/android/events/EventType.kt b/measure-android/measure/src/main/java/sh/measure/android/events/EventType.kt index a9b1c5918..fe9477602 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/events/EventType.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/events/EventType.kt @@ -14,4 +14,5 @@ object EventType { const val COLD_LAUNCH: String = "cold_launch" const val WARM_LAUNCH: String = "warm_launch" const val HOT_LAUNCH: String = "hot_launch" + const val NETWORK_CHANGE: String = "network_change" } \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/exceptions/ExceptionFactory.kt b/measure-android/measure/src/main/java/sh/measure/android/exceptions/ExceptionFactory.kt index 4d640a634..8f6ea5f14 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/exceptions/ExceptionFactory.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/exceptions/ExceptionFactory.kt @@ -12,7 +12,10 @@ internal object ExceptionFactory { handled: Boolean, timestamp: Long, thread: Thread, - isAnr: Boolean = false + networkType: String?, + networkGeneration: String?, + networkProvider: String?, + isAnr: Boolean = false, ): MeasureException { val exceptions = mutableListOf() var error: Throwable? = throwable @@ -42,17 +45,25 @@ internal object ExceptionFactory { val measureThread = MeasureThread( name = t.name, frames = stackTrace.trimStackTrace().map { stackTraceElement -> - Frame( - class_name = stackTraceElement.className, - method_name = stackTraceElement.methodName, - file_name = stackTraceElement.fileName, - line_num = stackTraceElement.lineNumber, - ) - }) + Frame( + class_name = stackTraceElement.className, + method_name = stackTraceElement.methodName, + file_name = stackTraceElement.fileName, + line_num = stackTraceElement.lineNumber, + ) + }) threads.add(measureThread) } count++ } - return MeasureException(timestamp, thread.name, exceptions, threads, handled, isAnr) + return MeasureException( + timestamp, thread.name, exceptions, + threads = threads, + handled = handled, + network_type = networkType, + network_provider = networkProvider, + network_generation = networkGeneration, + isAnr = isAnr, + ) } } diff --git a/measure-android/measure/src/main/java/sh/measure/android/exceptions/MeasureException.kt b/measure-android/measure/src/main/java/sh/measure/android/exceptions/MeasureException.kt index 39b4d838d..1304fb2fb 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/exceptions/MeasureException.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/exceptions/MeasureException.kt @@ -2,6 +2,8 @@ package sh.measure.android.exceptions import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import sh.measure.android.network_change.NetworkType +import sh.measure.android.network_change.NetworkGeneration /** * Represents an exception in Measure. This is used to track handled and unhandled exceptions. @@ -34,6 +36,23 @@ internal data class MeasureException( */ val handled: Boolean, + /** + * The [NetworkType] that was active when the exception occurred. + */ + val network_type: String?, + + /** + * The network provider that was active when the exception occurred. Only set for cellular + * networks. + */ + val network_provider: String?, + + /** + * The [NetworkGeneration] that was active when the exception occurred. Only set for cellular + * networks. + */ + val network_generation: String?, + /** * Whether is exception represents an ANR or not. */ diff --git a/measure-android/measure/src/main/java/sh/measure/android/exceptions/UnhandledExceptionCollector.kt b/measure-android/measure/src/main/java/sh/measure/android/exceptions/UnhandledExceptionCollector.kt index ea8e5153e..325db4467 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/exceptions/UnhandledExceptionCollector.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/exceptions/UnhandledExceptionCollector.kt @@ -3,6 +3,7 @@ package sh.measure.android.exceptions import sh.measure.android.events.EventTracker import sh.measure.android.logger.LogLevel import sh.measure.android.logger.Logger +import sh.measure.android.network_change.NetworkInfoProvider import sh.measure.android.utils.TimeProvider import java.lang.Thread.UncaughtExceptionHandler @@ -19,6 +20,7 @@ internal class UnhandledExceptionCollector( private val logger: Logger, private val eventTracker: EventTracker, private val timeProvider: TimeProvider, + private val networkInfoProvider: NetworkInfoProvider ) : UncaughtExceptionHandler { private val originalHandler: UncaughtExceptionHandler? = @@ -35,11 +37,15 @@ internal class UnhandledExceptionCollector( override fun uncaughtException(thread: Thread, throwable: Throwable) { logger.log(LogLevel.Debug, "Unhandled exception received") try { + val networkType = networkInfoProvider.getNetworkType() val measureException = ExceptionFactory.createMeasureException( throwable, handled = false, timestamp = timeProvider.currentTimeSinceEpochInMillis, thread = thread, + networkType = networkType, + networkGeneration = networkInfoProvider.getNetworkGeneration(networkType), + networkProvider = networkInfoProvider.getNetworkProvider(networkType) ) eventTracker.trackUnhandledException(measureException) } catch (e: Throwable) { diff --git a/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangeEvent.kt b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangeEvent.kt new file mode 100644 index 000000000..bb570c578 --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangeEvent.kt @@ -0,0 +1,36 @@ +package sh.measure.android.network_change + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +internal data class NetworkChangeEvent( + /** + * The [NetworkType] of the network that was previously active. This is null if there was no + * previously active network. + */ + val previous_network_type: String?, + + /** + * The [NetworkType] of the network that is now active. + */ + val network_type: String, + + /** + * The [NetworkGeneration] of the network that was previously active. Only set for cellular + * networks. + */ + val previous_network_generation: String?, + + /** + * The [NetworkGeneration] of the network that is now active. + */ + val network_generation: String?, + + /** + * The name of the network provider that is now active. Only set for cellular networks. + */ + val network_provider: String?, + @Transient val timestamp: Long = -1L, + @Transient val thread_name: String = "" +) \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangesCollector.kt b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangesCollector.kt new file mode 100644 index 000000000..50779c92d --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkChangesCollector.kt @@ -0,0 +1,220 @@ +package sh.measure.android.network_change + +import android.Manifest +import android.Manifest.permission.READ_BASIC_PHONE_STATE +import android.Manifest.permission.READ_PHONE_STATE +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.* +import android.net.NetworkRequest +import android.os.Build +import android.telephony.TelephonyManager +import android.telephony.TelephonyManager.* +import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import sh.measure.android.events.EventTracker +import sh.measure.android.logger.LogLevel +import sh.measure.android.logger.Logger +import sh.measure.android.utils.CurrentThread +import sh.measure.android.utils.SystemServiceProvider +import sh.measure.android.utils.TimeProvider +import sh.measure.android.utils.getNetworkGeneration +import sh.measure.android.utils.hasPermission +import sh.measure.android.utils.hasPhoneStatePermission + +/** + * Monitors changes in network. It is enabled only when the app is granted the + * ACCESS_NETWORK_STATE permission and the device is running on Android M (SDK 23) or a higher version. + * + * Tracking of [NetworkChangeEvent.network_generation] is limited to [NetworkType.CELLULAR] for Android + * M (SDK 23) and later. This requires the app to hold the [READ_PHONE_STATE] permission, which is + * a runtime permission. In case the user denies this permission, + * [NetworkChangeEvent.network_generation] will be null. For devices running + * Android Tiramisu (SDK 33) or later, [READ_BASIC_PHONE_STATE] permission is sufficient, which does + * not require a runtime permissions. + * + * The SDK does not add any new permissions to the app. It only uses the permissions that the app + * already has. + * + * Although SDK versions 21 and 22 also support registering network callbacks, the + * [ConnectivityManager.NetworkCallback.onCapabilitiesChanged] method is never called. + */ +internal class NetworkChangesCollector( + private val context: Context, + private val systemServiceProvider: SystemServiceProvider, + private val logger: Logger, + private val eventTracker: EventTracker, + private val timeProvider: TimeProvider, + private val currentThread: CurrentThread +) { + private var currentNetworkType: String? = null + private var currentNetworkGeneration: String? = null + private val telephonyManager: TelephonyManager? by lazy(mode = LazyThreadSafetyMode.NONE) { + systemServiceProvider.telephonyManager + } + + @SuppressLint("MissingPermission") + fun register() { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { + if (hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + val connectivityManager = systemServiceProvider.connectivityManager ?: return + connectivityManager.registerDefaultNetworkCallback(networkCallback()) + } else { + logger.log( + LogLevel.Info, + "ACCESS_NETWORK_STATE permission required to monitor network changes" + ) + } + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + val connectivityManager = systemServiceProvider.connectivityManager ?: return + if (hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + connectivityManager.registerNetworkCallback( + NetworkRequest.Builder().addTransportType(TRANSPORT_CELLULAR) + .addTransportType(TRANSPORT_WIFI).addTransportType(TRANSPORT_VPN) + .addCapability(NET_CAPABILITY_INTERNET).build(), networkCallback() + ) + } else { + logger.log( + LogLevel.Info, + "ACCESS_NETWORK_STATE permission required to monitor network changes" + ) + } + } + + else -> { + logger.log( + LogLevel.Info, + "Network change monitoring is not supported on Android versions below M" + ) + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun networkCallback() = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged( + network: Network, networkCapabilities: NetworkCapabilities + ) { + val newNetworkType = getNetworkType(networkCapabilities) + val previousNetworkType = currentNetworkType + val previousNetworkGeneration = currentNetworkGeneration + val newNetworkGeneration = getNetworkGenerationIfAvailable(newNetworkType) + val networkProvider = getNetworkOperatorName(newNetworkType) + + // for Android O+, the callback is called as soon as it's registered. However, we + // only want to track changes. + // This also means, the first change event will contain non-null previous states + // for Android O+. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (currentNetworkType == null) { + currentNetworkType = newNetworkType + currentNetworkGeneration = newNetworkGeneration + return + } + } + + // only track significant changes + if (!shouldTrackNetworkChange( + newNetworkType, + previousNetworkType, + newNetworkGeneration, + previousNetworkGeneration + ) + ) { + return + } + + eventTracker.trackNetworkChange( + NetworkChangeEvent( + previous_network_type = previousNetworkType, + network_type = newNetworkType, + previous_network_generation = previousNetworkGeneration, + network_generation = newNetworkGeneration, + network_provider = networkProvider, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + currentNetworkType = newNetworkType + currentNetworkGeneration = newNetworkGeneration + } + + override fun onLost(network: Network) { + val previousNetworkType = currentNetworkType + val previousNetworkGeneration = currentNetworkGeneration + val newNetworkType = NetworkType.NO_NETWORK + if (previousNetworkType == newNetworkType) { + return + } + eventTracker.trackNetworkChange( + NetworkChangeEvent( + previous_network_type = previousNetworkType, + network_type = newNetworkType, + previous_network_generation = previousNetworkGeneration, + network_generation = null, + network_provider = null, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + currentNetworkType = newNetworkType + currentNetworkGeneration = null + } + } + + private fun getNetworkOperatorName(networkType: String): String? { + if (networkType != NetworkType.CELLULAR) return null + val name = telephonyManager?.networkOperatorName + if (name?.isBlank() == true) { + return null + } + return name + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun shouldTrackNetworkChange( + newNetworkType: String, + previousNetworkType: String?, + newNetworkGeneration: String?, + previousNetworkGeneration: String? + ): Boolean { + return when { + // track if network type has changed + previousNetworkType != newNetworkType -> { + true + } + + // track if network type is cellular, but network generation has changed + newNetworkType == NetworkType.CELLULAR && newNetworkGeneration != null && newNetworkGeneration != previousNetworkGeneration -> { + true + } + + else -> { + false + } + } + } + + @RequiresApi(Build.VERSION_CODES.M) + @SuppressLint("MissingPermission") + private fun getNetworkGenerationIfAvailable(networkType: String): String? { + if (networkType != NetworkType.CELLULAR) return null + if (hasPhoneStatePermission(context)) { + return telephonyManager?.getNetworkGeneration() + } + return null + } + + private fun getNetworkType(networkCapabilities: NetworkCapabilities) = when { + networkCapabilities.hasTransport(TRANSPORT_WIFI) -> NetworkType.WIFI + networkCapabilities.hasTransport(TRANSPORT_CELLULAR) -> NetworkType.CELLULAR + networkCapabilities.hasTransport(TRANSPORT_VPN) -> NetworkType.VPN + else -> NetworkType.UNKNOWN + } +} diff --git a/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkInfoProvider.kt b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkInfoProvider.kt new file mode 100644 index 000000000..bf685cd59 --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkInfoProvider.kt @@ -0,0 +1,89 @@ +package sh.measure.android.network_change + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.annotation.RequiresApi +import sh.measure.android.logger.LogLevel +import sh.measure.android.logger.Logger +import sh.measure.android.utils.SystemServiceProvider +import sh.measure.android.utils.getNetworkGeneration +import sh.measure.android.utils.hasPermission +import sh.measure.android.utils.hasPhoneStatePermission + +internal interface NetworkInfoProvider { + fun getNetworkGeneration(networkType: String?): String? + fun getNetworkType(): String? + fun getNetworkProvider(networkType: String?): String? +} + +internal class NetworkInfoProviderImpl( + private val context: Context, + private val logger: Logger, + private val systemServiceProvider: SystemServiceProvider +) : NetworkInfoProvider { + + override fun getNetworkProvider(networkType: String?): String? { + if (networkType != NetworkType.CELLULAR) return null + systemServiceProvider.telephonyManager?.networkOperatorName.let { + if (it.isNullOrBlank()) return null + return it + } + } + + @SuppressLint("MissingPermission") + override fun getNetworkGeneration(networkType: String?): String? { + if (networkType != NetworkType.CELLULAR) return null + return if (hasPhoneStatePermission(context)) { + systemServiceProvider.telephonyManager?.getNetworkGeneration() + } else { + null + } + } + + override fun getNetworkType(): String? { + val connectivityManager = systemServiceProvider.connectivityManager ?: return null + if (!hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(LogLevel.Debug, "No permission to access network state") + return null + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + getNetworkTypeAboveApi23(connectivityManager) + } else { + getNetworkTypeBelowApi23(connectivityManager) + } + } + + @Suppress("DEPRECATION") + @SuppressLint("MissingPermission") + private fun getNetworkTypeBelowApi23(connectivityManager: ConnectivityManager): String { + val activeNetwork = connectivityManager.activeNetworkInfo ?: return NetworkType.NO_NETWORK + return when (activeNetwork.type) { + ConnectivityManager.TYPE_WIFI -> NetworkType.WIFI + ConnectivityManager.TYPE_MOBILE -> NetworkType.CELLULAR + ConnectivityManager.TYPE_VPN -> NetworkType.VPN + else -> NetworkType.UNKNOWN + } + } + + @SuppressLint("MissingPermission") + @RequiresApi(Build.VERSION_CODES.M) + private fun getNetworkTypeAboveApi23(connectivityManager: ConnectivityManager): String { + if (!hasPermission(context, Manifest.permission.ACCESS_NETWORK_STATE)) { + logger.log(LogLevel.Debug, "No permission to access network state") + return NetworkType.UNKNOWN + } + val capabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: return NetworkType.NO_NETWORK + return when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.CELLULAR + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> NetworkType.VPN + else -> NetworkType.UNKNOWN + } + } +} \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkType.kt b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkType.kt new file mode 100644 index 000000000..0bded6ca8 --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/network_change/NetworkType.kt @@ -0,0 +1,17 @@ +package sh.measure.android.network_change + +internal object NetworkType { + const val WIFI = "wifi" + const val CELLULAR = "cellular" + const val VPN = "vpn" + const val UNKNOWN = "unknown" + const val NO_NETWORK = "no_network" +} + + +internal object NetworkGeneration { + const val SECOND_GEN = "2g" + const val THIRD_GEN = "3g" + const val FOURTH_GEN = "4g" + const val FIFTH_GEN = "5g" +} \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/session/Resource.kt b/measure-android/measure/src/main/java/sh/measure/android/session/Resource.kt index a21a4e62f..7cf8ea347 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/session/Resource.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/session/Resource.kt @@ -27,5 +27,8 @@ data class Resource( val app_version: String? = null, val app_build: String? = null, val app_unique_id: String? = null, // package name, + val network_type: String? = null, + val network_generation: String? = null, + val network_provider_name: String? = null, val measure_sdk_version: String? = null, ) diff --git a/measure-android/measure/src/main/java/sh/measure/android/session/ResourceFactory.kt b/measure-android/measure/src/main/java/sh/measure/android/session/ResourceFactory.kt index 95662f6f9..7e265b4d5 100644 --- a/measure-android/measure/src/main/java/sh/measure/android/session/ResourceFactory.kt +++ b/measure-android/measure/src/main/java/sh/measure/android/session/ResourceFactory.kt @@ -6,6 +6,7 @@ import android.os.Build import sh.measure.android.Config import sh.measure.android.logger.LogLevel import sh.measure.android.logger.Logger +import sh.measure.android.network_change.NetworkInfoProvider interface ResourceFactory { fun create(): Resource @@ -15,7 +16,10 @@ interface ResourceFactory { * Factory to create a [Resource]. */ internal class ResourceFactoryImpl( - private val logger: Logger, private val context: Context, private val config: Config + private val logger: Logger, + private val context: Context, + private val config: Config, + private val networkInfoProvider: NetworkInfoProvider, ) : ResourceFactory { private val configuration = context.resources.configuration private val packageManager = context.packageManager @@ -29,6 +33,7 @@ internal class ResourceFactoryImpl( } override fun create(): Resource { + val networkType = networkInfoProvider.getNetworkType() return Resource( device_name = Build.DEVICE, device_model = Build.MODEL, @@ -46,10 +51,14 @@ internal class ResourceFactoryImpl( app_version = packageInfo.versionName, app_build = getBuildVersionCode(), app_unique_id = context.packageName, - measure_sdk_version = getMeasureVersion() + network_type = networkType, + network_generation = networkInfoProvider.getNetworkGeneration(networkType), + network_provider_name = networkInfoProvider.getNetworkProvider(networkType), + measure_sdk_version = getMeasureVersion(), ) } + // Using heuristics from: // https://android-developers.googleblog.com/2023/06/detecting-if-device-is-foldable-tablet.html private fun getDeviceType(): String? { diff --git a/measure-android/measure/src/main/java/sh/measure/android/utils/Permissions.kt b/measure-android/measure/src/main/java/sh/measure/android/utils/Permissions.kt new file mode 100644 index 000000000..7b3955831 --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/utils/Permissions.kt @@ -0,0 +1,11 @@ +package sh.measure.android.utils + +import android.content.Context +import androidx.core.content.PermissionChecker + +internal fun hasPermission(context: Context, permission: String): Boolean { + return PermissionChecker.checkSelfPermission( + context, + permission + ) == PermissionChecker.PERMISSION_GRANTED +} \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/utils/SystemServiceProvider.kt b/measure-android/measure/src/main/java/sh/measure/android/utils/SystemServiceProvider.kt new file mode 100644 index 000000000..0fd1fe330 --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/utils/SystemServiceProvider.kt @@ -0,0 +1,26 @@ +package sh.measure.android.utils + +import android.app.ActivityManager +import android.content.Context +import android.net.ConnectivityManager +import android.telephony.TelephonyManager + +internal interface SystemServiceProvider { + val connectivityManager: ConnectivityManager? + val telephonyManager: TelephonyManager? + val activityManager: ActivityManager? +} + +internal class SystemServiceProviderImpl(private val context: Context) : SystemServiceProvider { + override val activityManager: ActivityManager? by lazy(mode = LazyThreadSafetyMode.NONE) { + runCatching { context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager }.getOrNull() + } + + override val connectivityManager: ConnectivityManager? by lazy(mode = LazyThreadSafetyMode.NONE) { + runCatching { context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager }.getOrNull() + } + + override val telephonyManager: TelephonyManager? by lazy(mode = LazyThreadSafetyMode.NONE) { + runCatching { context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager }.getOrNull() + } +} \ No newline at end of file diff --git a/measure-android/measure/src/main/java/sh/measure/android/utils/Telephony.kt b/measure-android/measure/src/main/java/sh/measure/android/utils/Telephony.kt new file mode 100644 index 000000000..c82d9382a --- /dev/null +++ b/measure-android/measure/src/main/java/sh/measure/android/utils/Telephony.kt @@ -0,0 +1,62 @@ +package sh.measure.android.utils + +import android.Manifest.permission.READ_BASIC_PHONE_STATE +import android.Manifest.permission.READ_PHONE_STATE +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telephony.TelephonyManager +import androidx.annotation.RequiresPermission +import sh.measure.android.network_change.NetworkGeneration + +internal fun hasPhoneStatePermission(context: Context): Boolean { + return when { + hasPermission(context, READ_PHONE_STATE) -> { + true + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + hasPermission(context, READ_BASIC_PHONE_STATE) + } + + else -> { + false + } + } +} + +@Suppress("DEPRECATION") +@SuppressLint("InlinedApi") +@RequiresPermission(anyOf = [READ_PHONE_STATE, READ_BASIC_PHONE_STATE]) +internal fun TelephonyManager.getNetworkGeneration(): String? { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + internalNetworkGeneration(this.networkType) + } else { + val networkType = this.dataNetworkType + internalNetworkGeneration(networkType) + } +} + +private fun internalNetworkGeneration(networkType: Int): String? { + when (networkType) { + TelephonyManager.NETWORK_TYPE_GPRS, TelephonyManager.NETWORK_TYPE_EDGE, TelephonyManager.NETWORK_TYPE_CDMA, TelephonyManager.NETWORK_TYPE_1xRTT, TelephonyManager.NETWORK_TYPE_IDEN, TelephonyManager.NETWORK_TYPE_GSM -> { + return NetworkGeneration.SECOND_GEN + } + + TelephonyManager.NETWORK_TYPE_UMTS, TelephonyManager.NETWORK_TYPE_EVDO_0, TelephonyManager.NETWORK_TYPE_EVDO_A, TelephonyManager.NETWORK_TYPE_HSDPA, TelephonyManager.NETWORK_TYPE_HSUPA, TelephonyManager.NETWORK_TYPE_HSPA, TelephonyManager.NETWORK_TYPE_EVDO_B, TelephonyManager.NETWORK_TYPE_EHRPD, TelephonyManager.NETWORK_TYPE_HSPAP, TelephonyManager.NETWORK_TYPE_TD_SCDMA -> { + return NetworkGeneration.THIRD_GEN + } + + TelephonyManager.NETWORK_TYPE_LTE -> { + return NetworkGeneration.FOURTH_GEN + } + + TelephonyManager.NETWORK_TYPE_NR -> { + return NetworkGeneration.FIFTH_GEN + } + + else -> { + return null + } + } +} diff --git a/measure-android/measure/src/test/java/sh/measure/android/anr/AnrCollectorTest.kt b/measure-android/measure/src/test/java/sh/measure/android/anr/AnrCollectorTest.kt index 9619117e6..c26f1fa43 100644 --- a/measure-android/measure/src/test/java/sh/measure/android/anr/AnrCollectorTest.kt +++ b/measure-android/measure/src/test/java/sh/measure/android/anr/AnrCollectorTest.kt @@ -1,23 +1,27 @@ package sh.measure.android.anr -import android.content.Context import org.junit.Test import org.mockito.Mockito.mock import org.mockito.kotlin.verify import sh.measure.android.events.EventTracker import sh.measure.android.exceptions.ExceptionFactory +import sh.measure.android.fakes.FakeNetworkInfoProvider import sh.measure.android.fakes.FakeTimeProvider import sh.measure.android.fakes.NoopLogger +import sh.measure.android.utils.SystemServiceProvider class AnrCollectorTest { private val logger = NoopLogger() private val timeProvider = FakeTimeProvider() + private val networkInfoProvider = FakeNetworkInfoProvider() private val eventTracker = mock() - private val context = mock() + private val systemServiceProvider = mock() @Test fun `AnrCollector tracks exception using event tracker, when ANR is detected`() { - val anrCollector = AnrCollector(logger, context, timeProvider, eventTracker) + val anrCollector = AnrCollector( + logger, systemServiceProvider, networkInfoProvider, timeProvider, eventTracker + ) val thread = Thread.currentThread() val message = "ANR" val timestamp = timeProvider.currentTimeSinceEpochInMillis @@ -33,6 +37,9 @@ class AnrCollectorTest { handled = false, timestamp = anrError.timestamp, thread = thread, + networkType = networkInfoProvider.getNetworkType(), + networkGeneration = networkInfoProvider.getNetworkGeneration(networkInfoProvider.getNetworkType()), + networkProvider = networkInfoProvider.getNetworkProvider(networkInfoProvider.getNetworkType()), isAnr = true ) ) diff --git a/measure-android/measure/src/test/java/sh/measure/android/events/EventKtTest.kt b/measure-android/measure/src/test/java/sh/measure/android/events/EventKtTest.kt index 7ad0ee851..2b020b78b 100644 --- a/measure-android/measure/src/test/java/sh/measure/android/events/EventKtTest.kt +++ b/measure-android/measure/src/test/java/sh/measure/android/events/EventKtTest.kt @@ -8,6 +8,9 @@ import org.junit.Test import sh.measure.android.app_launch.ColdLaunchEvent import sh.measure.android.app_launch.HotLaunchEvent import sh.measure.android.app_launch.WarmLaunchEvent +import sh.measure.android.network_change.NetworkChangeEvent +import sh.measure.android.network_change.NetworkGeneration +import sh.measure.android.network_change.NetworkType import sh.measure.android.exceptions.ExceptionFactory import sh.measure.android.gestures.ClickEvent import sh.measure.android.gestures.Direction @@ -61,6 +64,9 @@ class EventKtTest { handled = false, timestamp = timestamp, thread = Thread.currentThread(), + networkType = null, + networkGeneration = null, + networkProvider = null, isAnr = false ) val event = exception.toEvent() @@ -79,6 +85,9 @@ class EventKtTest { handled = false, timestamp = timestamp, thread = Thread.currentThread(), + networkType = null, + networkGeneration = null, + networkProvider = null, isAnr = true ) val event = exception.toEvent() @@ -230,4 +239,25 @@ class EventKtTest { assertEquals(timestampIso, event.timestamp) assertEquals(EventType.HOT_LAUNCH, event.type) } + + @Test + fun `ConnectivityChange toEvent() returns an event of type network_type_change`() { + val timestamp = 0L + val timestampIso = timestamp.iso8601Timestamp() + val threadName = "thread" + val connectivityChange = NetworkChangeEvent( + previous_network_type = NetworkType.WIFI, + network_type = NetworkType.CELLULAR, + previous_network_generation = null, + network_generation = NetworkGeneration.FIFTH_GEN, + network_provider = null, + timestamp = timestamp, + thread_name = threadName + ) + val event = connectivityChange.toEvent() + + assertEquals(threadName, event.thread_name) + assertEquals(timestampIso, event.timestamp) + assertEquals(EventType.NETWORK_CHANGE, event.type) + } } \ No newline at end of file diff --git a/measure-android/measure/src/test/java/sh/measure/android/exceptions/ExceptionFactoryTest.kt b/measure-android/measure/src/test/java/sh/measure/android/exceptions/ExceptionFactoryTest.kt index 052fa1a1e..f7e32c23f 100644 --- a/measure-android/measure/src/test/java/sh/measure/android/exceptions/ExceptionFactoryTest.kt +++ b/measure-android/measure/src/test/java/sh/measure/android/exceptions/ExceptionFactoryTest.kt @@ -18,7 +18,7 @@ class ExceptionFactoryTest { // When val measureException = ExceptionFactory.createMeasureException( - exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread + exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread, null, null, null ) // Then @@ -40,7 +40,7 @@ class ExceptionFactoryTest { // When val measureException = ExceptionFactory.createMeasureException( - exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread + exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread, null, null, null ) // Then @@ -66,7 +66,7 @@ class ExceptionFactoryTest { // When val measureException = ExceptionFactory.createMeasureException( - exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread + exception, handled = true, timeProvider.currentTimeSinceEpochInMillis, thread, null, null, null ) // Then @@ -81,10 +81,33 @@ class ExceptionFactoryTest { // When val measureException = ExceptionFactory.createMeasureException( - exception, handled = false, timeProvider.currentTimeSinceEpochInMillis, thread + exception, handled = false, timeProvider.currentTimeSinceEpochInMillis, thread, null, null, null ) // Then assertFalse(measureException.handled) } + + @Test + fun `ExceptionFactory sets network info`() { + // Given + val exception = IllegalArgumentException("Test exception") + val thread = Thread.currentThread() + + // When + val measureException = ExceptionFactory.createMeasureException( + exception, + handled = false, + timeProvider.currentTimeSinceEpochInMillis, + thread = thread, + networkType = "network_type", + networkGeneration = "network_gen", + networkProvider = "network_provider" + ) + + // Then + assertEquals("network_type", measureException.network_type) + assertEquals("network_gen", measureException.network_generation) + assertEquals("network_provider", measureException.network_provider) + } } \ No newline at end of file diff --git a/measure-android/measure/src/test/java/sh/measure/android/exceptions/UnhandledExceptionCollectorTest.kt b/measure-android/measure/src/test/java/sh/measure/android/exceptions/UnhandledExceptionCollectorTest.kt index 3d45ba90b..2ea324070 100644 --- a/measure-android/measure/src/test/java/sh/measure/android/exceptions/UnhandledExceptionCollectorTest.kt +++ b/measure-android/measure/src/test/java/sh/measure/android/exceptions/UnhandledExceptionCollectorTest.kt @@ -6,15 +6,17 @@ import org.junit.Before import org.junit.Test import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import sh.measure.android.events.EventTracker +import sh.measure.android.fakes.FakeNetworkInfoProvider import sh.measure.android.fakes.FakeTimeProvider import sh.measure.android.fakes.NoopLogger -import sh.measure.android.events.EventTracker internal class UnhandledExceptionCollectorTest { private var originalDefaultHandler: Thread.UncaughtExceptionHandler? = null private val logger = NoopLogger() private val timeProvider = FakeTimeProvider() + private val networkInfoProvider = FakeNetworkInfoProvider() private val eventTracker = mock() @Before @@ -25,8 +27,9 @@ internal class UnhandledExceptionCollectorTest { @Test fun `UnhandledExceptionCollector registers itself as an uncaught exception handler`() { // When - val collector = - UnhandledExceptionCollector(logger, eventTracker, timeProvider).apply { register() } + val collector = UnhandledExceptionCollector( + logger, eventTracker, timeProvider, networkInfoProvider + ).apply { register() } val currentDefaultHandler = Thread.getDefaultUncaughtExceptionHandler() // Then @@ -35,14 +38,22 @@ internal class UnhandledExceptionCollectorTest { @Test fun `UnhandledExceptionCollector tracks uncaught exceptions`() { - val collector = - UnhandledExceptionCollector(logger, eventTracker, timeProvider).apply { register() } + val collector = UnhandledExceptionCollector( + logger, eventTracker, timeProvider, networkInfoProvider + ).apply { register() } // Given val thread = Thread.currentThread() val exception = RuntimeException("Test exception") + val networkType = networkInfoProvider.getNetworkType() val expectedException = ExceptionFactory.createMeasureException( - exception, handled = false, timeProvider.currentTimeSinceEpochInMillis, thread + exception, + handled = false, + timeProvider.currentTimeSinceEpochInMillis, + thread = thread, + networkType = networkType, + networkGeneration = networkInfoProvider.getNetworkGeneration(networkType), + networkProvider = networkInfoProvider.getNetworkProvider(networkType) ) // When @@ -60,8 +71,9 @@ internal class UnhandledExceptionCollectorTest { Thread.setDefaultUncaughtExceptionHandler { _, _ -> originalHandlerCalled = true } - val collector = - UnhandledExceptionCollector(logger, eventTracker, timeProvider).apply { register() } + val collector = UnhandledExceptionCollector( + logger, eventTracker, timeProvider, networkInfoProvider + ).apply { register() } // Given val thread = Thread.currentThread() diff --git a/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeNetworkInfoProvider.kt b/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeNetworkInfoProvider.kt new file mode 100644 index 000000000..61de0a52a --- /dev/null +++ b/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeNetworkInfoProvider.kt @@ -0,0 +1,19 @@ +package sh.measure.android.fakes + +import sh.measure.android.network_change.NetworkGeneration +import sh.measure.android.network_change.NetworkInfoProvider +import sh.measure.android.network_change.NetworkType + +internal class FakeNetworkInfoProvider : NetworkInfoProvider { + override fun getNetworkGeneration(networkType: String?): String { + return NetworkGeneration.FIFTH_GEN + } + + override fun getNetworkType(): String { + return NetworkType.CELLULAR + } + + override fun getNetworkProvider(networkType: String?): String { + return "Android" + } +} \ No newline at end of file diff --git a/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeResourceFactory.kt b/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeResourceFactory.kt index e0deed80c..fe78b9d2c 100644 --- a/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeResourceFactory.kt +++ b/measure-android/measure/src/test/java/sh/measure/android/fakes/FakeResourceFactory.kt @@ -1,5 +1,7 @@ package sh.measure.android.fakes +import sh.measure.android.network_change.NetworkGeneration +import sh.measure.android.network_change.NetworkType import sh.measure.android.session.Resource import sh.measure.android.session.ResourceFactory @@ -28,5 +30,8 @@ private fun fakeResource() = Resource( app_version = "app_version", app_build = "app_build", app_unique_id = "app_unique_id", + network_type = NetworkType.WIFI, + network_generation = NetworkGeneration.FIFTH_GEN, + network_provider_name = "Android", measure_sdk_version = "measure_sdk_version" ) \ No newline at end of file diff --git a/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkChangesCollectorTest.kt b/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkChangesCollectorTest.kt new file mode 100644 index 000000000..965bd9bd4 --- /dev/null +++ b/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkChangesCollectorTest.kt @@ -0,0 +1,490 @@ +package sh.measure.android.network_change + +import android.Manifest +import android.app.Application +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.telephony.TelephonyManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.kotlin.verify +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowNetwork +import org.robolectric.shadows.ShadowNetworkCapabilities +import sh.measure.android.events.EventTracker +import sh.measure.android.fakes.FakeTimeProvider +import sh.measure.android.fakes.NoopLogger +import sh.measure.android.utils.CurrentThread +import sh.measure.android.utils.SystemServiceProvider +import sh.measure.android.utils.SystemServiceProviderImpl + + +@RunWith(AndroidJUnit4::class) +class NetworkChangesCollectorTest { + private val currentThread = CurrentThread() + private val logger = NoopLogger() + private val timeProvider = FakeTimeProvider() + private val eventTracker = mock() + private lateinit var systemServiceProvider: SystemServiceProvider + private lateinit var connectivityManager: ConnectivityManager + private lateinit var telephonyManager: TelephonyManager + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + systemServiceProvider = SystemServiceProviderImpl(context) + connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + } + + @Test + @Config(sdk = [23, 24]) + fun `NetworkChangesCollector does not register network callbacks if permission not available`() { + shadowOf(context as Application).denyPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + Assert.assertEquals(0, shadowOf(connectivityManager).networkCallbacks.size) + } + + @Test + @Config(sdk = [21, 22]) + fun `NetworkChangesCollector does not register network callbacks below API 23`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + Assert.assertEquals(0, shadowOf(connectivityManager).networkCallbacks.size) + } + + @Test + @Config(sdk = [23, 24, 26, 28, 29, 30, 31, 33]) + fun `NetworkChangesCollector registers network callbacks when permission is available`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + Assert.assertEquals(1, shadowOf(connectivityManager).networkCallbacks.size) + } + + @Test + @Config(sdk = [23, 33]) + fun `NetworkChangesCollector tracks change to cellular network with network_provider and network_generation`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(context as Application).grantPermissions(Manifest.permission.READ_PHONE_STATE) + shadowOf(telephonyManager).setNetworkOperatorName("Test Provider") + setNetworkTypeInTelephonyManager(networkType = TelephonyManager.NETWORK_TYPE_NR) + var previousNetworkType: String? = null + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // first change is discarded for Android O and above + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_WIFI) + previousNetworkType = NetworkType.WIFI + } + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_CELLULAR) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = previousNetworkType, + network_type = NetworkType.CELLULAR, + previous_network_generation = null, + network_generation = NetworkGeneration.FIFTH_GEN, + network_provider = "Test Provider", + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + @Config(sdk = [23]) + fun `NetworkChangesCollector tracks change to cellular network without network_generation if READ_PHONE_STATE permission is not available`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(telephonyManager).setNetworkOperatorName("Test Provider") + setNetworkTypeInTelephonyManager(networkType = TelephonyManager.NETWORK_TYPE_NR) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_CELLULAR) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = null, + network_type = NetworkType.CELLULAR, + previous_network_generation = null, + network_generation = null, + network_provider = "Test Provider", + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + @Config(sdk = [33]) + fun `NetworkChangesCollector tracks change to cellular network with network_provider & network_generation if READ_BASIC_PHONE_STATE permission is available`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(context as Application).grantPermissions(Manifest.permission.READ_BASIC_PHONE_STATE) + shadowOf(telephonyManager).setNetworkOperatorName("Test Provider") + setNetworkTypeInTelephonyManager(networkType = TelephonyManager.NETWORK_TYPE_NR) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_WIFI) + } + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_CELLULAR) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = NetworkType.WIFI, + network_type = NetworkType.CELLULAR, + previous_network_generation = null, + network_generation = NetworkGeneration.FIFTH_GEN, + network_provider = "Test Provider", + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + @Config(sdk = [26, 33]) + fun `NetworkChangesCollector discards first change for SDK 26 and above`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(context as Application).grantPermissions(Manifest.permission.READ_BASIC_PHONE_STATE) + shadowOf(telephonyManager).setNetworkOperatorName("Test Provider") + setNetworkTypeInTelephonyManager(networkType = TelephonyManager.NETWORK_TYPE_NR) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_CELLULAR) + + Mockito.verifyNoInteractions(eventTracker) + } + + @Test + @Config(sdk = [23]) + fun `NetworkChangesCollector tracks change to wifi network`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_WIFI) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = null, + network_type = NetworkType.WIFI, + previous_network_generation = null, + network_generation = null, + network_provider = null, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + fun `NetworkChangesCollector should track network change with new network type and no previous`() { + // Simulate different previous and new network types + val previousNetworkType = null + val newNetworkType = NetworkType.WIFI + + // Simulate the current network generation as null + val previousNetworkGeneration: String? = null + val newNetworkGeneration: String? = null + + val collector = NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ) + + val shouldTrackChange = collector.shouldTrackNetworkChange( + newNetworkType, + previousNetworkType, + newNetworkGeneration, + previousNetworkGeneration + ) + + // Assert that the change should be tracked + assertTrue(shouldTrackChange) + } + + @Test + fun `NetworkChangesCollector should track network change with different previous and new network type`() { + // Simulate different previous and new network types + val previousNetworkType = NetworkType.CELLULAR + val newNetworkType = NetworkType.WIFI + + // Simulate the current network generation as null + val previousNetworkGeneration: String? = null + val newNetworkGeneration: String? = null + + val collector = NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ) + + val shouldTrackChange = collector.shouldTrackNetworkChange( + newNetworkType, + previousNetworkType, + newNetworkGeneration, + previousNetworkGeneration + ) + + // Assert that the change should be tracked + assertTrue(shouldTrackChange) + } + + @Test + fun `NetworkChangesCollector should track network change for cellular network when previous gen is not equal to new gen`() { + // Simulate a previous cellular network type with a different previous and new generation + val previousNetworkType = NetworkType.CELLULAR + val newNetworkType = NetworkType.CELLULAR + + // Simulate different previous and new network generations + val previousNetworkGeneration = "4G" + val newNetworkGeneration = "5G" + + val collector = NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider + ) + + val shouldTrackChange = collector.shouldTrackNetworkChange( + newNetworkType, + previousNetworkType, + newNetworkGeneration, + previousNetworkGeneration + ) + + // Assert that the change should be tracked as the cellular network generations differ + assertTrue(shouldTrackChange) + } + + @Test + fun `NetworkChangesCollector should not track network change for same network type and generation`() { + // Simulate a cellular network type with the same previous and new generation + val previousNetworkType = NetworkType.CELLULAR + val newNetworkType = NetworkType.CELLULAR + + // Simulate the same previous and new network generations + val previousNetworkGeneration = "4G" + val newNetworkGeneration = "4G" + + val collector = NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider + ) + + val shouldTrackChange = collector.shouldTrackNetworkChange( + newNetworkType, + previousNetworkType, + newNetworkGeneration, + previousNetworkGeneration + ) + + // Assert that the change should not be tracked as the network type and generation are the same + Assert.assertFalse(shouldTrackChange) + } + + @Test + @Config(sdk = [23]) + fun `NetworkChangesCollector tracks change to VPN network`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_VPN) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = null, + network_type = NetworkType.VPN, + previous_network_generation = null, + network_generation = null, + network_provider = null, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + @Config(sdk = [23]) + fun `NetworkChangesCollector tracks network change event when network is lost`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + val networkCallback = shadowOf(connectivityManager).networkCallbacks.first() + val network = ShadowNetwork.newInstance(789) + networkCallback.onLost(network) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = null, + network_type = NetworkType.NO_NETWORK, + previous_network_generation = null, + network_generation = null, + network_provider = null, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + @Test + @Config(sdk = [23]) + fun `NetworkChangesCollector discards first change with previous network when network is lost`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(context as Application).grantPermissions(Manifest.permission.READ_PHONE_STATE) + shadowOf(telephonyManager).setNetworkOperatorName("Test Provider") + setNetworkTypeInTelephonyManager(networkType = TelephonyManager.NETWORK_TYPE_NR) + + NetworkChangesCollector( + context = context, + logger = logger, + eventTracker = eventTracker, + timeProvider = timeProvider, + currentThread = currentThread, + systemServiceProvider = systemServiceProvider, + ).register() + + triggerNetworkCapabilitiesChange(addTransportType = NetworkCapabilities.TRANSPORT_CELLULAR) + val networkCallback = shadowOf(connectivityManager).networkCallbacks.first() + val network = ShadowNetwork.newInstance(789) + networkCallback.onLost(network) + + verify(eventTracker).trackNetworkChange( + NetworkChangeEvent( + previous_network_type = NetworkType.CELLULAR, + network_type = NetworkType.NO_NETWORK, + previous_network_generation = NetworkGeneration.FIFTH_GEN, + network_generation = null, + network_provider = null, + timestamp = timeProvider.currentTimeSinceEpochInMillis, + thread_name = currentThread.name + ) + ) + } + + private fun triggerNetworkCapabilitiesChange( + addTransportType: Int, removeTransportType: Int? = null + ) { + val networkCallback = shadowOf(connectivityManager).networkCallbacks.first() + val network = ShadowNetwork.newInstance(789) + val capabilities = ShadowNetworkCapabilities.newInstance() + if (removeTransportType != null) { + shadowOf(capabilities).removeTransportType(removeTransportType) + } + shadowOf(capabilities).addTransportType(addTransportType) + networkCallback.onCapabilitiesChanged(network, capabilities) + } + + @Suppress("SameParameterValue") + private fun setNetworkTypeInTelephonyManager(networkType: Int = TelephonyManager.NETWORK_TYPE_NR) { + shadowOf(telephonyManager).setDataNetworkType(networkType) + @Suppress("DEPRECATION") // Required for APIs below Android N + shadowOf(telephonyManager).setNetworkType(networkType) + } +} diff --git a/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkInfoProviderImplTest.kt b/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkInfoProviderImplTest.kt new file mode 100644 index 000000000..6f3f1597a --- /dev/null +++ b/measure-android/measure/src/test/java/sh/measure/android/network_change/NetworkInfoProviderImplTest.kt @@ -0,0 +1,141 @@ +package sh.measure.android.network_change + +import android.Manifest +import android.app.Application +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import android.telephony.TelephonyManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowNetwork +import org.robolectric.shadows.ShadowNetworkCapabilities +import org.robolectric.shadows.ShadowNetworkInfo +import sh.measure.android.fakes.NoopLogger +import sh.measure.android.utils.SystemServiceProviderImpl + +@RunWith(AndroidJUnit4::class) +internal class NetworkInfoProviderImplTest { + + private val logger = NoopLogger() + private val context = RuntimeEnvironment.getApplication() + private val systemServiceProvider = SystemServiceProviderImpl(context) + + @Test + fun `NetworkInfoProviderImpl returns null network generation for non cellular networks`() { + val networkGeneration = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkGeneration(NetworkType.WIFI) + + assertNull(networkGeneration) + } + + @Test + fun `NetworkInfoProviderImpl returns correct network generation with permission`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.READ_PHONE_STATE) + shadowOf(systemServiceProvider.telephonyManager).setNetworkType(TelephonyManager.NETWORK_TYPE_LTE) + + val networkGeneration = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkGeneration(NetworkType.CELLULAR) + + assertEquals(NetworkGeneration.FOURTH_GEN, networkGeneration) + } + + @Test + fun `NetworkInfoProviderImpl returns null network generation without permission`() { + shadowOf(context as Application).denyPermissions(Manifest.permission.READ_PHONE_STATE) + shadowOf(systemServiceProvider.telephonyManager).setNetworkType(TelephonyManager.NETWORK_TYPE_LTE) + + val networkGeneration = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkGeneration(NetworkType.CELLULAR) + + assertNull(networkGeneration) + } + + @Test + fun `NetworkInfoProviderImpl returns null network provider for non cellular networks`() { + val networkProvider = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkProvider(NetworkType.WIFI) + + assertNull(networkProvider) + } + + @Test + fun `NetworkInfoProviderImpl returns null network provider for blank network operator name`() { + shadowOf(systemServiceProvider.telephonyManager).setNetworkOperatorName("") + val networkProvider = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkProvider(NetworkType.CELLULAR) + + assertNull(networkProvider) + } + + @Test + fun `NetworkInfoProviderImpl returns correct network provider`() { + shadowOf(systemServiceProvider.telephonyManager).setNetworkOperatorName("test_provider") + val networkProvider = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkProvider(NetworkType.CELLULAR) + + assertEquals("test_provider", networkProvider) + } + + @Test + fun `NetworkInfoProviderImpl returns null network type without network state permission`() { + shadowOf(context as Application).denyPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + val networkType = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkType() + + assertNull(networkType) + } + + @Suppress("DEPRECATION") + @Test + @Config(sdk = [21]) + fun `NetworkInfoProviderImpl returns correct network type below API 23`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + shadowOf(systemServiceProvider.connectivityManager).setActiveNetworkInfo( + systemServiceProvider.connectivityManager!!.getNetworkInfo(ConnectivityManager.TYPE_WIFI) + ) + val networkType = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkType() + + assertEquals(NetworkType.WIFI, networkType) + } + + @Test + @Config(sdk = [23, 33]) + fun `NetworkInfoProviderImpl returns correct network type above API 23`() { + shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE) + val nc = ShadowNetworkCapabilities.newInstance() + val network = ShadowNetwork.newInstance(789) + shadowOf(nc).addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + shadowOf(systemServiceProvider.connectivityManager).setNetworkCapabilities( + network, nc + ) + shadowOf(systemServiceProvider.connectivityManager).setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + null, + ConnectivityManager.TYPE_WIFI, + TelephonyManager.NETWORK_TYPE_UNKNOWN, + true, + NetworkInfo.State.CONNECTED) + ) + + val networkType = NetworkInfoProviderImpl( + context = context, logger = logger, systemServiceProvider = systemServiceProvider + ).getNetworkType() + + assertEquals(NetworkType.WIFI, networkType) + } +} \ No newline at end of file diff --git a/measure-android/sample/src/main/AndroidManifest.xml b/measure-android/sample/src/main/AndroidManifest.xml index e74b59262..f6bdf5338 100644 --- a/measure-android/sample/src/main/AndroidManifest.xml +++ b/measure-android/sample/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + +