diff --git a/android/src/main/java/com/amplitude/android/Amplitude.kt b/android/src/main/java/com/amplitude/android/Amplitude.kt index f46b5b7d..6e90dfe6 100644 --- a/android/src/main/java/com/amplitude/android/Amplitude.kt +++ b/android/src/main/java/com/amplitude/android/Amplitude.kt @@ -1,5 +1,6 @@ package com.amplitude.android +import android.app.Application import android.content.Context import com.amplitude.android.migration.MigrationManager import com.amplitude.android.plugins.AnalyticsConnectorIdentityPlugin @@ -8,6 +9,7 @@ import com.amplitude.android.plugins.AndroidContextPlugin import com.amplitude.android.plugins.AndroidLifecyclePlugin import com.amplitude.android.plugins.AndroidNetworkConnectivityCheckerPlugin import com.amplitude.android.storage.AndroidStorageContextV3 +import com.amplitude.android.utilities.ActivityLifecycleObserver import com.amplitude.core.Amplitude import com.amplitude.core.events.BaseEvent import com.amplitude.core.platform.plugins.AmplitudeDestination @@ -25,8 +27,15 @@ open class Amplitude( return (timeline as Timeline).sessionId } + private val activityLifecycleCallbacks = ActivityLifecycleObserver() + init { registerShutdownHook() + if (AutocaptureOption.APP_LIFECYCLES in configuration.autocapture) { + with(configuration.context as Application) { + registerActivityLifecycleCallbacks(activityLifecycleCallbacks) + } + } } override fun createTimeline(): Timeline { @@ -63,7 +72,7 @@ open class Amplitude( } add(androidContextPlugin) add(GetAmpliExtrasPlugin()) - add(AndroidLifecyclePlugin()) + add(AndroidLifecyclePlugin(activityLifecycleCallbacks)) add(AnalyticsConnectorIdentityPlugin()) add(AnalyticsConnectorPlugin()) add(AmplitudeDestination()) diff --git a/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt b/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt index 2c76cca8..472e27e5 100644 --- a/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt +++ b/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt @@ -5,58 +5,101 @@ import android.app.Application import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Bundle +import androidx.annotation.VisibleForTesting import com.amplitude.android.AutocaptureOption import com.amplitude.android.Configuration import com.amplitude.android.ExperimentalAmplitudeFeature +import com.amplitude.android.utilities.ActivityCallbackType +import com.amplitude.android.utilities.ActivityLifecycleObserver import com.amplitude.android.utilities.DefaultEventUtils import com.amplitude.core.Amplitude import com.amplitude.core.platform.Plugin -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import com.amplitude.android.Amplitude as AndroidAmplitude -class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { +class AndroidLifecyclePlugin( + private val activityLifecycleObserver: ActivityLifecycleObserver +) : Application.ActivityLifecycleCallbacks, Plugin { override val type: Plugin.Type = Plugin.Type.Utility override lateinit var amplitude: Amplitude private lateinit var packageInfo: PackageInfo - private lateinit var androidAmplitude: com.amplitude.android.Amplitude + private lateinit var androidAmplitude: AndroidAmplitude private lateinit var androidConfiguration: Configuration - private val hasTrackedApplicationLifecycleEvents = AtomicBoolean(false) - private val numberOfActivities = AtomicInteger(1) - private val isFirstLaunch = AtomicBoolean(false) + private val created: MutableSet = mutableSetOf() + private val started: MutableSet = mutableSetOf() + + private var appInBackground = false + + @VisibleForTesting + internal var eventJob: Job? = null override fun setup(amplitude: Amplitude) { super.setup(amplitude) - androidAmplitude = amplitude as com.amplitude.android.Amplitude + androidAmplitude = amplitude as AndroidAmplitude androidConfiguration = amplitude.configuration as Configuration val application = androidConfiguration.context as Application - val packageManager: PackageManager = application.packageManager - packageInfo = try { - packageManager.getPackageInfo(application.packageName, 0) - } catch (e: PackageManager.NameNotFoundException) { - // This shouldn't happen, but in case it happens, fallback to empty package info. - amplitude.logger.error("Cannot find package with application.packageName: " + application.packageName) - PackageInfo() + + if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture) { + packageInfo = try { + application.packageManager.getPackageInfo(application.packageName, 0) + } catch (e: PackageManager.NameNotFoundException) { + // This shouldn't happen, but in case it happens, fallback to empty package info. + amplitude.logger.error("Cannot find package with application.packageName: " + application.packageName) + PackageInfo() + } + + DefaultEventUtils(androidAmplitude).trackAppUpdatedInstalledEvent(packageInfo) + + eventJob = amplitude.amplitudeScope.launch { + for (event in activityLifecycleObserver.eventChannel) { + event.activity.get()?.let { activity -> + when (event.type) { + ActivityCallbackType.Created -> onActivityCreated( + activity, + activity.intent?.extras + ) + ActivityCallbackType.Started -> onActivityStarted(activity) + ActivityCallbackType.Resumed -> onActivityResumed(activity) + ActivityCallbackType.Paused -> onActivityPaused(activity) + ActivityCallbackType.Stopped -> onActivityStopped(activity) + ActivityCallbackType.Destroyed -> onActivityDestroyed(activity) + } + } + } + } } - application.registerActivityLifecycleCallbacks(this) } override fun onActivityCreated(activity: Activity, bundle: Bundle?) { - if (!hasTrackedApplicationLifecycleEvents.getAndSet(true) && AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture) { - numberOfActivities.set(0) - isFirstLaunch.set(true) - DefaultEventUtils(androidAmplitude).trackAppUpdatedInstalledEvent(packageInfo) - } - if (AutocaptureOption.DEEP_LINKS in androidConfiguration.autocapture) { - DefaultEventUtils(androidAmplitude).trackDeepLinkOpenedEvent(activity) - } + created.add(activity.hashCode()) + if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) { DefaultEventUtils(androidAmplitude).startFragmentViewedEventTracking(activity) } } override fun onActivityStarted(activity: Activity) { + if (!created.contains(activity.hashCode())) { + // We check for On Create in case if sdk was initialised in Main Activity + onActivityCreated(activity, activity.intent.extras) + } + started.add(activity.hashCode()) + + if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && started.size == 1) { + DefaultEventUtils(androidAmplitude).trackAppOpenedEvent( + packageInfo = packageInfo, + isFromBackground = appInBackground + ) + appInBackground = false + } + + if (AutocaptureOption.DEEP_LINKS in androidConfiguration.autocapture) { + DefaultEventUtils(androidAmplitude).trackDeepLinkOpenedEvent(activity) + } + if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) { DefaultEventUtils(androidAmplitude).trackScreenViewedEvent(activity) } @@ -65,11 +108,6 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { override fun onActivityResumed(activity: Activity) { androidAmplitude.onEnterForeground(getCurrentTimeMillis()) - // numberOfActivities makes sure it only fires after activity creation or activity stopped - if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && numberOfActivities.incrementAndGet() == 1) { - val isFromBackground = !isFirstLaunch.getAndSet(false) - DefaultEventUtils(androidAmplitude).trackAppOpenedEvent(packageInfo, isFromBackground) - } @OptIn(ExperimentalAmplitudeFeature::class) if (AutocaptureOption.ELEMENT_INTERACTIONS in androidConfiguration.autocapture) { DefaultEventUtils(androidAmplitude).startUserInteractionEventTracking(activity) @@ -78,6 +116,7 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { override fun onActivityPaused(activity: Activity) { androidAmplitude.onExitForeground(getCurrentTimeMillis()) + @OptIn(ExperimentalAmplitudeFeature::class) if (AutocaptureOption.ELEMENT_INTERACTIONS in androidConfiguration.autocapture) { DefaultEventUtils(androidAmplitude).stopUserInteractionEventTracking(activity) @@ -85,9 +124,11 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { } override fun onActivityStopped(activity: Activity) { - // numberOfActivities makes sure it only fires after setup or activity resumed - if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && numberOfActivities.decrementAndGet() == 0) { + started.remove(activity.hashCode()) + + if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && started.isEmpty()) { DefaultEventUtils(androidAmplitude).trackAppBackgroundedEvent() + appInBackground = true } } @@ -95,6 +136,8 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { } override fun onActivityDestroyed(activity: Activity) { + created.remove(activity.hashCode()) + if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) { DefaultEventUtils(androidAmplitude).stopFragmentViewedEventTracking(activity) } @@ -102,7 +145,7 @@ class AndroidLifecyclePlugin : Application.ActivityLifecycleCallbacks, Plugin { override fun teardown() { super.teardown() - (androidConfiguration.context as Application).unregisterActivityLifecycleCallbacks(this) + eventJob?.cancel() } companion object { diff --git a/android/src/main/java/com/amplitude/android/utilities/ActivityLifecycleObserver.kt b/android/src/main/java/com/amplitude/android/utilities/ActivityLifecycleObserver.kt new file mode 100644 index 00000000..5a80d75c --- /dev/null +++ b/android/src/main/java/com/amplitude/android/utilities/ActivityLifecycleObserver.kt @@ -0,0 +1,78 @@ +package com.amplitude.android.utilities + +import android.app.Activity +import android.app.Application.ActivityLifecycleCallbacks +import android.os.Bundle +import kotlinx.coroutines.channels.Channel +import java.lang.ref.WeakReference + +class ActivityLifecycleObserver : ActivityLifecycleCallbacks { + + internal val eventChannel = Channel(Channel.UNLIMITED) + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Created + ) + ) + } + + override fun onActivityStarted(activity: Activity) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Started + ) + ) + } + + override fun onActivityResumed(activity: Activity) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Resumed + ) + ) + } + + override fun onActivityPaused(activity: Activity) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Paused + ) + ) + } + + override fun onActivityStopped(activity: Activity) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Stopped + ) + ) + } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { + } + + override fun onActivityDestroyed(activity: Activity) { + eventChannel.trySend( + ActivityCallbackEvent( + WeakReference(activity), + ActivityCallbackType.Destroyed + ) + ) + } +} + +enum class ActivityCallbackType { + Created, Started, Resumed, Paused, Stopped, Destroyed +} + +data class ActivityCallbackEvent( + val activity: WeakReference, + val type: ActivityCallbackType +) diff --git a/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt index 4652dc2b..b175ade9 100644 --- a/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt +++ b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt @@ -162,18 +162,18 @@ class DefaultEventUtils(private val amplitude: Amplitude) { } ?: amplitude.logger.error("Failed to stop user interaction event tracking: Activity window is null") } + private val isFragmentActivityAvailable by lazy { + LoadClass.isClassAvailable(FRAGMENT_ACTIVITY_CLASS_NAME, amplitude.logger) + } + fun startFragmentViewedEventTracking(activity: Activity) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && - LoadClass.isClassAvailable(FRAGMENT_ACTIVITY_CLASS_NAME, amplitude.logger) - ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isFragmentActivityAvailable) { activity.registerFragmentLifecycleCallbacks(amplitude::track, amplitude.logger) } } fun stopFragmentViewedEventTracking(activity: Activity) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && - LoadClass.isClassAvailable(FRAGMENT_ACTIVITY_CLASS_NAME, amplitude.logger) - ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isFragmentActivityAvailable) { activity.unregisterFragmentLifecycleCallbacks(amplitude.logger) } } @@ -181,7 +181,6 @@ class DefaultEventUtils(private val amplitude: Amplitude) { companion object { private const val FRAGMENT_ACTIVITY_CLASS_NAME = "androidx.fragment.app.FragmentActivity" internal val Activity.screenName: String? - @Throws(PackageManager.NameNotFoundException::class, Exception::class) get() { val packageManager = packageManager val info = @@ -203,21 +202,18 @@ class DefaultEventUtils(private val amplitude: Amplitude) { return activity.referrer } else { var referrerUri: Uri? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - val intent = activity.intent - referrerUri = intent.getParcelableExtra(Intent.EXTRA_REFERRER) - - if (referrerUri == null) { - referrerUri = - intent.getStringExtra("android.intent.extra.REFERRER_NAME")?.let { - try { - Uri.parse(it) - } catch (e: ParseException) { - amplitude.logger.error("Failed to parse the referrer uri: $it") - null - } + val intent = activity.intent + referrerUri = intent.getParcelableExtra(Intent.EXTRA_REFERRER) + if (referrerUri == null) { + referrerUri = + intent.getStringExtra("android.intent.extra.REFERRER_NAME")?.let { + try { + Uri.parse(it) + } catch (e: ParseException) { + amplitude.logger.error("Failed to parse the referrer uri: $it") + null } - } + } } return referrerUri } diff --git a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt index d4bc04ba..6d1a5a6a 100644 --- a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt +++ b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt @@ -2,574 +2,462 @@ package com.amplitude.android.plugins import android.app.Activity import android.app.Application -import android.content.Context import android.content.Intent -import android.content.pm.ActivityInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager -import android.net.ConnectivityManager import android.net.Uri -import android.os.Bundle import com.amplitude.android.Amplitude import com.amplitude.android.AutocaptureOption import com.amplitude.android.Configuration -import com.amplitude.android.StubPlugin +import com.amplitude.android.internal.fragments.FragmentActivityHandler +import com.amplitude.android.internal.fragments.FragmentActivityHandler.registerFragmentLifecycleCallbacks +import com.amplitude.android.internal.fragments.FragmentActivityHandler.unregisterFragmentLifecycleCallbacks +import com.amplitude.android.utilities.ActivityLifecycleObserver import com.amplitude.android.utilities.DefaultEventUtils -import com.amplitude.common.android.AndroidContextProvider import com.amplitude.core.Storage -import com.amplitude.core.events.BaseEvent -import com.amplitude.core.utilities.ConsoleLoggerProvider -import com.amplitude.core.utilities.InMemoryStorageProvider -import com.amplitude.id.IMIdentityStorageProvider +import com.amplitude.core.utilities.InMemoryStorage +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkConstructor +import io.mockk.mockkObject import io.mockk.spyk +import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Before import org.junit.Test -import org.junit.jupiter.api.Assertions import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) class AndroidLifecyclePluginTest { - private val androidLifecyclePlugin = AndroidLifecyclePlugin() - private lateinit var amplitude: Amplitude - private lateinit var configuration: Configuration private val mockedContext = mockk(relaxed = true) - private var mockedPackageManager: PackageManager - private lateinit var connectivityManager: ConnectivityManager + private val mockedAmplitude = mockk(relaxed = true) + private val mockedConfig = mockk(relaxed = true) - init { - val packageInfo = PackageInfo() - @Suppress("DEPRECATION") - packageInfo.versionCode = 66 - packageInfo.versionName = "6.0.0" + private lateinit var spiedStorage: InMemoryStorage - mockedPackageManager = mockk { - every { getPackageInfo("com.plugin.test", 0) } returns packageInfo - } - every { mockedContext.packageName } returns "com.plugin.test" - every { mockedContext.packageManager } returns mockedPackageManager + private val mockedPackageManager = mockk() + private val packageInfo = PackageInfo().apply { + versionCode = 66 + longVersionCode = 66 + versionName = "6.0.0" } - private fun setDispatcher(testScheduler: TestCoroutineScheduler) { - val dispatcher = StandardTestDispatcher(testScheduler) - // inject the amplitudeDispatcher field with reflection, as the field is val (read-only) - val amplitudeDispatcherField = com.amplitude.core.Amplitude::class.java.getDeclaredField("amplitudeDispatcher") - amplitudeDispatcherField.isAccessible = true - amplitudeDispatcherField.set(amplitude, dispatcher) - } + private val testDispatcher = StandardTestDispatcher() + + private lateinit var observer: ActivityLifecycleObserver + private lateinit var plugin: AndroidLifecyclePlugin @Before fun setup() { - mockkConstructor(AndroidContextProvider::class) - every { anyConstructed().osName } returns "android" - every { anyConstructed().osVersion } returns "10" - every { anyConstructed().brand } returns "google" - every { anyConstructed().manufacturer } returns "Android" - every { anyConstructed().model } returns "Android SDK built for x86" - every { anyConstructed().language } returns "English" - every { anyConstructed().advertisingId } returns "" - every { anyConstructed().versionName } returns "1.0" - every { anyConstructed().carrier } returns "Android" - every { anyConstructed().country } returns "US" - every { anyConstructed().mostRecentLocation } returns null - every { anyConstructed().appSetId } returns "" - - connectivityManager = mockk(relaxed = true) - every { mockedContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager - every { mockedContext!!.getDir(any(), any()) } returns File("/tmp/amplitude-kotlin-test") - - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf() - ) - amplitude = Amplitude(configuration) + Dispatchers.setMain(testDispatcher) + every { mockedAmplitude.configuration } returns mockedConfig + every { mockedConfig.context } returns mockedContext + every { mockedContext.packageManager } returns mockedPackageManager + every { mockedPackageManager.getPackageInfo(any(), any()) } returns packageInfo + + spiedStorage = spyk(InMemoryStorage()) + every { mockedAmplitude.storage } returns spiedStorage + every { mockedAmplitude.storageIODispatcher } returns testDispatcher + + observer = ActivityLifecycleObserver() + plugin = AndroidLifecyclePlugin(observer) + + mockkObject(FragmentActivityHandler) } @Test fun `test application installed event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.APP_LIFECYCLES) - ) - amplitude = Amplitude(configuration) - - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + every { mockedConfig.autocapture } returns setOf(AutocaptureOption.APP_LIFECYCLES) + every { mockedAmplitude.amplitudeScope } returns this - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() - - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + plugin.setup(mockedAmplitude) advanceUntilIdle() - Thread.sleep(100) - - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.APPLICATION_INSTALLED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.BUILD), "66") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.VERSION), "6.0.0") + verify { + mockedAmplitude.track( + DefaultEventUtils.EventTypes.APPLICATION_INSTALLED, + any(), + any() + ) } + + coVerify(exactly = 1) { spiedStorage.write(eq(Storage.Constants.APP_VERSION), any()) } + coVerify(exactly = 1) { spiedStorage.write(eq(Storage.Constants.APP_BUILD), any()) } + + close() } @Test fun `test application installed event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + every { mockedAmplitude.amplitudeScope } returns this - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + plugin.setup(mockedAmplitude) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + verify(exactly = 0) { + mockedAmplitude.track( + DefaultEventUtils.EventTypes.APPLICATION_INSTALLED, + any(), + any() + ) + } + + coVerify(exactly = 0) { spiedStorage.write(eq(Storage.Constants.APP_VERSION), any()) } + coVerify(exactly = 0) { spiedStorage.write(eq(Storage.Constants.APP_BUILD), any()) } + + close() } @Test fun `test application updated event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.APP_LIFECYCLES) - ) - amplitude = Amplitude(configuration) - - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + every { mockedConfig.autocapture } returns setOf(AutocaptureOption.APP_LIFECYCLES) + every { mockedAmplitude.amplitudeScope } returns this // Stored previous version/build - amplitude.storage.write(Storage.Constants.APP_BUILD, "55") - amplitude.storage.write(Storage.Constants.APP_VERSION, "5.0.0") + spiedStorage.write(Storage.Constants.APP_BUILD, "55") + spiedStorage.write(Storage.Constants.APP_VERSION, "5.0.0") - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + plugin.setup(mockedAmplitude) advanceUntilIdle() - Thread.sleep(100) - - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) - - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.APPLICATION_UPDATED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.BUILD), "66") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.VERSION), "6.0.0") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.PREVIOUS_BUILD), "55") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.PREVIOUS_VERSION), "5.0.0") + + verify { + mockedAmplitude.track( + DefaultEventUtils.EventTypes.APPLICATION_UPDATED, + any(), + any() + ) } + + coVerify(exactly = 2) { spiedStorage.write(eq(Storage.Constants.APP_VERSION), any()) } + coVerify(exactly = 2) { spiedStorage.write(eq(Storage.Constants.APP_BUILD), any()) } + + close() } @Test fun `test application updated event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + every { mockedAmplitude.amplitudeScope } returns this // Stored previous version/build - amplitude.storage.write(Storage.Constants.APP_BUILD, "55") - amplitude.storage.write(Storage.Constants.APP_VERSION, "5.0.0") + spiedStorage.write(Storage.Constants.APP_BUILD, "55") + spiedStorage.write(Storage.Constants.APP_VERSION, "5.0.0") - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + plugin.setup(mockedAmplitude) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + verify(exactly = 0) { + mockedAmplitude.track( + DefaultEventUtils.EventTypes.APPLICATION_UPDATED, + any(), + ) + } + + coVerify(exactly = 1) { spiedStorage.write(eq(Storage.Constants.APP_VERSION), any()) } + coVerify(exactly = 1) { spiedStorage.write(eq(Storage.Constants.APP_BUILD), any()) } + + close() } @Test - fun `test application opened event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.APP_LIFECYCLES) + fun `test fragment activity is tracked if enabled`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES, + AutocaptureOption.SCREEN_VIEWS ) - amplitude = Amplitude(configuration) + every { mockedAmplitude.amplitudeScope } returns this - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + val activity = mockk() + every { activity.intent } returns Intent() - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + plugin.setup(mockedAmplitude) - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) - androidLifecyclePlugin.onActivityStarted(mockedActivity) - androidLifecyclePlugin.onActivityResumed(mockedActivity) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + every { activity.unregisterFragmentLifecycleCallbacks(any()) } returns Unit - advanceUntilIdle() - Thread.sleep(100) + observer.onActivityCreated(activity, mockk()) + observer.onActivityDestroyed(activity) - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(2, tracks.count()) + advanceUntilIdle() - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.APPLICATION_INSTALLED, eventType) - } - with(tracks[1]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.APPLICATION_OPENED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.BUILD), "66") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.VERSION), "6.0.0") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.FROM_BACKGROUND), false) + verify(exactly = 1) { + activity.registerFragmentLifecycleCallbacks(any(), any()) } - } - - @Test - fun `test application opened event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() - val mockedActivity = mockk() - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) - androidLifecyclePlugin.onActivityStarted(mockedActivity) - androidLifecyclePlugin.onActivityResumed(mockedActivity) - - advanceUntilIdle() - Thread.sleep(100) + verify(exactly = 1) { + activity.unregisterFragmentLifecycleCallbacks(any()) + } - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + close() } @Test - fun `test application backgrounded event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.APP_LIFECYCLES) + fun `test fragment activity is not tracked if disabled`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES ) - amplitude = Amplitude(configuration) + every { mockedAmplitude.amplitudeScope } returns this - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + val activity = mockk() + every { activity.intent } returns Intent() - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + plugin.setup(mockedAmplitude) - val mockedActivity = mockk() - androidLifecyclePlugin.onActivityPaused(mockedActivity) - androidLifecyclePlugin.onActivityStopped(mockedActivity) - androidLifecyclePlugin.onActivityDestroyed(mockedActivity) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + every { activity.unregisterFragmentLifecycleCallbacks(any()) } returns Unit + + observer.onActivityCreated(activity, mockk()) + observer.onActivityDestroyed(activity) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) + verify(exactly = 0) { + activity.registerFragmentLifecycleCallbacks(any(), any()) + } - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.APPLICATION_BACKGROUNDED, eventType) + verify(exactly = 0) { + activity.unregisterFragmentLifecycleCallbacks(any()) } + + close() } @Test - fun `test application backgrounded event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + fun `test application opened event is tracked not from background`() = runTest { + every { mockedConfig.autocapture } returns setOf(AutocaptureOption.APP_LIFECYCLES) + every { mockedAmplitude.amplitudeScope } returns this - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedActivity = mockk() - androidLifecyclePlugin.onActivityPaused(mockedActivity) - androidLifecyclePlugin.onActivityStopped(mockedActivity) - androidLifecyclePlugin.onActivityDestroyed(mockedActivity) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + verify(exactly = 1) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.APPLICATION_OPENED), + match { param -> param.values.first() == false }, + ) + } + + close() } @Test - fun `test screen viewed event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.SCREEN_VIEWS) - ) - amplitude = Amplitude(configuration) - - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + fun `test application opened event is tracked from background`() = runTest { + every { mockedConfig.autocapture } returns setOf(AutocaptureOption.APP_LIFECYCLES) + every { mockedAmplitude.amplitudeScope } returns this - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedActivity = mockk() - every { mockedActivity.packageManager } returns mockedPackageManager - every { mockedActivity.componentName } returns mockk() - val mockedActivityInfo = mockk() - every { mockedPackageManager.getActivityInfo(any(), PackageManager.GET_META_DATA) } returns mockedActivityInfo - every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) - androidLifecyclePlugin.onActivityStarted(mockedActivity) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) + verify(exactly = 1) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.APPLICATION_OPENED), + match { param -> param.values.first() == false }, + any() + ) + } - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) + observer.onActivityStopped(activity) + advanceUntilIdle() + verify(exactly = 1) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.APPLICATION_BACKGROUNDED), + any(), + any() + ) + } - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.SCREEN_VIEWED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.SCREEN_NAME), "test-label") + observer.onActivityStarted(activity) + advanceUntilIdle() + verify(exactly = 1) { + mockedAmplitude.track( + DefaultEventUtils.EventTypes.APPLICATION_OPENED, + match { param -> param.values.first() == false }, + any() + ) } + close() } @Test - fun `test screen viewed event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) - - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() - - val mockedActivity = mockk() - every { mockedActivity.packageManager } returns mockedPackageManager - every { mockedActivity.componentName } returns mockk() - val mockedActivityInfo = mockk() - every { mockedPackageManager.getActivityInfo(any(), PackageManager.GET_META_DATA) } returns mockedActivityInfo - every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) - androidLifecyclePlugin.onActivityStarted(mockedActivity) + fun `test application opened event is not tracked when disabled`() = runTest { + every { mockedAmplitude.amplitudeScope } returns this + + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) + + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + + observer.onActivityStarted(activity) + advanceUntilIdle() + verify(exactly = 0) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.APPLICATION_OPENED), + match { param -> param.values.first() == false }, + ) + } + observer.onActivityStopped(activity) advanceUntilIdle() - Thread.sleep(100) + verify(exactly = 0) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.APPLICATION_BACKGROUNDED), + any(), + any() + ) + } - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + close() } @Test - fun `test deep link opened event is tracked`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.DEEP_LINKS) + fun `test screen viewed event is tracked`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES, + AutocaptureOption.SCREEN_VIEWS ) - amplitude = Amplitude(configuration) - - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + every { mockedAmplitude.amplitudeScope } returns this - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedIntent = mockk() - every { mockedIntent.data } returns Uri.parse("app://url.com/open") - val mockedActivity = mockk() - every { mockedActivity.intent } returns mockedIntent - every { mockedActivity.referrer } returns Uri.parse("android-app://com.android.chrome") - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) - - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.DEEP_LINK_OPENED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_URL), "app://url.com/open") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_REFERRER), "android-app://com.android.chrome") + verify(exactly = 1) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.SCREEN_VIEWED), + any(), + ) } + + close() } - @Config(sdk = [21]) @Test - fun `test deep link opened event is tracked when using sdk is between 17 and 21`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.DEEP_LINKS) + fun `test screen viewed event is not tracked when disabled`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES, ) - amplitude = Amplitude(configuration) + every { mockedAmplitude.amplitudeScope } returns this - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() - - val mockedIntent = mockk() - every { mockedIntent.data } returns Uri.parse("app://url.com/open") - every { mockedIntent.getParcelableExtra(any()) } returns Uri.parse("android-app://com.android.chrome") - val mockedActivity = mockk() - every { mockedActivity.intent } returns mockedIntent - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) - - val tracks = mutableListOf() - verify { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(1, tracks.count()) - with(tracks[0]) { - Assertions.assertEquals(DefaultEventUtils.EventTypes.DEEP_LINK_OPENED, eventType) - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_URL), "app://url.com/open") - Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.LINK_REFERRER), "android-app://com.android.chrome") + verify(exactly = 0) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.SCREEN_VIEWED), + any(), + ) } + + close() } @Test - fun `test deep link opened event is not tracked when disabled`() = runTest { - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + fun `test deep link opened event is tracked`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES, + AutocaptureOption.DEEP_LINKS + ) + every { mockedAmplitude.amplitudeScope } returns this - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedIntent = mockk() - every { mockedIntent.data } returns Uri.parse("app://url.com/open") - val mockedActivity = mockk() - every { mockedActivity.intent } returns mockedIntent - every { mockedActivity.referrer } returns Uri.parse("android-app://com.android.chrome") - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) + + every { activity.intent } returns Intent().apply { + data = Uri.parse("android-app://com.android.unit-test") + } + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + verify(exactly = 1) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.DEEP_LINK_OPENED), + any(), + ) + } + + close() } @Test - fun `test deep link opened event is not tracked when URL is missing`() = runTest { - configuration = Configuration( - apiKey = "api-key", - context = mockedContext, - storageProvider = InMemoryStorageProvider(), - loggerProvider = ConsoleLoggerProvider(), - identifyInterceptStorageProvider = InMemoryStorageProvider(), - identityStorageProvider = IMIdentityStorageProvider(), - autocapture = setOf(AutocaptureOption.DEEP_LINKS) + fun `test deep link opened event is not tracked when disabled`() = runTest { + every { mockedConfig.autocapture } returns setOf( + AutocaptureOption.APP_LIFECYCLES, ) - amplitude = Amplitude(configuration) + every { mockedAmplitude.amplitudeScope } returns this - setDispatcher(testScheduler) - amplitude.add(androidLifecyclePlugin) + val activity = mockk(relaxed = true) + plugin.setup(mockedAmplitude) - val mockedPlugin = spyk(StubPlugin()) - amplitude.add(mockedPlugin) - amplitude.isBuilt.await() + every { activity.registerFragmentLifecycleCallbacks(any(), any()) } returns Unit + observer.onActivityCreated(activity, mockk()) - val mockedIntent = mockk() - every { mockedIntent.data } returns null - val mockedActivity = mockk() - every { mockedActivity.intent } returns mockedIntent - every { mockedActivity.referrer } returns Uri.parse("android-app://com.android.unit-test") - val mockedBundle = mockk() - androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + every { activity.intent } returns Intent().apply { + data = Uri.parse("android-app://com.android.unit-test") + } + observer.onActivityStarted(activity) advanceUntilIdle() - Thread.sleep(100) - val tracks = mutableListOf() - verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } - Assertions.assertEquals(0, tracks.count()) + verify(exactly = 0) { + mockedAmplitude.track( + eq(DefaultEventUtils.EventTypes.DEEP_LINK_OPENED), + any(), + ) + } + + close() + } + + // TODO Replace with Turbine + private suspend fun close() { + observer.eventChannel.close() + plugin.eventJob?.join() + } + + @After + fun teardown() { + Dispatchers.resetMain() + unmockkObject(FragmentActivityHandler) } }