Skip to content

fix: decouple app lifecycle from sessions autocapture #284

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import android.content.pm.PackageManager
import android.os.Bundle
import androidx.annotation.VisibleForTesting
import com.amplitude.android.AutocaptureOption
import com.amplitude.android.AutocaptureOption.APP_LIFECYCLES
import com.amplitude.android.AutocaptureOption.DEEP_LINKS
import com.amplitude.android.AutocaptureOption.ELEMENT_INTERACTIONS
import com.amplitude.android.AutocaptureOption.SCREEN_VIEWS
import com.amplitude.android.Configuration
import com.amplitude.android.ExperimentalAmplitudeFeature
import com.amplitude.android.utilities.ActivityCallbackType
Expand All @@ -26,7 +30,7 @@ class AndroidLifecyclePlugin(
override lateinit var amplitude: Amplitude
private lateinit var packageInfo: PackageInfo
private lateinit var androidAmplitude: AndroidAmplitude
private lateinit var androidConfiguration: Configuration
private lateinit var autocapture: Set<AutocaptureOption>

private val created: MutableSet<Int> = mutableSetOf()
private val started: MutableSet<Int> = mutableSetOf()
Expand All @@ -39,11 +43,12 @@ class AndroidLifecyclePlugin(
override fun setup(amplitude: Amplitude) {
super.setup(amplitude)
androidAmplitude = amplitude as AndroidAmplitude
androidConfiguration = amplitude.configuration as Configuration
val androidConfiguration = amplitude.configuration as Configuration
autocapture = androidConfiguration.autocapture

val application = androidConfiguration.context as Application

if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture) {
if (APP_LIFECYCLES in autocapture) {
packageInfo = try {
application.packageManager.getPackageInfo(application.packageName, 0)
} catch (e: PackageManager.NameNotFoundException) {
Expand All @@ -53,21 +58,21 @@ class AndroidLifecyclePlugin(
}

DefaultEventUtils(androidAmplitude).trackAppUpdatedInstalledEvent(packageInfo)
}

eventJob = amplitude.amplitudeScope.launch(Dispatchers.Main) {
for (event in activityLifecycleObserver.eventChannel) {
event.activity.get()?.let { activity ->
when (event.type) {
ActivityCallbackType.Created -> onActivityCreated(
activity,
activity.intent?.extras
)
ActivityCallbackType.Started -> onActivityStarted(activity)
ActivityCallbackType.Resumed -> onActivityResumed(activity)
ActivityCallbackType.Paused -> onActivityPaused(activity)
ActivityCallbackType.Stopped -> onActivityStopped(activity)
ActivityCallbackType.Destroyed -> onActivityDestroyed(activity)
}
eventJob = amplitude.amplitudeScope.launch(Dispatchers.Main) {
for (event in activityLifecycleObserver.eventChannel) {
event.activity.get()?.let { activity ->
when (event.type) {
ActivityCallbackType.Created -> onActivityCreated(
activity,
activity.intent?.extras
)
ActivityCallbackType.Started -> onActivityStarted(activity)
ActivityCallbackType.Resumed -> onActivityResumed(activity)
ActivityCallbackType.Paused -> onActivityPaused(activity)
ActivityCallbackType.Stopped -> onActivityStopped(activity)
ActivityCallbackType.Destroyed -> onActivityDestroyed(activity)
}
}
}
Expand All @@ -77,7 +82,7 @@ class AndroidLifecyclePlugin(
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {
created.add(activity.hashCode())

if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) {
if (SCREEN_VIEWS in autocapture) {
DefaultEventUtils(androidAmplitude).startFragmentViewedEventTracking(activity)
}
}
Expand All @@ -89,19 +94,19 @@ class AndroidLifecyclePlugin(
}
started.add(activity.hashCode())

if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && started.size == 1) {
if (APP_LIFECYCLES in autocapture && started.size == 1) {
DefaultEventUtils(androidAmplitude).trackAppOpenedEvent(
packageInfo = packageInfo,
isFromBackground = appInBackground
)
appInBackground = false
}

if (AutocaptureOption.DEEP_LINKS in androidConfiguration.autocapture) {
if (DEEP_LINKS in autocapture) {
DefaultEventUtils(androidAmplitude).trackDeepLinkOpenedEvent(activity)
}

if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) {
if (SCREEN_VIEWS in autocapture) {
DefaultEventUtils(androidAmplitude).trackScreenViewedEvent(activity)
}
}
Expand All @@ -110,7 +115,7 @@ class AndroidLifecyclePlugin(
androidAmplitude.onEnterForeground(getCurrentTimeMillis())

@OptIn(ExperimentalAmplitudeFeature::class)
if (AutocaptureOption.ELEMENT_INTERACTIONS in androidConfiguration.autocapture) {
if (ELEMENT_INTERACTIONS in autocapture) {
DefaultEventUtils(androidAmplitude).startUserInteractionEventTracking(activity)
}
}
Expand All @@ -119,15 +124,15 @@ class AndroidLifecyclePlugin(
androidAmplitude.onExitForeground(getCurrentTimeMillis())

@OptIn(ExperimentalAmplitudeFeature::class)
if (AutocaptureOption.ELEMENT_INTERACTIONS in androidConfiguration.autocapture) {
if (ELEMENT_INTERACTIONS in autocapture) {
DefaultEventUtils(androidAmplitude).stopUserInteractionEventTracking(activity)
}
}

override fun onActivityStopped(activity: Activity) {
started.remove(activity.hashCode())

if (AutocaptureOption.APP_LIFECYCLES in androidConfiguration.autocapture && started.isEmpty()) {
if (APP_LIFECYCLES in autocapture && started.isEmpty()) {
DefaultEventUtils(androidAmplitude).trackAppBackgroundedEvent()
appInBackground = true
}
Expand All @@ -139,7 +144,7 @@ class AndroidLifecyclePlugin(
override fun onActivityDestroyed(activity: Activity) {
created.remove(activity.hashCode())

if (AutocaptureOption.SCREEN_VIEWS in androidConfiguration.autocapture) {
if (SCREEN_VIEWS in autocapture) {
DefaultEventUtils(androidAmplitude).stopFragmentViewedEventTracking(activity)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File

Expand Down Expand Up @@ -57,6 +61,11 @@ class AmplitudeSessionTest {
)
}

@BeforeEach
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}

@Test
fun amplitude_closeBackgroundEventsShouldNotStartNewSession() = runTest {
val amplitude = createFakeAmplitude(
Expand Down
11 changes: 11 additions & 0 deletions android/src/test/kotlin/com/amplitude/android/AmplitudeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlin.concurrent.thread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File

Expand All @@ -44,6 +49,7 @@ internal class FakeEventPlugin : EventPlugin {

@ExperimentalCoroutinesApi
class AmplitudeTest {

private fun createConfiguration(
minTimeBetweenSessionsMillis: Long? = null,
storageProvider: StorageProvider = InMemoryStorageProvider(),
Expand Down Expand Up @@ -88,6 +94,11 @@ class AmplitudeTest {
return configuration
}

@BeforeEach
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}

@Test
fun amplitude_reset_wipesUserIdDeviceId() = runTest {
val amplitude = createFakeAmplitude(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ class AndroidLifecyclePluginTest {
mockkObject(FragmentActivityHandler)
}

@Test
fun `test eventJob is created even if APP_LIFECYCLES is not enabled`() = runTest {
every { mockedConfig.autocapture } returns emptySet()
every { mockedAmplitude.amplitudeScope } returns this

plugin.setup(mockedAmplitude)

advanceUntilIdle()

assert(
plugin.eventJob != null
) { "eventJob should be created even if APP_LIFECYCLES is not enabled" }

close()
}

@Test
fun `test application installed event is tracked`() = runTest {
every { mockedConfig.autocapture } returns setOf(AutocaptureOption.APP_LIFECYCLES)
Expand Down