From 669eead95c7cd745f63481663fad5ea473feb99d Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Tue, 23 Jan 2024 16:49:51 -0800 Subject: [PATCH] feat: offline support (#171) --- .../java/com/amplitude/android/Amplitude.kt | 4 + .../com/amplitude/android/Configuration.kt | 3 +- ...AndroidNetworkConnectivityCheckerPlugin.kt | 48 +++++++++ .../AndroidNetworkConnectivityChecker.kt | 63 +++++++++++ .../utilities/AndroidNetworkListener.kt | 102 ++++++++++++++++++ .../android/AmplitudeRobolectricTests.kt | 5 +- .../amplitude/android/AmplitudeSessionTest.kt | 4 + .../com/amplitude/android/AmplitudeTest.kt | 5 + .../plugins/AndroidLifecyclePluginTest.kt | 6 ++ ...oidNetworkConnectivityCheckerPluginTest.kt | 61 +++++++++++ .../AndroidNetworkConnectivityCheckerTest.kt | 71 ++++++++++++ build.gradle | 2 +- .../java/com/amplitude/core/Configuration.kt | 1 + .../amplitude/core/platform/EventPipeline.kt | 5 + .../com/amplitude/core/platform/Plugin.kt | 4 + .../com/amplitude/core/platform/Timeline.kt | 5 +- .../core/platform/EventPipelineTest.kt | 86 +++++++++++++++ 17 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 android/src/main/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPlugin.kt create mode 100644 android/src/main/java/com/amplitude/android/utilities/AndroidNetworkConnectivityChecker.kt create mode 100644 android/src/main/java/com/amplitude/android/utilities/AndroidNetworkListener.kt create mode 100644 android/src/test/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPluginTest.kt create mode 100644 android/src/test/java/com/amplitude/android/utilities/AndroidNetworkConnectivityCheckerTest.kt create mode 100644 core/src/test/kotlin/com/amplitude/core/platform/EventPipelineTest.kt diff --git a/android/src/main/java/com/amplitude/android/Amplitude.kt b/android/src/main/java/com/amplitude/android/Amplitude.kt index 79a5ff74..749d38ba 100644 --- a/android/src/main/java/com/amplitude/android/Amplitude.kt +++ b/android/src/main/java/com/amplitude/android/Amplitude.kt @@ -7,6 +7,7 @@ import com.amplitude.android.plugins.AnalyticsConnectorIdentityPlugin import com.amplitude.android.plugins.AnalyticsConnectorPlugin import com.amplitude.android.plugins.AndroidContextPlugin import com.amplitude.android.plugins.AndroidLifecyclePlugin +import com.amplitude.android.plugins.AndroidNetworkConnectivityCheckerPlugin import com.amplitude.core.Amplitude import com.amplitude.core.events.BaseEvent import com.amplitude.core.platform.plugins.AmplitudeDestination @@ -56,6 +57,9 @@ open class Amplitude( } this.createIdentityContainer(identityConfiguration) + if (this.configuration.offline != AndroidNetworkConnectivityCheckerPlugin.Disabled) { + add(AndroidNetworkConnectivityCheckerPlugin()) + } androidContextPlugin = AndroidContextPlugin() add(androidContextPlugin) add(GetAmpliExtrasPlugin()) diff --git a/android/src/main/java/com/amplitude/android/Configuration.kt b/android/src/main/java/com/amplitude/android/Configuration.kt index 6c536721..47de8abc 100644 --- a/android/src/main/java/com/amplitude/android/Configuration.kt +++ b/android/src/main/java/com/amplitude/android/Configuration.kt @@ -46,7 +46,8 @@ open class Configuration @JvmOverloads constructor( override var identifyInterceptStorageProvider: StorageProvider = AndroidStorageProvider(), override var identityStorageProvider: IdentityStorageProvider = FileIdentityStorageProvider(), var migrateLegacyData: Boolean = true, -) : Configuration(apiKey, flushQueueSize, flushIntervalMillis, instanceName, optOut, storageProvider, loggerProvider, minIdLength, partnerId, callback, flushMaxRetries, useBatch, serverZone, serverUrl, plan, ingestionMetadata, identifyBatchIntervalMillis, identifyInterceptStorageProvider, identityStorageProvider) { + override var offline: Boolean? = false, +) : Configuration(apiKey, flushQueueSize, flushIntervalMillis, instanceName, optOut, storageProvider, loggerProvider, minIdLength, partnerId, callback, flushMaxRetries, useBatch, serverZone, serverUrl, plan, ingestionMetadata, identifyBatchIntervalMillis, identifyInterceptStorageProvider, identityStorageProvider, offline) { companion object { const val MIN_TIME_BETWEEN_SESSIONS_MILLIS: Long = 300000 } diff --git a/android/src/main/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPlugin.kt b/android/src/main/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPlugin.kt new file mode 100644 index 00000000..f187669f --- /dev/null +++ b/android/src/main/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPlugin.kt @@ -0,0 +1,48 @@ +package com.amplitude.android.plugins + +import com.amplitude.android.Configuration +import com.amplitude.android.utilities.AndroidNetworkConnectivityChecker +import com.amplitude.android.utilities.AndroidNetworkListener +import com.amplitude.core.Amplitude +import com.amplitude.core.platform.Plugin +import kotlinx.coroutines.launch + +class AndroidNetworkConnectivityCheckerPlugin : Plugin { + override val type: Plugin.Type = Plugin.Type.Before + override lateinit var amplitude: Amplitude + internal lateinit var networkConnectivityChecker: AndroidNetworkConnectivityChecker + internal lateinit var networkListener: AndroidNetworkListener + + companion object { + val Disabled = null + } + + override fun setup(amplitude: Amplitude) { + super.setup(amplitude) + amplitude.logger.debug("Installing AndroidNetworkConnectivityPlugin, offline feature should be supported.") + networkConnectivityChecker = AndroidNetworkConnectivityChecker((amplitude.configuration as Configuration).context, amplitude.logger) + amplitude.amplitudeScope.launch(amplitude.storageIODispatcher) { + amplitude.configuration.offline = !networkConnectivityChecker.isConnected() + } + val networkChangeHandler = + object : AndroidNetworkListener.NetworkChangeCallback { + override fun onNetworkAvailable() { + amplitude.logger.debug("AndroidNetworkListener, onNetworkAvailable.") + amplitude.configuration.offline = false + amplitude.flush() + } + + override fun onNetworkUnavailable() { + amplitude.logger.debug("AndroidNetworkListener, onNetworkUnavailable.") + amplitude.configuration.offline = true + } + } + networkListener = AndroidNetworkListener((amplitude.configuration as Configuration).context) + networkListener.setNetworkChangeCallback(networkChangeHandler) + networkListener.startListening() + } + + override fun teardown() { + networkListener.stopListening() + } +} diff --git a/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkConnectivityChecker.kt b/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkConnectivityChecker.kt new file mode 100644 index 00000000..275f2e42 --- /dev/null +++ b/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkConnectivityChecker.kt @@ -0,0 +1,63 @@ +package com.amplitude.android.utilities + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import com.amplitude.common.Logger + +class AndroidNetworkConnectivityChecker(private val context: Context, private val logger: Logger) { + companion object { + private const val ACCESS_NETWORK_STATE = "android.permission.ACCESS_NETWORK_STATE" + } + + private val hasPermission: Boolean + internal var isMarshmallowAndAbove: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + init { + hasPermission = hasPermission(context, ACCESS_NETWORK_STATE) + if (!hasPermission) { + logger.warn( + @Suppress("ktlint:standard:max-line-length") + "No ACCESS_NETWORK_STATE permission, offline mode is not supported. To enable, add to your AndroidManifest.xml. Learn more at https://www.docs.developers.amplitude.com/data/sdks/android-kotlin/#offline-mode", + ) + } + } + + @SuppressLint("MissingPermission", "NewApi") + fun isConnected(): Boolean { + // Assume connection and proceed. + // Events will be treated like online + // regardless network connectivity + if (!hasPermission) { + return true + } + + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + if (cm is ConnectivityManager) { + if (isMarshmallowAndAbove) { + val network = cm.activeNetwork ?: return false + val capabilities = cm.getNetworkCapabilities(network) ?: return false + + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + } else { + @SuppressLint("MissingPermission") + val networkInfo = cm.activeNetworkInfo + return networkInfo != null && networkInfo.isConnectedOrConnecting + } + } else { + logger.debug("Service is not an instance of ConnectivityManager. Offline mode is not supported") + return true + } + } + + private fun hasPermission( + context: Context, + permission: String, + ): Boolean { + return context.checkCallingOrSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkListener.kt b/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkListener.kt new file mode 100644 index 00000000..336602cf --- /dev/null +++ b/android/src/main/java/com/amplitude/android/utilities/AndroidNetworkListener.kt @@ -0,0 +1,102 @@ +package com.amplitude.android.utilities + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import java.lang.IllegalArgumentException + +class AndroidNetworkListener(private val context: Context) { + private var networkCallback: NetworkChangeCallback? = null + private var networkCallbackForLowerApiLevels: BroadcastReceiver? = null + private var networkCallbackForHigherApiLevels: ConnectivityManager.NetworkCallback? = null + + interface NetworkChangeCallback { + fun onNetworkAvailable() + + fun onNetworkUnavailable() + } + + fun setNetworkChangeCallback(callback: NetworkChangeCallback) { + this.networkCallback = callback + } + + fun startListening() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setupNetworkCallback() + } else { + setupBroadcastReceiver() + } + } + + @SuppressLint("NewApi", "MissingPermission") + // startListening() checks API level + // ACCESS_NETWORK_STATE permission should be added manually by users to enable this feature + private fun setupNetworkCallback() { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkRequest = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + networkCallbackForHigherApiLevels = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + networkCallback?.onNetworkAvailable() + } + + override fun onLost(network: Network) { + networkCallback?.onNetworkUnavailable() + } + } + + connectivityManager.registerNetworkCallback(networkRequest, networkCallbackForHigherApiLevels!!) + } + + private fun setupBroadcastReceiver() { + networkCallbackForLowerApiLevels = + object : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (ConnectivityManager.CONNECTIVITY_ACTION == intent.action) { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetworkInfo + val isConnected = activeNetwork?.isConnectedOrConnecting == true + + if (isConnected) { + networkCallback?.onNetworkAvailable() + } else { + networkCallback?.onNetworkUnavailable() + } + } + } + } + + val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) + context.registerReceiver(networkCallbackForLowerApiLevels, filter) + } + + fun stopListening() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + networkCallbackForHigherApiLevels?.let { connectivityManager.unregisterNetworkCallback(it) } + } else { + networkCallbackForLowerApiLevels?.let { context.unregisterReceiver(it) } + } + } catch (e: IllegalArgumentException) { + // callback was already unregistered. + } catch (e: IllegalStateException) { + // shutdown process is in progress and certain operations are not allowed. + } + } +} diff --git a/android/src/test/java/com/amplitude/android/AmplitudeRobolectricTests.kt b/android/src/test/java/com/amplitude/android/AmplitudeRobolectricTests.kt index aa9cdce6..7cd43a07 100644 --- a/android/src/test/java/com/amplitude/android/AmplitudeRobolectricTests.kt +++ b/android/src/test/java/com/amplitude/android/AmplitudeRobolectricTests.kt @@ -2,6 +2,7 @@ package com.amplitude.android import android.app.Application import android.content.Context +import android.net.ConnectivityManager import com.amplitude.core.events.BaseEvent import com.amplitude.core.utilities.ConsoleLoggerProvider import com.amplitude.id.IMIdentityStorageProvider @@ -23,6 +24,7 @@ import kotlin.io.path.absolutePathString class AmplitudeRobolectricTests { private lateinit var amplitude: Amplitude private var context: Context? = null + private lateinit var connectivityManager: ConnectivityManager var tempDir = TempDirectory() @@ -30,8 +32,9 @@ class AmplitudeRobolectricTests { @Before fun setup() { context = mockk(relaxed = true) + connectivityManager = mockk(relaxed = true) every { context!!.getDir(any(), any()) } returns File(tempDir.create("data").absolutePathString()) - + every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager amplitude = Amplitude(createConfiguration()) } diff --git a/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt b/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt index 7aded7e6..9e066ccf 100644 --- a/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt +++ b/android/src/test/java/com/amplitude/android/AmplitudeSessionTest.kt @@ -1,6 +1,8 @@ package com.amplitude.android import android.app.Application +import android.content.Context +import android.net.ConnectivityManager import com.amplitude.android.plugins.AndroidLifecyclePlugin import com.amplitude.common.android.AndroidContextProvider import com.amplitude.core.Storage @@ -64,6 +66,8 @@ class AmplitudeSessionTest { private fun createConfiguration(storageProvider: StorageProvider? = null): Configuration { val context = mockk(relaxed = true) + var connectivityManager = mockk(relaxed = true) + every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager return Configuration( apiKey = "api-key", diff --git a/android/src/test/java/com/amplitude/android/AmplitudeTest.kt b/android/src/test/java/com/amplitude/android/AmplitudeTest.kt index 3faf6fcb..4e3120b3 100644 --- a/android/src/test/java/com/amplitude/android/AmplitudeTest.kt +++ b/android/src/test/java/com/amplitude/android/AmplitudeTest.kt @@ -2,6 +2,7 @@ package com.amplitude.android import android.app.Application import android.content.Context +import android.net.ConnectivityManager import com.amplitude.analytics.connector.AnalyticsConnector import com.amplitude.analytics.connector.Identity import com.amplitude.android.plugins.AndroidLifecyclePlugin @@ -38,10 +39,14 @@ open class StubPlugin : EventPlugin { class AmplitudeTest { private var context: Context? = null private var amplitude: Amplitude? = null + private lateinit var connectivityManager: ConnectivityManager @BeforeEach fun setUp() { context = mockk(relaxed = true) + connectivityManager = mockk(relaxed = true) + every { context!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + mockkStatic(AndroidLifecyclePlugin::class) mockkConstructor(AndroidContextProvider::class) 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 c30999d0..4cdb4e19 100644 --- a/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt +++ b/android/src/test/java/com/amplitude/android/plugins/AndroidLifecyclePluginTest.kt @@ -2,10 +2,12 @@ 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 @@ -44,6 +46,7 @@ class AndroidLifecyclePluginTest { private val mockedContext = mockk(relaxed = true) private var mockedPackageManager: PackageManager + private lateinit var connectivityManager: ConnectivityManager init { val packageInfo = PackageInfo() @@ -82,6 +85,9 @@ class AndroidLifecyclePluginTest { every { anyConstructed().mostRecentLocation } returns null every { anyConstructed().appSetId } returns "" + connectivityManager = mockk(relaxed = true) + every { mockedContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + configuration = Configuration( apiKey = "api-key", context = mockedContext, diff --git a/android/src/test/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPluginTest.kt b/android/src/test/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPluginTest.kt new file mode 100644 index 00000000..f7df5d5d --- /dev/null +++ b/android/src/test/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPluginTest.kt @@ -0,0 +1,61 @@ +package com.amplitude.android.plugins + +import android.app.Application +import com.amplitude.android.Configuration +import com.amplitude.core.Amplitude +import com.amplitude.core.utilities.ConsoleLoggerProvider +import com.amplitude.core.utilities.InMemoryStorageProvider +import com.amplitude.id.IMIdentityStorageProvider +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull + +class AndroidNetworkConnectivityCheckerPluginTest { + + private lateinit var amplitude: Amplitude + private lateinit var plugin: AndroidNetworkConnectivityCheckerPlugin + + private val context = mockk(relaxed = true) + + @Before + fun setup() { + amplitude = Amplitude( + Configuration( + apiKey = "api-key", + context = context, + storageProvider = InMemoryStorageProvider(), + loggerProvider = ConsoleLoggerProvider(), + identifyInterceptStorageProvider = InMemoryStorageProvider(), + identityStorageProvider = IMIdentityStorageProvider(), + trackingSessionEvents = false, + ) + ) + plugin = AndroidNetworkConnectivityCheckerPlugin() + } + + @Test + fun `should set up correctly by default`() { + // amplitude.configuration.offline defaults to false + plugin.setup(amplitude) + assertEquals(amplitude, plugin.amplitude) + assertNotNull(plugin.networkConnectivityChecker) + // Unit tests are run on JVM so default to online + assertEquals(false, amplitude.configuration.offline) + assertNotNull(plugin.networkListener) + } + + @Test + fun `should teardown correctly`() { + plugin.setup(amplitude) + assertNotNull(plugin.networkListener) + plugin.networkListener?.let { networkListener -> + spyk(networkListener) + plugin.teardown() + verify { networkListener.stopListening() } + } + } +} diff --git a/android/src/test/java/com/amplitude/android/utilities/AndroidNetworkConnectivityCheckerTest.kt b/android/src/test/java/com/amplitude/android/utilities/AndroidNetworkConnectivityCheckerTest.kt new file mode 100644 index 00000000..6f860d06 --- /dev/null +++ b/android/src/test/java/com/amplitude/android/utilities/AndroidNetworkConnectivityCheckerTest.kt @@ -0,0 +1,71 @@ +package com.amplitude.android.utilities + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import com.amplitude.common.Logger +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AndroidNetworkConnectivityCheckerTest { + + private lateinit var context: Context + private lateinit var connectivityManager: ConnectivityManager + private lateinit var networkCapabilities: NetworkCapabilities + private lateinit var networkInfo: NetworkInfo + private lateinit var logger: Logger + private lateinit var networkConnectivityChecker: AndroidNetworkConnectivityChecker + + @BeforeEach + fun setUp() { + context = mockk() + connectivityManager = mockk() + networkCapabilities = mockk() + networkInfo = mockk() + logger = mockk(relaxed = true) + + every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + every { context.checkCallingOrSelfPermission("android.permission.ACCESS_NETWORK_STATE") } returns 0 + networkConnectivityChecker = AndroidNetworkConnectivityChecker(context, logger) + } + + @Test + fun `should return true when connected to network on devices with API 23 and above`() { + every { connectivityManager.activeNetwork } returns mockk() + every { connectivityManager.getNetworkCapabilities(any()) } returns networkCapabilities + every { networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true + networkConnectivityChecker.isMarshmallowAndAbove = true + + assertTrue(networkConnectivityChecker.isConnected()) + } + + @Test + fun `should return false when not connected to network on devices with API 23 and above`() { + every { connectivityManager.activeNetwork } returns null + networkConnectivityChecker.isMarshmallowAndAbove = true + + assertFalse(networkConnectivityChecker.isConnected()) + } + + @Test + fun `should return true when connected to network devices with API lower than 23`() { + every { connectivityManager.activeNetworkInfo } returns networkInfo + every { networkInfo.isConnectedOrConnecting } returns true + networkConnectivityChecker.isMarshmallowAndAbove = false + + assertTrue(networkConnectivityChecker.isConnected()) + } + + @Test + fun `should return false when not connected to network devices with API lower than 23`() { + every { connectivityManager.activeNetworkInfo } returns null + networkConnectivityChecker.isMarshmallowAndAbove = false + + assertFalse(networkConnectivityChecker.isConnected()) + } +} diff --git a/build.gradle b/build.gradle index adf51f03..04bbf1f2 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,7 @@ allprojects{ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { - freeCompilerArgs = ['-Xjvm-default=enable'] + freeCompilerArgs = ['-Xjvm-default=all'] jvmTarget = "1.8" } } diff --git a/core/src/main/java/com/amplitude/core/Configuration.kt b/core/src/main/java/com/amplitude/core/Configuration.kt index 0fa82754..29b20beb 100644 --- a/core/src/main/java/com/amplitude/core/Configuration.kt +++ b/core/src/main/java/com/amplitude/core/Configuration.kt @@ -30,6 +30,7 @@ open class Configuration @JvmOverloads constructor( open var identifyBatchIntervalMillis: Long = IDENTIFY_BATCH_INTERVAL_MILLIS, open var identifyInterceptStorageProvider: StorageProvider = InMemoryStorageProvider(), open var identityStorageProvider: IdentityStorageProvider = IMIdentityStorageProvider(), + open var offline: Boolean? = false, ) { companion object { diff --git a/core/src/main/java/com/amplitude/core/platform/EventPipeline.kt b/core/src/main/java/com/amplitude/core/platform/EventPipeline.kt index 71d948d6..ebcc232a 100644 --- a/core/src/main/java/com/amplitude/core/platform/EventPipeline.kt +++ b/core/src/main/java/com/amplitude/core/platform/EventPipeline.kt @@ -99,6 +99,11 @@ class EventPipeline( } } + // Skip flush when offline + if (amplitude.configuration.offline == true) { + continue + } + // if flush condition met, generate paths if (eventCount.incrementAndGet() >= getFlushCount() || triggerFlush) { eventCount.set(0) diff --git a/core/src/main/java/com/amplitude/core/platform/Plugin.kt b/core/src/main/java/com/amplitude/core/platform/Plugin.kt index 2178103f..557cf2ac 100644 --- a/core/src/main/java/com/amplitude/core/platform/Plugin.kt +++ b/core/src/main/java/com/amplitude/core/platform/Plugin.kt @@ -25,6 +25,10 @@ interface Plugin { fun execute(event: BaseEvent): BaseEvent? { return event } + + fun teardown() { + // Clean up any resources from setup if necessary + } } interface EventPlugin : Plugin { diff --git a/core/src/main/java/com/amplitude/core/platform/Timeline.kt b/core/src/main/java/com/amplitude/core/platform/Timeline.kt index 7b913a74..aebe3fea 100644 --- a/core/src/main/java/com/amplitude/core/platform/Timeline.kt +++ b/core/src/main/java/com/amplitude/core/platform/Timeline.kt @@ -42,7 +42,10 @@ open class Timeline { fun remove(plugin: Plugin) { // remove all plugins with this name in every category plugins.forEach { (_, list) -> - list.remove(plugin) + val wasRemoved = list.remove(plugin) + if (wasRemoved) { + plugin.teardown() + } } } diff --git a/core/src/test/kotlin/com/amplitude/core/platform/EventPipelineTest.kt b/core/src/test/kotlin/com/amplitude/core/platform/EventPipelineTest.kt new file mode 100644 index 00000000..8d60a984 --- /dev/null +++ b/core/src/test/kotlin/com/amplitude/core/platform/EventPipelineTest.kt @@ -0,0 +1,86 @@ +package com.amplitude.core.platform + +import com.amplitude.core.Amplitude +import com.amplitude.core.Configuration +import com.amplitude.core.State +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.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@ExperimentalCoroutinesApi +class EventPipelineTest { + private lateinit var amplitude: Amplitude + private lateinit var testScope: TestScope + private lateinit var testDispatcher: TestDispatcher + private val config = Configuration( + apiKey = "API_KEY", + flushIntervalMillis = 1, + storageProvider = InMemoryStorageProvider(), + loggerProvider = ConsoleLoggerProvider(), + identifyInterceptStorageProvider = InMemoryStorageProvider(), + identityStorageProvider = IMIdentityStorageProvider(), + ) + + @BeforeEach + fun setup() { + testDispatcher = StandardTestDispatcher() + testScope = TestScope(testDispatcher) + amplitude = Amplitude(config, State(), testScope, testDispatcher, testDispatcher, testDispatcher, testDispatcher) + } + + @Test + fun `should not flush when put and offline`() = + runTest(testDispatcher) { + amplitude.isBuilt.await() + amplitude.configuration.offline = true + val eventPipeline = spyk(EventPipeline(amplitude)) + val event = BaseEvent().apply { eventType = "test_event" } + + eventPipeline.start() + eventPipeline.put(event) + advanceUntilIdle() + + verify(exactly = 0) { eventPipeline.flush() } + } + + @Test + fun `should flush when put and online`() = + runTest(testDispatcher) { + amplitude.isBuilt.await() + amplitude.configuration.offline = false + val eventPipeline = spyk(EventPipeline(amplitude)) + val event = BaseEvent().apply { eventType = "test_event" } + + eventPipeline.start() + eventPipeline.put(event) + advanceUntilIdle() + + verify(exactly = 1) { eventPipeline.flush() } + } + + @Test + fun `should flush when put and offline is disabled`() = + runTest(testDispatcher) { + amplitude.isBuilt.await() + amplitude.configuration.offline = null + val eventPipeline = spyk(EventPipeline(amplitude)) + val event = BaseEvent().apply { eventType = "test_event" } + + eventPipeline.start() + eventPipeline.put(event) + advanceUntilIdle() + + verify(exactly = 1) { eventPipeline.flush() } + } +}