For #18836: add StartupPathProvider + tests.
parent
ec65737cbb
commit
a64540bd06
@ -0,0 +1,107 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.perf
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import androidx.annotation.VisibleForTesting.NONE
|
||||||
|
import androidx.annotation.VisibleForTesting.PRIVATE
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "path" that this activity started in. See the
|
||||||
|
* [Fenix perf glossary](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary)
|
||||||
|
* for specific definitions.
|
||||||
|
*
|
||||||
|
* This should be a member variable of [Activity] because its data is tied to the lifecycle of an
|
||||||
|
* Activity. Call [attachOnActivityOnCreate] & [onIntentReceived] for this class to work correctly.
|
||||||
|
*/
|
||||||
|
class StartupPathProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path the application took to
|
||||||
|
* [Fenix perf glossary](https://wiki.mozilla.org/index.php?title=Performance/Fenix/Glossary)
|
||||||
|
* for specific definitions.
|
||||||
|
*/
|
||||||
|
enum class StartupPath {
|
||||||
|
MAIN,
|
||||||
|
VIEW,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start up path if we received an Intent but we're unable to categorize it into other buckets.
|
||||||
|
*/
|
||||||
|
UNKNOWN,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start up path has not been set. This state includes:
|
||||||
|
* - this API is accessed before it is set
|
||||||
|
* - if no intent is received before the activity is STARTED (e.g. app switcher)
|
||||||
|
*/
|
||||||
|
NOT_SET
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [StartupPath] for the currently started activity. This value will be set
|
||||||
|
* after an [Intent] is received that causes this activity to move into the STARTED state.
|
||||||
|
*/
|
||||||
|
var startupPathForActivity = StartupPath.NOT_SET
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var wasResumedSinceStartedState = false
|
||||||
|
|
||||||
|
fun attachOnActivityOnCreate(lifecycle: Lifecycle, intent: Intent?) {
|
||||||
|
lifecycle.addObserver(StartupPathLifecycleObserver())
|
||||||
|
onIntentReceived(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// N.B.: this method duplicates the actual logic for determining what page to open.
|
||||||
|
// Unfortunately, it's difficult to re-use that logic because it occurs in many places throughout
|
||||||
|
// the code so we do the simple thing for now and duplicate it. It's noticeably different from
|
||||||
|
// what you might expect: e.g. ACTION_MAIN can open a URL and if ACTION_VIEW provides an invalid
|
||||||
|
// URL, it'll perform a MAIN action. However, it's fairly representative of what users *intended*
|
||||||
|
// to do when opening the app and shouldn't change much because it's based on Android system-wide
|
||||||
|
// conventions, so it's probably fine for our purposes.
|
||||||
|
private fun getStartupPathFromIntent(intent: Intent): StartupPath = when (intent.action) {
|
||||||
|
Intent.ACTION_MAIN -> StartupPath.MAIN
|
||||||
|
Intent.ACTION_VIEW -> StartupPath.VIEW
|
||||||
|
else -> StartupPath.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expected to be called when a new [Intent] is received by the [Activity]: i.e.
|
||||||
|
* [Activity.onCreate] and [Activity.onNewIntent].
|
||||||
|
*/
|
||||||
|
fun onIntentReceived(intent: Intent?) {
|
||||||
|
// We want to set a path only if the intent causes the Activity to move into the STARTED state.
|
||||||
|
// This means we want to discard any intents that are received when the app is foregrounded.
|
||||||
|
// However, we can't use the Lifecycle.currentState to determine this because:
|
||||||
|
// - the app is briefly paused (state becomes STARTED) before receiving the Intent in
|
||||||
|
// the foreground so we can't say <= STARTED
|
||||||
|
// - onIntentReceived can be called from the CREATED or STARTED state so we can't say == CREATED
|
||||||
|
// So we're forced to track this state ourselves.
|
||||||
|
if (!wasResumedSinceStartedState && intent != null) {
|
||||||
|
startupPathForActivity = getStartupPathFromIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = NONE)
|
||||||
|
fun getTestCallbacks() = StartupPathLifecycleObserver()
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = PRIVATE)
|
||||||
|
inner class StartupPathLifecycleObserver : DefaultLifecycleObserver {
|
||||||
|
override fun onResume(owner: LifecycleOwner) {
|
||||||
|
wasResumedSinceStartedState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
|
// Clear existing state.
|
||||||
|
startupPathForActivity = StartupPath.NOT_SET
|
||||||
|
wasResumedSinceStartedState = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,203 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.perf
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import io.mockk.MockKAnnotations
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.impl.annotations.MockK
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.spyk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.perf.StartupPathProvider.StartupPath
|
||||||
|
|
||||||
|
class StartupPathProviderTest {
|
||||||
|
|
||||||
|
private lateinit var provider: StartupPathProvider
|
||||||
|
private lateinit var callbacks: StartupPathProvider.StartupPathLifecycleObserver
|
||||||
|
|
||||||
|
@MockK private lateinit var intent: Intent
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
MockKAnnotations.init(this)
|
||||||
|
|
||||||
|
provider = StartupPathProvider()
|
||||||
|
callbacks = provider.getTestCallbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN attach is called THEN the provider is registered to the lifecycle`() {
|
||||||
|
val lifecycle = mockk<Lifecycle>(relaxed = true)
|
||||||
|
provider.attachOnActivityOnCreate(lifecycle, null)
|
||||||
|
|
||||||
|
verify { lifecycle.addObserver(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN calling attach THEN the intent is passed to on intent received`() {
|
||||||
|
// With this test, we're basically saying, "attach..." does the same thing as
|
||||||
|
// "onIntentReceived" so we don't need to duplicate all the tests we run for
|
||||||
|
// "onIntentReceived".
|
||||||
|
val spyProvider = spyk(provider)
|
||||||
|
every { spyProvider.onIntentReceived(intent) } returns Unit
|
||||||
|
spyProvider.attachOnActivityOnCreate(mockk(relaxed = true), intent)
|
||||||
|
|
||||||
|
verify { spyProvider.onIntentReceived(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no intent is received and the activity is not started WHEN getting the start up path THEN it is not set`() {
|
||||||
|
assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a main intent is received but the activity is not started yet WHEN getting the start up path THEN main is returned`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
provider.onIntentReceived(intent)
|
||||||
|
assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a main intent is received and the app is started WHEN getting the start up path THEN it is main`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
callbacks.onCreate(mockk())
|
||||||
|
provider.onIntentReceived(intent)
|
||||||
|
callbacks.onStart(mockk())
|
||||||
|
|
||||||
|
assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched from the homescreen WHEN getting the start up path THEN it is main`() {
|
||||||
|
// There's technically more to a homescreen Intent but it's fine for now.
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched by app link WHEN getting the start up path THEN it is view`() {
|
||||||
|
// There's technically more to a homescreen Intent but it's fine for now.
|
||||||
|
every { intent.action } returns Intent.ACTION_VIEW
|
||||||
|
launchApp(intent)
|
||||||
|
assertEquals(StartupPath.VIEW, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched by a send action WHEN getting the start up path THEN it is unknown`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_SEND
|
||||||
|
launchApp(intent)
|
||||||
|
assertEquals(StartupPath.UNKNOWN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched by a null intent (is this possible) WHEN getting the start up path THEN it is not set`() {
|
||||||
|
callbacks.onCreate(mockk())
|
||||||
|
provider.onIntentReceived(null)
|
||||||
|
callbacks.onStart(mockk())
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
|
||||||
|
assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched to the homescreen and stopped WHEN getting the start up path THEN it is not set`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
stopLaunchedApp()
|
||||||
|
|
||||||
|
assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched to the homescreen, stopped, and relaunched warm from app link WHEN getting the start up path THEN it is view`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
stopLaunchedApp()
|
||||||
|
|
||||||
|
every { intent.action } returns Intent.ACTION_VIEW
|
||||||
|
startStoppedApp(intent)
|
||||||
|
|
||||||
|
assertEquals(StartupPath.VIEW, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched to the homescreen, stopped, and relaunched warm from the app switcher WHEN getting the start up path THEN it is not set`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
stopLaunchedApp()
|
||||||
|
startStoppedAppFromAppSwitcher()
|
||||||
|
|
||||||
|
assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched to the homescreen, paused, and resumed WHEN getting the start up path THEN it returns the initial intent value`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
callbacks.onPause(mockk())
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
|
||||||
|
assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched with an intent and receives an intent while the activity is foregrounded WHEN getting the start up path THEN it returns the initial intent value`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
every { intent.action } returns Intent.ACTION_VIEW
|
||||||
|
receiveIntentInForeground(intent)
|
||||||
|
|
||||||
|
assertEquals(StartupPath.MAIN, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the app is launched, stopped, started from the app switcher and receives an intent in the foreground WHEN getting the start up path THEN it returns not set`() {
|
||||||
|
every { intent.action } returns Intent.ACTION_MAIN
|
||||||
|
launchApp(intent)
|
||||||
|
stopLaunchedApp()
|
||||||
|
startStoppedAppFromAppSwitcher()
|
||||||
|
every { intent.action } returns Intent.ACTION_VIEW
|
||||||
|
receiveIntentInForeground(intent)
|
||||||
|
|
||||||
|
assertEquals(StartupPath.NOT_SET, provider.startupPathForActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchApp(intent: Intent) {
|
||||||
|
callbacks.onCreate(mockk())
|
||||||
|
provider.onIntentReceived(intent)
|
||||||
|
callbacks.onStart(mockk())
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLaunchedApp() {
|
||||||
|
callbacks.onPause(mockk())
|
||||||
|
callbacks.onStop(mockk())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startStoppedApp(intent: Intent) {
|
||||||
|
callbacks.onStart(mockk())
|
||||||
|
provider.onIntentReceived(intent)
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startStoppedAppFromAppSwitcher() {
|
||||||
|
// What makes the app switcher case special is it starts the app without an intent.
|
||||||
|
callbacks.onStart(mockk())
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiveIntentInForeground(intent: Intent) {
|
||||||
|
// To my surprise, the app is paused before receiving an intent on Pixel 2.
|
||||||
|
callbacks.onPause(mockk())
|
||||||
|
provider.onIntentReceived(intent)
|
||||||
|
callbacks.onResume(mockk())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue