From 12a743bb8b03d87c474ab20719601d704c2e8bff Mon Sep 17 00:00:00 2001 From: Marvin Liu Date: Tue, 20 Jun 2023 14:42:38 -0700 Subject: [PATCH] feat: add default events (#126) * feat: add deep link opened default event * feat: add screen viewed event * feat: update the sample app to test the screen navigation * feat: add app lifecycle default events * test: add tests for lifecycle plugin * refactor: add empty last lines * refactor: fix lint issues * build: fix ci test failure * refactor: fix lint * refactor: use const for default event properties * refactor: use shared casted amplitude and configuration vars * feat: use defaultTracking in the options * refactor: rename default tracking options * refactor: update comment --- .../com/amplitude/android/Configuration.kt | 2 + .../android/DefaultTrackingOptions.kt | 24 + .../java/com/amplitude/android/Timeline.kt | 9 +- .../android/plugins/AndroidLifecyclePlugin.kt | 51 +- .../android/utilities/DefaultEventUtils.kt | 157 ++++++ .../amplitude/android/AmplitudeSessionTest.kt | 51 ++ .../amplitude/android/ConfigurationTest.kt | 32 ++ .../plugins/AndroidLifecyclePluginTest.kt | 499 ++++++++++++++++++ .../main/java/com/amplitude/core/Storage.kt | 4 +- .../src/main/AndroidManifest.xml | 20 +- .../android/sample/AdvancedActivity.kt | 48 ++ .../amplitude/android/sample/MainActivity.kt | 36 +- .../android/sample/MainApplication.kt | 7 +- .../src/main/res/layout/activity_advanced.xml | 32 ++ .../src/main/res/layout/activity_main.xml | 16 +- 15 files changed, 947 insertions(+), 41 deletions(-) create mode 100644 android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt create mode 100644 android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt create mode 100644 android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt create mode 100644 samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/AdvancedActivity.kt create mode 100644 samples/kotlin-android-app/src/main/res/layout/activity_advanced.xml diff --git a/android/src/main/java/com/amplitude/android/Configuration.kt b/android/src/main/java/com/amplitude/android/Configuration.kt index a8ee2032..8dbe0fa1 100644 --- a/android/src/main/java/com/amplitude/android/Configuration.kt +++ b/android/src/main/java/com/amplitude/android/Configuration.kt @@ -39,7 +39,9 @@ open class Configuration @JvmOverloads constructor( var locationListening: Boolean = true, var flushEventsOnClose: Boolean = true, var minTimeBetweenSessionsMillis: Long = MIN_TIME_BETWEEN_SESSIONS_MILLIS, + @Deprecated("Please use 'defaultTracking.sessions' instead.", ReplaceWith("defaultTracking.sessions")) var trackingSessionEvents: Boolean = true, + var defaultTracking: DefaultTrackingOptions = DefaultTrackingOptions(), override var identifyBatchIntervalMillis: Long = IDENTIFY_BATCH_INTERVAL_MILLIS, override var identifyInterceptStorageProvider: StorageProvider = AndroidStorageProvider(), override var identityStorageProvider: IdentityStorageProvider = FileIdentityStorageProvider(), diff --git a/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt b/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt new file mode 100644 index 00000000..8b935777 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/DefaultTrackingOptions.kt @@ -0,0 +1,24 @@ +package com.amplitude.android + +class DefaultTrackingOptions( + var sessions: Boolean = true, + var appLifecycles: Boolean = false, + var deepLinks: Boolean = false, + var screenViews: Boolean = false +) { + // Prebuilt options for easier usage + companion object { + val ALL = DefaultTrackingOptions( + sessions = true, + appLifecycles = true, + deepLinks = true, + screenViews = true + ) + val NONE = DefaultTrackingOptions( + sessions = false, + appLifecycles = false, + deepLinks = false, + screenViews = false + ) + } +} diff --git a/android/src/main/java/com/amplitude/android/Timeline.kt b/android/src/main/java/com/amplitude/android/Timeline.kt index 4a0a98cc..7f02e9da 100644 --- a/android/src/main/java/com/amplitude/android/Timeline.kt +++ b/android/src/main/java/com/amplitude/android/Timeline.kt @@ -75,7 +75,7 @@ class Timeline : Timeline() { val savedLastEventId = lastEventId - sessionEvents ?. let { + sessionEvents?.let { it.forEach { e -> e.eventId ?: let { val newEventId = lastEventId + 1 @@ -97,7 +97,7 @@ class Timeline : Timeline() { amplitude.storage.write(Storage.Constants.LAST_EVENT_ID, lastEventId.toString()) } - sessionEvents ?. let { + sessionEvents?.let { it.forEach { e -> super.process(e) } @@ -123,7 +123,10 @@ class Timeline : Timeline() { private suspend fun startNewSession(timestamp: Long): Iterable { val sessionEvents = mutableListOf() - val trackingSessionEvents = (amplitude.configuration as Configuration).trackingSessionEvents + val configuration = amplitude.configuration as Configuration + // If any trackingSessionEvents is false (default value is true), means it is manually set + @Suppress("DEPRECATION") + val trackingSessionEvents = configuration.trackingSessionEvents && configuration.defaultTracking.sessions // end previous session if (trackingSessionEvents && inSession()) { 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 aa3861b3..18335d5a 100644 --- a/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt +++ b/android/src/main/java/com/amplitude/android/plugins/AndroidLifecyclePlugin.kt @@ -2,35 +2,80 @@ package com.amplitude.android.plugins import android.app.Activity import android.app.Application +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.os.Bundle import com.amplitude.android.Configuration +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 class AndroidLifecyclePlugin : 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 androidConfiguration: Configuration + + private val hasTrackedApplicationLifecycleEvents = AtomicBoolean(false) + private val numberOfActivities = AtomicInteger(1) + private val isFirstLaunch = AtomicBoolean(false) override fun setup(amplitude: Amplitude) { super.setup(amplitude) - ((amplitude.configuration as Configuration).context as Application).registerActivityLifecycleCallbacks(this) + androidAmplitude = amplitude as com.amplitude.android.Amplitude + 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() + } + application.registerActivityLifecycleCallbacks(this) } override fun onActivityCreated(activity: Activity, bundle: Bundle?) { + if (!hasTrackedApplicationLifecycleEvents.getAndSet(true) && androidConfiguration.defaultTracking.appLifecycles) { + numberOfActivities.set(0) + isFirstLaunch.set(true) + DefaultEventUtils(androidAmplitude).trackAppUpdatedInstalledEvent(packageInfo) + } + if (androidConfiguration.defaultTracking.deepLinks) { + DefaultEventUtils(androidAmplitude).trackDeepLinkOpenedEvent(activity) + } } override fun onActivityStarted(activity: Activity) { + if (androidConfiguration.defaultTracking.screenViews) { + DefaultEventUtils(androidAmplitude).trackScreenViewedEvent(activity) + } } override fun onActivityResumed(activity: Activity) { - (amplitude as com.amplitude.android.Amplitude).onEnterForeground(getCurrentTimeMillis()) + androidAmplitude.onEnterForeground(getCurrentTimeMillis()) + + // numberOfActivities makes sure it only fires after activity creation or activity stopped + if (androidConfiguration.defaultTracking.appLifecycles && numberOfActivities.incrementAndGet() == 1) { + val isFromBackground = !isFirstLaunch.getAndSet(false) + DefaultEventUtils(androidAmplitude).trackAppOpenedEvent(packageInfo, isFromBackground) + } } override fun onActivityPaused(activity: Activity) { - (amplitude as com.amplitude.android.Amplitude).onExitForeground(getCurrentTimeMillis()) + androidAmplitude.onExitForeground(getCurrentTimeMillis()) } override fun onActivityStopped(activity: Activity) { + // numberOfActivities makes sure it only fires after setup or activity resumed + if (androidConfiguration.defaultTracking.appLifecycles && numberOfActivities.decrementAndGet() == 0) { + DefaultEventUtils(androidAmplitude).trackAppBackgroundedEvent() + } } override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) { diff --git a/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt new file mode 100644 index 00000000..0b705dc7 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/utilities/DefaultEventUtils.kt @@ -0,0 +1,157 @@ +package com.amplitude.android.utilities + +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.ParseException +import android.net.Uri +import android.os.Build +import com.amplitude.android.Amplitude +import com.amplitude.core.Storage +import kotlinx.coroutines.launch + +class DefaultEventUtils(private val amplitude: Amplitude) { + object EventTypes { + const val APPLICATION_INSTALLED = "[Amplitude] Application Installed" + const val APPLICATION_UPDATED = "[Amplitude] Application Updated" + const val APPLICATION_OPENED = "[Amplitude] Application Opened" + const val APPLICATION_BACKGROUNDED = "[Amplitude] Application Backgrounded" + const val DEEP_LINK_OPENED = "[Amplitude] Deep Link Opened" + const val SCREEN_VIEWED = "[Amplitude] Screen Viewed" + } + + object EventProperties { + const val VERSION = "[Amplitude] Version" + const val BUILD = "[Amplitude] Build" + const val PREVIOUS_VERSION = "[Amplitude] Previous Version" + const val PREVIOUS_BUILD = "[Amplitude] Previous Build" + const val FROM_BACKGROUND = "[Amplitude] From Background" + const val LINK_URL = "[Amplitude] Link URL" + const val LINK_REFERRER = "[Amplitude] Link Referrer" + const val SCREEN_NAME = "[Amplitude] Screen Name" + } + + fun trackAppUpdatedInstalledEvent(packageInfo: PackageInfo) { + // Get current version/build and previously stored version/build information + val currentVersion = packageInfo.versionName + val currentBuild = packageInfo.getVersionCode().toString() + val storage = amplitude.storage + val previousVersion = storage.read(Storage.Constants.APP_VERSION) + val previousBuild = storage.read(Storage.Constants.APP_BUILD) + + if (previousBuild == null) { + // No stored build, treat it as fresh installed + amplitude.track( + EventTypes.APPLICATION_INSTALLED, + mapOf( + EventProperties.VERSION to currentVersion, + EventProperties.BUILD to currentBuild, + ), + ) + } else if (currentBuild != previousBuild) { + // Has stored build, but different from current build + amplitude.track( + EventTypes.APPLICATION_UPDATED, + mapOf( + EventProperties.PREVIOUS_VERSION to previousVersion, + EventProperties.PREVIOUS_BUILD to previousBuild, + EventProperties.VERSION to currentVersion, + EventProperties.BUILD to currentBuild, + ), + ) + } + + // Write the current version/build into persistent storage + amplitude.amplitudeScope.launch(amplitude.storageIODispatcher) { + storage.write(Storage.Constants.APP_VERSION, currentVersion) + storage.write(Storage.Constants.APP_BUILD, currentBuild) + } + } + + fun trackAppOpenedEvent(packageInfo: PackageInfo, isFromBackground: Boolean) { + val currentVersion = packageInfo.versionName + val currentBuild = packageInfo.getVersionCode().toString() + + amplitude.track( + EventTypes.APPLICATION_OPENED, + mapOf( + EventProperties.FROM_BACKGROUND to isFromBackground, + EventProperties.VERSION to currentVersion, + EventProperties.BUILD to currentBuild, + ), + ) + } + + fun trackAppBackgroundedEvent() { + amplitude.track(EventTypes.APPLICATION_BACKGROUNDED) + } + + fun trackDeepLinkOpenedEvent(activity: Activity) { + val intent = activity.intent + intent?.let { + val referrer = getReferrer(activity)?.toString() + val url = it.data?.toString() + amplitude.track( + EventTypes.DEEP_LINK_OPENED, + mapOf( + EventProperties.LINK_URL to url, + EventProperties.LINK_REFERRER to referrer, + ), + ) + } + } + + fun trackScreenViewedEvent(activity: Activity) { + try { + val packageManager = activity.packageManager + val info = packageManager?.getActivityInfo( + activity.componentName, + PackageManager.GET_META_DATA, + ) + /* Get the label metadata in following order + 1. activity label + 2. if 1 is missing, fallback to parent application label + 3. if 2 is missing, use the activity name + */ + val activityLabel = info?.loadLabel(packageManager)?.toString() ?: info?.name + amplitude.track(EventTypes.SCREEN_VIEWED, mapOf(EventProperties.SCREEN_NAME to activityLabel)) + } catch (e: PackageManager.NameNotFoundException) { + amplitude.logger.error("Failed to get activity info: $e") + } catch (e: Exception) { + amplitude.logger.error("Failed to track screen viewed event: $e") + } + } + + private fun getReferrer(activity: Activity): Uri? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + 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 + } + } + } + } + return referrerUri + } + } +} + +private fun PackageInfo.getVersionCode(): Number = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + this.longVersionCode + } else { + @Suppress("DEPRECATION") + this.versionCode + } diff --git a/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt b/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt index 284e2734..7aded7e6 100644 --- a/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt +++ b/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt @@ -549,6 +549,57 @@ class AmplitudeSessionTest { Assertions.assertEquals(1100, event.timestamp) } + @Suppress("DEPRECATION") + @Test + fun amplitude_noSessionEventsWhenDisabledWithTrackingSessionEvents() = runTest { + val configuration = createConfiguration() + configuration.trackingSessionEvents = false + val amplitude = Amplitude(configuration) + setDispatcher(amplitude, testScheduler) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + + amplitude.isBuilt.await() + + amplitude.track(createEvent(1000, "test event")) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + + verify { + mockedPlugin.track(capture(tracks)) + } + Assertions.assertEquals(1, tracks.count()) + } + + @Test + fun amplitude_noSessionEventsWhenDisabledWithDefaultTrackingOptions() = runTest { + val configuration = createConfiguration() + configuration.defaultTracking.sessions = false + val amplitude = Amplitude(configuration) + setDispatcher(amplitude, testScheduler) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + + amplitude.isBuilt.await() + + amplitude.track(createEvent(1000, "test event")) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + + verify { + mockedPlugin.track(capture(tracks)) + } + Assertions.assertEquals(1, tracks.count()) + } + private fun createEvent(timestamp: Long, eventType: String, sessionId: Long? = null): BaseEvent { val event = BaseEvent() event.userId = "user" diff --git a/android/src/test/java/com/amplitude/android/ConfigurationTest.kt b/android/src/test/java/com/amplitude/android/ConfigurationTest.kt index 207f0bdd..177971af 100644 --- a/android/src/test/java/com/amplitude/android/ConfigurationTest.kt +++ b/android/src/test/java/com/amplitude/android/ConfigurationTest.kt @@ -24,11 +24,43 @@ class ConfigurationTest { Assertions.assertTrue(configuration.isValid()) } + @Suppress("DEPRECATION") @Test fun configuration_allows_propertyUpdate() { val configuration = Configuration("test-apikey", context!!) Assertions.assertTrue(configuration.trackingSessionEvents) + Assertions.assertTrue(configuration.defaultTracking.sessions) + Assertions.assertFalse(configuration.defaultTracking.appLifecycles) + Assertions.assertFalse(configuration.defaultTracking.deepLinks) + Assertions.assertFalse(configuration.defaultTracking.screenViews) configuration.trackingSessionEvents = false + configuration.defaultTracking.sessions = false + configuration.defaultTracking.appLifecycles = true + configuration.defaultTracking.deepLinks = true + configuration.defaultTracking.screenViews = true Assertions.assertFalse(configuration.trackingSessionEvents) + Assertions.assertFalse(configuration.defaultTracking.sessions) + Assertions.assertTrue(configuration.defaultTracking.appLifecycles) + Assertions.assertTrue(configuration.defaultTracking.deepLinks) + Assertions.assertTrue(configuration.defaultTracking.screenViews) + } + + @Test + fun configuration_defaultTracking_quick_update() { + val configuration = Configuration( + "test-apikey", + context!!, + defaultTracking = DefaultTrackingOptions.ALL + ) + Assertions.assertTrue(configuration.defaultTracking.sessions) + Assertions.assertTrue(configuration.defaultTracking.appLifecycles) + Assertions.assertTrue(configuration.defaultTracking.deepLinks) + Assertions.assertTrue(configuration.defaultTracking.screenViews) + + configuration.defaultTracking = DefaultTrackingOptions.NONE + Assertions.assertFalse(configuration.defaultTracking.sessions) + Assertions.assertFalse(configuration.defaultTracking.appLifecycles) + Assertions.assertFalse(configuration.defaultTracking.deepLinks) + Assertions.assertFalse(configuration.defaultTracking.screenViews) } } diff --git a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt new file mode 100644 index 00000000..f8032624 --- /dev/null +++ b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt @@ -0,0 +1,499 @@ +package com.amplitude.android.plugins + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import com.amplitude.android.Amplitude +import com.amplitude.android.Configuration +import com.amplitude.android.StubPlugin +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 io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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 + +@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 + + init { + val packageInfo = PackageInfo() + @Suppress("DEPRECATION") + packageInfo.versionCode = 66 + packageInfo.versionName = "6.0.0" + + mockedPackageManager = mockk { + every { getPackageInfo("com.plugin.test", 0) } returns packageInfo + } + every { mockedContext.packageName } returns "com.plugin.test" + every { mockedContext.packageManager } returns mockedPackageManager + } + + 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) + } + + @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 "" + + configuration = Configuration( + apiKey = "api-key", + context = mockedContext, + storageProvider = InMemoryStorageProvider(), + loggerProvider = ConsoleLoggerProvider(), + identifyInterceptStorageProvider = InMemoryStorageProvider(), + identityStorageProvider = IMIdentityStorageProvider(), + trackingSessionEvents = false, + ) + amplitude = Amplitude(configuration) + } + + @Test + fun `test application installed event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = true + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + val mockedActivity = mockk() + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + + 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") + } + } + + @Test + fun `test application installed event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = false + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + val mockedActivity = mockk() + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } + + @Test + fun `test application updated event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = true + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + // Stored previous version/build + amplitude.storage.write(Storage.Constants.APP_BUILD, "55") + amplitude.storage.write(Storage.Constants.APP_VERSION, "5.0.0") + + val mockedActivity = mockk() + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + + 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") + } + } + + @Test + fun `test application updated event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = false + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + // Stored previous version/build + amplitude.storage.write(Storage.Constants.APP_BUILD, "55") + amplitude.storage.write(Storage.Constants.APP_VERSION, "5.0.0") + + val mockedActivity = mockk() + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } + + @Test + fun `test application opened event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = true + 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) + + val tracks = mutableListOf() + verify { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(2, tracks.count()) + + 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) + } + } + + @Test + fun `test application opened event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = false + 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) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } + + @Test + fun `test application backgrounded event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.appLifecycles = true + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + val mockedActivity = mockk() + androidLifecyclePlugin.onActivityPaused(mockedActivity) + androidLifecyclePlugin.onActivityStopped(mockedActivity) + androidLifecyclePlugin.onActivityDestroyed(mockedActivity) + + 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_BACKGROUNDED, eventType) + } + } + + @Test + fun `test application backgrounded event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + (amplitude.configuration as Configuration).defaultTracking.appLifecycles = false + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + val mockedActivity = mockk() + androidLifecyclePlugin.onActivityPaused(mockedActivity) + androidLifecyclePlugin.onActivityStopped(mockedActivity) + androidLifecyclePlugin.onActivityDestroyed(mockedActivity) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } + + @Test + fun `test screen viewed event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.screenViews = true + 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(), any()) } returns mockedActivityInfo + every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + androidLifecyclePlugin.onActivityStarted(mockedActivity) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(1, tracks.count()) + + with(tracks[0]) { + Assertions.assertEquals(DefaultEventUtils.EventTypes.SCREEN_VIEWED, eventType) + Assertions.assertEquals(eventProperties?.get(DefaultEventUtils.EventProperties.SCREEN_NAME), "test-label") + } + } + + @Test + fun `test screen viewed event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.screenViews = false + 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(), any()) } returns mockedActivityInfo + every { mockedActivityInfo.loadLabel(mockedPackageManager) } returns "test-label" + val mockedBundle = mockk() + androidLifecyclePlugin.onActivityCreated(mockedActivity, mockedBundle) + androidLifecyclePlugin.onActivityStarted(mockedActivity) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } + + @Test + fun `test deep link opened event is tracked`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.deepLinks = true + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + 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) + + 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") + } + } + + @Config(sdk = [21]) + @Test + fun `test deep link opened event is tracked when using sdk is between 17 and 21`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.deepLinks = true + amplitude.add(androidLifecyclePlugin) + + 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) + + 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") + } + } + + @Config(sdk = [16]) + @Test + fun `test deep link opened event is tracked when using sdk is lower than 17`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.deepLinks = true + amplitude.add(androidLifecyclePlugin) + + 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) + + 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), null) + } + } + + @Test + fun `test deep link opened event is not tracked when disabled`() = runTest { + setDispatcher(testScheduler) + configuration.defaultTracking.deepLinks = false + amplitude.add(androidLifecyclePlugin) + + val mockedPlugin = spyk(StubPlugin()) + amplitude.add(mockedPlugin) + amplitude.isBuilt.await() + + 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) + + advanceUntilIdle() + Thread.sleep(100) + + val tracks = mutableListOf() + verify(exactly = 0) { mockedPlugin.track(capture(tracks)) } + Assertions.assertEquals(0, tracks.count()) + } +} diff --git a/core/src/main/java/com/amplitude/core/Storage.kt b/core/src/main/java/com/amplitude/core/Storage.kt index ed3970ce..290563b2 100644 --- a/core/src/main/java/com/amplitude/core/Storage.kt +++ b/core/src/main/java/com/amplitude/core/Storage.kt @@ -13,7 +13,9 @@ interface Storage { PREVIOUS_SESSION_ID("previous_session_id"), LAST_EVENT_TIME("last_event_time"), OPT_OUT("opt_out"), - Events("events") + Events("events"), + APP_VERSION("app_version"), + APP_BUILD("app_build") } suspend fun writeEvent(event: BaseEvent) diff --git a/samples/kotlin-android-app/src/main/AndroidManifest.xml b/samples/kotlin-android-app/src/main/AndroidManifest.xml index f37c3b3e..11ccf13b 100644 --- a/samples/kotlin-android-app/src/main/AndroidManifest.xml +++ b/samples/kotlin-android-app/src/main/AndroidManifest.xml @@ -14,18 +14,34 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Kotlinsdk"> + + + + + + + + + + + android:value="true" /> - \ No newline at end of file + diff --git a/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/AdvancedActivity.kt b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/AdvancedActivity.kt new file mode 100644 index 00000000..0b5c5225 --- /dev/null +++ b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/AdvancedActivity.kt @@ -0,0 +1,48 @@ +package com.amplitude.android.sample + +import android.os.Bundle +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import com.amplitude.core.events.Identify +import com.amplitude.core.events.Revenue + +class AdvancedActivity : AppCompatActivity() { + private val amplitude = MainApplication.amplitude + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_advanced) + + val sendAdvancedEventsButton: Button = findViewById(R.id.send_advanced_events_button) + sendAdvancedEventsButton.setOnClickListener { + // set user properties + val identify = Identify() + identify.set("user-platform", "android") + .set("custom-properties", "sample") + amplitude.identify(identify) + + // set groups fro this user + val groupType = "test-group-type" + val groupName = "android-kotlin-sample" + amplitude.setGroup(groupType, groupName) + amplitude.setGroup("orgId", "15") + amplitude.setGroup("sport", arrayOf("tennis", "soccer")) // list values + + // group identify to set group properties + val groupIdentifyObj = Identify().set("key", "value") + amplitude.groupIdentify(groupType, groupName, groupIdentifyObj) + + // log revenue call + val revenue = Revenue() + revenue.productId = "com.company.productId" + revenue.price = 3.99 + revenue.quantity = 3 + amplitude.revenue(revenue) + } + + val backButton: Button = findViewById(R.id.back_button) + backButton.setOnClickListener { + this.finish() + } + } +} diff --git a/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainActivity.kt b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainActivity.kt index ae370cd5..724092f1 100644 --- a/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainActivity.kt +++ b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainActivity.kt @@ -1,12 +1,11 @@ package com.amplitude.android.sample +import android.content.Intent import android.os.Bundle import android.widget.Button import androidx.appcompat.app.AppCompatActivity import com.amplitude.core.events.EventOptions -import com.amplitude.core.events.Identify import com.amplitude.core.events.Plan -import com.amplitude.core.events.Revenue class MainActivity : AppCompatActivity() { private val amplitude = MainApplication.amplitude @@ -15,37 +14,20 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - val sendEventButton: Button = findViewById(R.id.button) - - // set user properties - val identify = Identify() - identify.set("user-platform", "android") - .set("custom-properties", "sample") val options = EventOptions() options.plan = Plan(branch = "test") - amplitude.identify(identify) - - // set groups fro this user - val groupType = "test-group-type" - val groupName = "android-kotlin-sample" - amplitude.setGroup(groupType, groupName) - amplitude.setGroup("orgId", "15") - amplitude.setGroup("sport", arrayOf("tennis", "soccer")) // list values - - // group identify to set group properties - val groupIdentifyObj = Identify().set("key", "value") - amplitude.groupIdentify(groupType, groupName, groupIdentifyObj) - - // log revenue call - val revenue = Revenue() - revenue.productId = "com.company.productId" - revenue.price = 3.99 - revenue.quantity = 3 - amplitude.revenue(revenue) + val sendEventButton: Button = findViewById(R.id.send_event_button) // track event with event properties sendEventButton.setOnClickListener { amplitude.track("test event properties", mapOf("test" to "test event property value"), options) } + + // navigate to the advanced events view + val naviToAdvancedActivityButton: Button = findViewById(R.id.navi_to_advanced_activity_button) + naviToAdvancedActivityButton.setOnClickListener { + val intent = Intent(this, AdvancedActivity::class.java) + startActivity(intent) + } } } diff --git a/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainApplication.kt b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainApplication.kt index 6d2fb977..12238c24 100644 --- a/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainApplication.kt +++ b/samples/kotlin-android-app/src/main/java/com/amplitude/android/sample/MainApplication.kt @@ -3,11 +3,11 @@ package com.amplitude.android.sample import android.app.Application import com.amplitude.android.Amplitude import com.amplitude.android.Configuration +import com.amplitude.android.DefaultTrackingOptions import com.amplitude.core.events.BaseEvent import com.amplitude.core.platform.Plugin import com.amplitude.experiment.Experiment import com.amplitude.experiment.ExperimentConfig -import java.lang.Exception class MainApplication : Application() { companion object { @@ -23,7 +23,8 @@ class MainApplication : Application() { amplitude = Amplitude( Configuration( apiKey = AMPLITUDE_API_KEY, - context = applicationContext + context = applicationContext, + defaultTracking = DefaultTrackingOptions.ALL ) ) @@ -45,7 +46,7 @@ class MainApplication : Application() { override val type: Plugin.Type = Plugin.Type.Enrichment override lateinit var amplitude: com.amplitude.core.Amplitude - override fun execute(event: BaseEvent): BaseEvent? { + override fun execute(event: BaseEvent): BaseEvent { event.eventProperties = event.eventProperties ?: mutableMapOf() event.eventProperties?.put("custom android event property", "test") return event diff --git a/samples/kotlin-android-app/src/main/res/layout/activity_advanced.xml b/samples/kotlin-android-app/src/main/res/layout/activity_advanced.xml new file mode 100644 index 00000000..d3894b75 --- /dev/null +++ b/samples/kotlin-android-app/src/main/res/layout/activity_advanced.xml @@ -0,0 +1,32 @@ + + + +