forked from mozilla-mobile/fenix
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
For mozilla-mobile#18836: add StartupPathProvider + tests.
- Loading branch information
Showing
4 changed files
with
317 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
107 changes: 107 additions & 0 deletions
107
app/src/main/java/org/mozilla/fenix/perf/StartupPathProvider.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} |
203 changes: 203 additions & 0 deletions
203
app/src/test/java/org/mozilla/fenix/perf/StartupPathProviderTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) | ||
} | ||
} |