From c4a99b14502a5b46f6ec6318db8d8dbe13a5bbbb Mon Sep 17 00:00:00 2001 From: Abhishek Pandey <64667840+1abhishekpandey@users.noreply.github.com> Date: Fri, 12 Apr 2024 18:50:16 +0530 Subject: [PATCH] feat: add Application Installed and Application Updated events (#412) * feat: add option to save and fetch versionName and build In RudderPreferenceManager.kt class * feat: add option to save and fetch versionName and build In AndroidStorage.kt * feat: add option to save and fetch versionName and build In AndroidStorageImpl.kt * feat: add AppVersion.kt data class * feat: add AppVersionManager.kt * feat: add AppInstallUpdateTrackerPlugin.kt * feat: add AppInstallUpdateTrackerPlugin inside infrastructure plugin list * refactor: change build to versionCode * refactor: move business logic outside of AppVersion * refactor: move AppVersion to model package * refactor: change versionCode back to build * chore: remove AppVersionManager.kt * refactor: move the lifecycle logic to AppInstallUpdateTrackerPlugin.kt * test: add AppInstallUpdateTrackerPluginTest.kt * refactor: change versionCode param to build * chore: simplify logic to save versionName and build * test: add more test case in AppInstallUpdateTrackerPluginTest.kt --- .../rudderstack/android/RudderAnalytics.kt | 2 + .../internal/RudderPreferenceManager.kt | 16 + .../AppInstallUpdateTrackerPlugin.kt | 121 +++++++ .../android/storage/AndroidStorage.kt | 6 +- .../android/storage/AndroidStorageImpl.kt | 13 +- .../AppInstallUpdateTrackerPluginTest.kt | 316 ++++++++++++++++++ .../java/com/rudderstack/models/AppVersion.kt | 8 + 7 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 android/src/main/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPlugin.kt create mode 100644 android/src/test/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPluginTest.kt create mode 100644 models/src/main/java/com/rudderstack/models/AppVersion.kt diff --git a/android/src/main/java/com/rudderstack/android/RudderAnalytics.kt b/android/src/main/java/com/rudderstack/android/RudderAnalytics.kt index c944f882c..7c5ab3bde 100644 --- a/android/src/main/java/com/rudderstack/android/RudderAnalytics.kt +++ b/android/src/main/java/com/rudderstack/android/RudderAnalytics.kt @@ -17,6 +17,7 @@ package com.rudderstack.android import com.rudderstack.android.internal.infrastructure.ActivityBroadcasterPlugin import com.rudderstack.android.internal.infrastructure.AnonymousIdHeaderPlugin +import com.rudderstack.android.internal.infrastructure.AppInstallUpdateTrackerPlugin import com.rudderstack.android.internal.infrastructure.LifecycleObserverPlugin import com.rudderstack.android.internal.infrastructure.ResetImplementationPlugin import com.rudderstack.android.internal.plugins.ReinstatePlugin @@ -136,6 +137,7 @@ fun Analytics.setUserId(userId: String) { private val infrastructurePlugins get() = arrayOf( AnonymousIdHeaderPlugin(), + AppInstallUpdateTrackerPlugin(), LifecycleObserverPlugin(), ActivityBroadcasterPlugin(), ResetImplementationPlugin() diff --git a/android/src/main/java/com/rudderstack/android/internal/RudderPreferenceManager.kt b/android/src/main/java/com/rudderstack/android/internal/RudderPreferenceManager.kt index 41e95631a..26169b870 100644 --- a/android/src/main/java/com/rudderstack/android/internal/RudderPreferenceManager.kt +++ b/android/src/main/java/com/rudderstack/android/internal/RudderPreferenceManager.kt @@ -34,6 +34,8 @@ private const val RUDDER_PERIODIC_WORK_REQUEST_ID_KEY = "rl_periodic_work_reques private const val RUDDER_SESSION_ID_KEY = "rl_session_id_key" private const val RUDDER_SESSION_LAST_ACTIVE_TIMESTAMP_KEY = "rl_last_event_timestamp_key" +private const val RUDDER_APPLICATION_VERSION_KEY = "rl_application_version_key" +private const val RUDDER_APPLICATION_BUILD_KEY = "rl_application_build_key" internal class RudderPreferenceManager(application: Application, private val writeKey: String) { @@ -175,4 +177,18 @@ internal class RudderPreferenceManager(application: Application, val optStatus: Boolean get() = preferences.getBoolean(RUDDER_OPT_STATUS_KEY.key, false) + + fun saveVersionName(versionName: String) { + preferences.edit().putString(RUDDER_APPLICATION_VERSION_KEY, versionName).apply() + } + + val versionName: String? + get() = preferences.getString(RUDDER_APPLICATION_VERSION_KEY, null) + + fun saveBuild(build: Int) { + preferences.edit().putInt(RUDDER_APPLICATION_BUILD_KEY, build).apply() + } + + val build: Int + get() = preferences.getInt(RUDDER_APPLICATION_BUILD_KEY, -1) } diff --git a/android/src/main/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPlugin.kt b/android/src/main/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPlugin.kt new file mode 100644 index 000000000..2e557b49b --- /dev/null +++ b/android/src/main/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPlugin.kt @@ -0,0 +1,121 @@ +package com.rudderstack.android.internal.infrastructure + +import android.content.pm.PackageManager +import android.os.Build +import com.rudderstack.android.androidStorage +import com.rudderstack.android.currentConfigurationAndroid +import com.rudderstack.android.storage.AndroidStorage +import com.rudderstack.models.AppVersion +import com.rudderstack.core.Analytics +import com.rudderstack.core.InfrastructurePlugin + +private const val PREVIOUS_VERSION = "previous_version" +private const val PREVIOUS_BUILD = "previous_build" +private const val VERSION = "version" +private const val BUILD = "build" + +private const val EVENT_NAME_APPLICATION_INSTALLED = "Application Installed" +private const val EVENT_NAME_APPLICATION_UPDATED = "Application Updated" + +private const val DEFAULT_BUILD = -1 +private const val DEFAULT_VERSION_NAME = "" + +class AppInstallUpdateTrackerPlugin : InfrastructurePlugin { + + private var analytics: Analytics? = null + private lateinit var appVersion: AppVersion + + override fun setup(analytics: Analytics) { + this.analytics = analytics + this.appVersion = getAppVersion(analytics) + storeVersionNameAndBuild(analytics.androidStorage) + if (this.analytics?.currentConfigurationAndroid?.trackLifecycleEvents == true) { + trackApplicationStatus() + } + } + + private fun getAppVersion(analytics: Analytics): AppVersion { + val previousBuild: Int? = analytics.androidStorage.build + val previousVersionName: String? = analytics.androidStorage.versionName + var currentBuild: Int? = null + var currentVersionName: String? = null + + try { + val packageName = analytics.currentConfigurationAndroid?.application?.packageName + val packageManager: PackageManager? = analytics.currentConfigurationAndroid?.application?.packageManager + val packageInfo = packageName?.let { + packageManager?.getPackageInfo(it, 0) + } + + currentVersionName = packageInfo?.versionName + currentBuild = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo?.longVersionCode?.toInt() + } else { + packageInfo?.versionCode + } + } catch (ex: PackageManager.NameNotFoundException) { + analytics.logger.error(log = "Failed to get app version info: ${ex.message}") + } + + return AppVersion( + previousBuild = previousBuild ?: DEFAULT_BUILD, + previousVersionName = previousVersionName ?: DEFAULT_VERSION_NAME, + currentBuild = currentBuild ?: DEFAULT_BUILD, + currentVersionName = currentVersionName ?: DEFAULT_VERSION_NAME, + ) + } + + private fun storeVersionNameAndBuild(analyticsStorage: AndroidStorage) { + analyticsStorage.setVersionName(this.appVersion.currentVersionName) + analyticsStorage.setBuild(this.appVersion.currentBuild) + } + + private fun trackApplicationStatus() { + if (this.isApplicationInstalled()) { + sendApplicationInstalledEvent() + } else if (this.isApplicationUpdated()) { + sendApplicationUpdatedEvent() + } + } + + private fun isApplicationInstalled(): Boolean { + return this.appVersion.previousBuild == -1 + } + + private fun isApplicationUpdated(): Boolean { + return this.appVersion.previousBuild != -1 && this.appVersion.previousBuild != this.appVersion.currentBuild + } + + private fun sendApplicationInstalledEvent() { + this.analytics?.logger?.debug(log = "Tracking Application Installed event") + val trackProperties = mutableMapOf() + trackProperties[VERSION] = this.appVersion.currentVersionName + trackProperties[BUILD] = this.appVersion.currentBuild + + sendEvent(EVENT_NAME_APPLICATION_INSTALLED, trackProperties) + } + + private fun sendApplicationUpdatedEvent() { + this.analytics?.logger?.debug(log = "Tracking Application Updated event") + val trackProperties = mutableMapOf() + trackProperties[PREVIOUS_VERSION] = this.appVersion.previousVersionName + trackProperties[PREVIOUS_BUILD] = this.appVersion.previousBuild + trackProperties[VERSION] = this.appVersion.currentVersionName + trackProperties[BUILD] = this.appVersion.currentBuild + + sendEvent(EVENT_NAME_APPLICATION_UPDATED, trackProperties) + } + + private fun sendEvent(eventName: String, properties: Map) { + analytics?.track { + event(eventName) + trackProperties { + add(properties) + } + } + } + + override fun shutdown() { + analytics = null + } +} diff --git a/android/src/main/java/com/rudderstack/android/storage/AndroidStorage.kt b/android/src/main/java/com/rudderstack/android/storage/AndroidStorage.kt index a7b0f3ce4..9f303e6fb 100644 --- a/android/src/main/java/com/rudderstack/android/storage/AndroidStorage.kt +++ b/android/src/main/java/com/rudderstack/android/storage/AndroidStorage.kt @@ -30,6 +30,8 @@ interface AndroidStorage : Storage { val v1Traits: Map? val v1ExternalIds: List>? val trackAutoSession: Boolean + val build: Int? + val versionName: String? /** * Platform specific implementation of caching context. This can be done locally too. * @@ -55,4 +57,6 @@ interface AndroidStorage : Storage { fun resetV1OptOut() fun resetV1Traits() fun resetV1ExternalIds() -} \ No newline at end of file + fun setBuild(build: Int) + fun setVersionName(versionName: String) +} diff --git a/android/src/main/java/com/rudderstack/android/storage/AndroidStorageImpl.kt b/android/src/main/java/com/rudderstack/android/storage/AndroidStorageImpl.kt index af3dc1a2f..81b5f4f18 100644 --- a/android/src/main/java/com/rudderstack/android/storage/AndroidStorageImpl.kt +++ b/android/src/main/java/com/rudderstack/android/storage/AndroidStorageImpl.kt @@ -335,6 +335,10 @@ class AndroidStorageImpl( } override val trackAutoSession: Boolean get() = preferenceManager?.trackAutoSession?: false + override val build: Int? + get() = preferenceManager?.build + override val versionName: String? + get() = preferenceManager?.versionName override fun setAnonymousId(anonymousId: String) { _anonymousId = anonymousId @@ -382,6 +386,14 @@ class AndroidStorageImpl( preferenceManager?.resetV1ExternalIds() } + override fun setBuild(build: Int) { + preferenceManager?.saveBuild(build) + } + + override fun setVersionName(versionName: String) { + preferenceManager?.saveVersionName(versionName) + } + override val libraryName: String get() = BuildConfig.LIBRARY_PACKAGE_NAME override val libraryVersion: String @@ -413,7 +425,6 @@ class AndroidStorageImpl( private fun importV1Data() { val oldDbName = "events.db" val oldDb = application.getDatabasePath(oldDbName) - } } diff --git a/android/src/test/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPluginTest.kt b/android/src/test/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPluginTest.kt new file mode 100644 index 000000000..7910581cc --- /dev/null +++ b/android/src/test/java/com/rudderstack/android/internal/infrastructure/AppInstallUpdateTrackerPluginTest.kt @@ -0,0 +1,316 @@ +package com.rudderstack.android.internal.infrastructure + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.rudderstack.android.ConfigurationAndroid +import com.rudderstack.android.android.utils.TestExecutor +import com.rudderstack.android.storage.AndroidStorage +import com.rudderstack.android.storage.AndroidStorageImpl +import com.rudderstack.core.Analytics +import com.rudderstack.gsonrudderadapter.GsonAdapter +import com.rudderstack.models.TrackMessage +import com.rudderstack.rudderjsonadapter.JsonAdapter +import com.vagabond.testcommon.generateTestAnalytics +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Test + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.P]) +class AppInstallUpdateTrackerPluginTest { + + private lateinit var appInstallUpdateTrackerPlugin: AppInstallUpdateTrackerPlugin + private val jsonAdapter: JsonAdapter = GsonAdapter() + private val application: Application = ApplicationProvider.getApplicationContext() + private lateinit var analytics: Analytics + private lateinit var storage: AndroidStorage + + @Before + fun setup() { + appInstallUpdateTrackerPlugin = AppInstallUpdateTrackerPlugin() + } + + @After + fun destroy() { + val storage = analytics.storage as AndroidStorageImpl + storage.clearStorage() + analytics.shutdown() + } + + /** + * Helper function to generate an instance of the analytics object with the given configuration. + */ + private fun generateTestAnalytics(trackLifecycleEvents: Boolean = true): Analytics { + storage = AndroidStorageImpl( + ApplicationProvider.getApplicationContext(), + false, + writeKey = "test_writeKey", + storageExecutor = TestExecutor(), + ) + val mockConfig = ConfigurationAndroid( + application, jsonAdapter, + shouldVerifySdk = false, + analyticsExecutor = TestExecutor(), + trackLifecycleEvents = trackLifecycleEvents, + ) + return generateTestAnalytics(mockConfig, storage = this.storage) + } + + /** + * Helper function to simulate app restart by clearing the storage and shutting down the analytics instance. + */ + private fun simulateAppRestart() { + analytics.storage.clearStorage() + analytics.shutdown() + } + + @Test + fun `when lifecycle is enabled at the first app install, then Application Installed event should be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.allOf( + Matchers.iterableWithSize(1), Matchers.everyItem( + Matchers.allOf( + Matchers.isA(TrackMessage::class.java), + Matchers.hasProperty( + "eventName", Matchers.equalTo("Application Installed") + ), + Matchers.hasProperty( + "properties", Matchers.allOf( + Matchers.hasEntry("version", "1.0.0"), + Matchers.hasEntry("build", 1.0) + ) + ) + ) + ) + ) + ) + } + + @Test + fun `when application is updated, then Application Updated should be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics() + setCurrentVersionNameAndCode("1.0.1", 2) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.allOf( + Matchers.hasItems( + Matchers.allOf( + Matchers.isA(TrackMessage::class.java), + Matchers.hasProperty( + "eventName", Matchers.equalTo("Application Updated") + ), + Matchers.hasProperty( + "properties", Matchers.allOf( + Matchers.hasEntry("previous_version", "1.0.0"), + Matchers.hasEntry("previous_build", 1.0), + Matchers.hasEntry("version", "1.0.1"), + Matchers.hasEntry("build", 2.0) + ) + ) + ) + ) + ) + ) + } + + @Test + fun `when lifecycle is disabled at the first app install, then Application Installed shouldn't be made`() { + analytics = generateTestAnalytics(trackLifecycleEvents = false) + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `given lifecycle was disabled at the first app install, when app is launched again with lifecycle enabled, then Application Installed shouldn't be made`() { + analytics = generateTestAnalytics(trackLifecycleEvents = false) + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics() + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `given lifecycle was enabled at the first app install, when app is launched again with lifecycle disabled, then Application Installed shouldn't be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics(trackLifecycleEvents = false) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `given lifecycle was disabled at the first app install, when app is launched again with lifecycle disabled, then Application Installed shouldn't be made`() { + analytics = generateTestAnalytics(trackLifecycleEvents = false) + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics(trackLifecycleEvents = false) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `when application is updated with lifecycle disabled, then Application Updated shouldn't be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics(trackLifecycleEvents = false) + setCurrentVersionNameAndCode("1.0.0", 2) + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `given application is already updated, when app is launched again with lifecycle disabled, then Application Updated shouldn't be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics() + setCurrentVersionNameAndCode("1.0.0", 2) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics(trackLifecycleEvents = false) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `when version name is changed, then Application Update should not be made`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.0", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics() + setCurrentVersionNameAndCode("1.0.1", 1) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.empty() + ) + } + + @Test + fun `given first time application is updated with lifecycle tracking disabled, when app is updated again with lifecycle tracking enabled, then Application Updated should be made with correct properties`() { + analytics = generateTestAnalytics() + setDefaultVersionNameAndCode() + setCurrentVersionNameAndCode("1.0.1", 1) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics(false) + setCurrentVersionNameAndCode("1.0.2", 2) + appInstallUpdateTrackerPlugin.setup(analytics) + simulateAppRestart() + analytics = generateTestAnalytics() + setCurrentVersionNameAndCode("1.0.3", 3) + appInstallUpdateTrackerPlugin.setup(analytics) + + appInstallUpdateTrackerPlugin.setup(analytics) + + val saved = analytics.storage.getDataSync() + MatcherAssert.assertThat( + saved, Matchers.allOf( + Matchers.hasItems( + Matchers.allOf( + Matchers.isA(TrackMessage::class.java), + Matchers.hasProperty( + "eventName", Matchers.equalTo("Application Updated") + ), + Matchers.hasProperty( + "properties", Matchers.allOf( + Matchers.hasEntry("previous_version", "1.0.2"), + Matchers.hasEntry("previous_build", 2.0), + Matchers.hasEntry("version", "1.0.3"), + Matchers.hasEntry("build", 3.0) + ) + ) + ) + ) + ) + ) + } + + /** + * Helper function to set the version name and build in the Robolectric Application object. + */ + private fun setCurrentVersionNameAndCode(versionName: String, build: Long) { + shadowOf(application.packageManager).getInternalMutablePackageInfo(application.packageName).versionName = versionName + shadowOf(application.packageManager).getInternalMutablePackageInfo(application.packageName).longVersionCode = build + } + + /** + * Helper function to set the default version name and build in the storage object. + */ + private fun setDefaultVersionNameAndCode() { + val storage = analytics.storage as AndroidStorageImpl + storage.setVersionName("") + storage.setBuild(-1) + } +} diff --git a/models/src/main/java/com/rudderstack/models/AppVersion.kt b/models/src/main/java/com/rudderstack/models/AppVersion.kt new file mode 100644 index 000000000..6346b62e3 --- /dev/null +++ b/models/src/main/java/com/rudderstack/models/AppVersion.kt @@ -0,0 +1,8 @@ +package com.rudderstack.models + +data class AppVersion( + val previousBuild: Int, + val previousVersionName: String, + val currentBuild: Int, + val currentVersionName: String, +)