Skip to content

Commit 6b6b262

Browse files
committed
Create nav state of activity to activity navigation
1 parent 58ce8e9 commit 6b6b262

File tree

15 files changed

+604
-1
lines changed

15 files changed

+604
-1
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
plugins {
2+
id("embrace-prod-android-conventions")
3+
}
4+
5+
description = "Embrace Android SDK: Navigation State Instrumentation"
6+
7+
android {
8+
namespace = "io.embrace.android.embracesdk.instrumentation.navigation"
9+
}
10+
11+
dependencies {
12+
implementation(project(":embrace-android-instrumentation-api"))
13+
testImplementation(project(":embrace-android-instrumentation-api-fakes"))
14+
testImplementation(libs.robolectric)
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import android.app.Activity
4+
import android.app.Application
5+
import android.os.Build
6+
import android.os.Bundle
7+
import io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationEvent.ActivityStarted
8+
import java.util.concurrent.atomic.AtomicInteger
9+
10+
internal class ActivityNavigationTracker(
11+
private val onEvent: (NavigationEvent) -> Unit,
12+
) : Application.ActivityLifecycleCallbacks {
13+
14+
/**
15+
* A ref count of how many activities have been started and not stopped. If this number is greater than one it means there
16+
* is still an activity that is visible.
17+
*/
18+
private var startedActivityCount = AtomicInteger(0)
19+
private val usePrePostCallbacks = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
20+
21+
override fun onActivityPreStarted(activity: Activity) {
22+
if (usePrePostCallbacks) {
23+
handleActivityStarted(activity)
24+
}
25+
}
26+
27+
override fun onActivityStarted(activity: Activity) {
28+
if (!usePrePostCallbacks) {
29+
handleActivityStarted(activity)
30+
}
31+
}
32+
33+
override fun onActivityPostStopped(activity: Activity) {
34+
if (usePrePostCallbacks) {
35+
checkForBackgrounded()
36+
}
37+
}
38+
39+
override fun onActivityStopped(activity: Activity) {
40+
startedActivityCount.decrementAndGet()
41+
if (!usePrePostCallbacks) {
42+
checkForBackgrounded()
43+
}
44+
}
45+
46+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
47+
override fun onActivityResumed(activity: Activity) {}
48+
override fun onActivityPaused(activity: Activity) {}
49+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
50+
override fun onActivityDestroyed(activity: Activity) {}
51+
52+
private fun handleActivityStarted(activity: Activity) {
53+
startedActivityCount.incrementAndGet()
54+
onEvent(ActivityStarted(activity.localClassName))
55+
}
56+
57+
private fun checkForBackgrounded() {
58+
if (startedActivityCount.get() <= 0) {
59+
onEvent(NavigationEvent.Backgrounded)
60+
}
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
/**
4+
* Typed events representing navigation-related signals from various sources
5+
*/
6+
internal sealed class NavigationEvent(
7+
val name: String
8+
) {
9+
/**
10+
* An Activity is about to be started.
11+
*/
12+
data class ActivityStarted(private var activityName: String) : NavigationEvent(name = activityName)
13+
14+
/**
15+
* The app has backgrounded, i.e. it has no visible activities
16+
*/
17+
object Backgrounded : NavigationEvent("Backgrounded")
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import java.util.concurrent.ConcurrentLinkedQueue
4+
import java.util.concurrent.atomic.AtomicBoolean
5+
import java.util.concurrent.atomic.AtomicReference
6+
7+
/**
8+
* Receives instance of [NavigationEvent] from various sources, processes them serially, and update the
9+
*/
10+
internal class NavigationEventBroker(
11+
private val onScreenLoad: (newScreenName: String) -> Unit,
12+
) {
13+
private val eventQueue = ConcurrentLinkedQueue<NavigationEvent>()
14+
private val isProcessing = AtomicBoolean(false)
15+
private val lastEvent = AtomicReference<NavigationEvent>(null)
16+
17+
fun queueEvent(event: NavigationEvent) {
18+
eventQueue.add(event)
19+
processEvents()
20+
}
21+
22+
private fun processEvents() {
23+
if (isProcessing.compareAndSet(false, true)) {
24+
try {
25+
var event = eventQueue.poll()
26+
while (event != null) {
27+
processEvent(event)
28+
event = eventQueue.poll()
29+
}
30+
} finally {
31+
isProcessing.set(false)
32+
}
33+
34+
if (eventQueue.isNotEmpty()) {
35+
processEvents()
36+
}
37+
}
38+
}
39+
40+
private fun processEvent(event: NavigationEvent) {
41+
if (event != lastEvent.getAndSet(event)) {
42+
onScreenLoad(event.name)
43+
}
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import io.embrace.android.embracesdk.internal.arch.InstrumentationArgs
4+
import io.embrace.android.embracesdk.internal.arch.datasource.StateDataSource
5+
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState
6+
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState.Screen
7+
8+
/**
9+
* Tracks the user's navigation state in the app
10+
*/
11+
class NavigationStateDataSource(
12+
private val args: InstrumentationArgs,
13+
) : StateDataSource<Screen>(
14+
args = args,
15+
stateTypeFactory = ::NavigationState,
16+
defaultValue = Screen(name = INITIALIZING),
17+
maxTransitions = MAX_NAVIGATION_STATE_TRANSITIONS,
18+
) {
19+
private val broker = NavigationEventBroker(::onScreenLoad)
20+
private val activityNavigationTracker = ActivityNavigationTracker(broker::queueEvent)
21+
22+
override fun onDataCaptureEnabled() {
23+
super.onDataCaptureEnabled()
24+
args.application.registerActivityLifecycleCallbacks(activityNavigationTracker)
25+
}
26+
27+
override fun onDataCaptureDisabled() {
28+
args.application.unregisterActivityLifecycleCallbacks(activityNavigationTracker)
29+
}
30+
31+
fun onScreenLoad(screenName: String) {
32+
onStateChange(clock.now(), Screen(name = screenName))
33+
}
34+
35+
companion object {
36+
private const val INITIALIZING = "Initializing"
37+
private const val MAX_NAVIGATION_STATE_TRANSITIONS = 1000
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import io.embrace.android.embracesdk.internal.arch.InstrumentationArgs
4+
import io.embrace.android.embracesdk.internal.arch.datasource.StateInstrumentationProvider
5+
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState.Screen
6+
7+
class NavigationStateInstrumentationProvider :
8+
StateInstrumentationProvider<NavigationStateDataSource, Screen>(
9+
configGate = {
10+
configService.autoDataCaptureBehavior.isNavigationStateCaptureEnabled()
11+
},
12+
) {
13+
override fun factoryProvider(args: InstrumentationArgs): () -> NavigationStateDataSource {
14+
return { NavigationStateDataSource(args) }
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
io.embrace.android.embracesdk.internal.instrumentation.navigation.NavigationStateInstrumentationProvider
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import android.app.Activity
4+
import android.os.Build
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Before
8+
import org.junit.Test
9+
import org.junit.runner.RunWith
10+
import org.robolectric.Robolectric
11+
import org.robolectric.RuntimeEnvironment
12+
import org.robolectric.android.controller.ActivityController
13+
import org.robolectric.annotation.Config
14+
15+
@RunWith(AndroidJUnit4::class)
16+
internal class ActivityNavigationTrackerTest {
17+
18+
private lateinit var events: MutableList<NavigationEvent>
19+
private lateinit var tracker: ActivityNavigationTracker
20+
private lateinit var activityController: ActivityController<DopeActivity>
21+
private lateinit var anotherController: ActivityController<CoolActivity>
22+
23+
@Before
24+
fun setUp() {
25+
events = mutableListOf()
26+
tracker = ActivityNavigationTracker(events::add)
27+
RuntimeEnvironment.getApplication().registerActivityLifecycleCallbacks(tracker)
28+
activityController = Robolectric.buildActivity(DopeActivity::class.java).create()
29+
anotherController = Robolectric.buildActivity(CoolActivity::class.java).create()
30+
}
31+
32+
@Test
33+
@Config(sdk = [Build.VERSION_CODES.P])
34+
fun `transition from start to background to foreground produce the right events in P`() {
35+
simulateActivityTransition()
36+
}
37+
38+
@Test
39+
@Config(sdk = [Build.VERSION_CODES.Q])
40+
fun `transition from start to background to foreground produce the right events in Q`() {
41+
simulateActivityTransition()
42+
}
43+
44+
private fun simulateActivityTransition() {
45+
activityController.start()
46+
assertEquals(1, events.size)
47+
assertEquals(NavigationEvent.ActivityStarted(activityController.get().localClassName), events.last())
48+
activityController.resume()
49+
assertEquals(1, events.size)
50+
activityController.pause()
51+
assertEquals(1, events.size)
52+
anotherController.start()
53+
assertEquals(2, events.size)
54+
assertEquals(NavigationEvent.ActivityStarted(anotherController.get().localClassName), events.last())
55+
anotherController.resume()
56+
activityController.stop()
57+
anotherController.pause()
58+
assertEquals(2, events.size)
59+
anotherController.stop()
60+
assertEquals(3, events.size)
61+
assertEquals(NavigationEvent.Backgrounded, events.last())
62+
}
63+
64+
private class DopeActivity : Activity()
65+
private class CoolActivity : Activity()
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Before
5+
import org.junit.Test
6+
import java.util.concurrent.CopyOnWriteArrayList
7+
import java.util.concurrent.CountDownLatch
8+
import java.util.concurrent.TimeUnit
9+
10+
internal class NavigationEventBrokerTest {
11+
private val states = CopyOnWriteArrayList<String>()
12+
private lateinit var broker: NavigationEventBroker
13+
14+
@Before
15+
fun setUp() {
16+
states.clear()
17+
broker = NavigationEventBroker { route ->
18+
states.add(route)
19+
}
20+
}
21+
22+
@Test
23+
fun `events processed serially and in order`() {
24+
val latch = CountDownLatch(1)
25+
broker.queueEvent(NavigationEvent.ActivityStarted("home"))
26+
broker.queueEvent(NavigationEvent.ActivityStarted("settings"))
27+
Thread {
28+
Thread.sleep(20)
29+
broker.queueEvent(NavigationEvent.Backgrounded)
30+
latch.countDown()
31+
}.start()
32+
latch.await(1, TimeUnit.SECONDS)
33+
broker.queueEvent(NavigationEvent.ActivityStarted("profile"))
34+
35+
assertEquals(listOf("home", "settings", NavigationEvent.Backgrounded.name, "profile"), states)
36+
}
37+
38+
@Test
39+
fun `duplicate events are dropped`() {
40+
broker.queueEvent(NavigationEvent.ActivityStarted("home"))
41+
broker.queueEvent(NavigationEvent.ActivityStarted("home"))
42+
broker.queueEvent(NavigationEvent.Backgrounded)
43+
broker.queueEvent(NavigationEvent.Backgrounded)
44+
45+
assertEquals(listOf("home", NavigationEvent.Backgrounded.name), states)
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.embrace.android.embracesdk.internal.instrumentation.navigation
2+
3+
import androidx.test.core.app.ApplicationProvider
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import io.embrace.android.embracesdk.fakes.FakeInstrumentationArgs
6+
import io.embrace.android.embracesdk.internal.arch.schema.SchemaType.NavigationState.Screen
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Before
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
12+
@RunWith(AndroidJUnit4::class)
13+
internal class NavigationStateDataSourceTest {
14+
private lateinit var dataSource: NavigationStateDataSource
15+
private lateinit var args: FakeInstrumentationArgs
16+
17+
@Before
18+
fun setUp() {
19+
args = FakeInstrumentationArgs(ApplicationProvider.getApplicationContext())
20+
dataSource = NavigationStateDataSource(args)
21+
}
22+
23+
@Test
24+
fun `state updated when notified of new screen load`() {
25+
assertEquals(Screen("Initializing"), dataSource.getCurrentStateValue())
26+
dataSource.onScreenLoad("home")
27+
assertEquals(Screen("home"), dataSource.getCurrentStateValue())
28+
dataSource.onScreenLoad("settings")
29+
assertEquals(Screen("settings"), dataSource.getCurrentStateValue())
30+
}
31+
}

0 commit comments

Comments
 (0)