[fenix] For https://github.com/mozilla-mobile/fenix/issues/22722 - Reacting to the crashed flag
Whenever the ".crashed" property of the currently displayed TabSessionState -> EngineState is true we will show an in-app crash reporter with the usual close tab / restore tab options and also the option to report all current non-fatal crashes to Mozilla if the setting for sending the crash reports is enabled in app settings. This closely mimics the previous crash reporter UI but there might be some subtle differences stemming from migrating to using a ComposeView. Whenever the ".crashed" property of the currently displayed TabSessionState -> EngineState is false we will set the in-app crash reporter to have a View.GONE visibility effectively removing it from the layout. The functionality for receiving the non-fatal crashes from the AC CrashReporter through an Intent is still kept and these crashes will be persisted in memory until the user closes / restores a tab and so also makes a decision about sending or not these crashes. Currently more tabs can crash following just one since more share the same process and as such there is no way to differentiate between them or link a certain Crash to a certain tab. They will all be acted upon at once from any tab the user chooses to close or restore.pull/600/head
parent
80f59b6543
commit
bb85952d10
@ -0,0 +1,88 @@
|
|||||||
|
/* 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.crashes
|
||||||
|
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab
|
||||||
|
import mozilla.components.browser.state.selector.normalTabs
|
||||||
|
import mozilla.components.browser.state.selector.privateTabs
|
||||||
|
import mozilla.components.browser.state.state.BrowserState
|
||||||
|
import mozilla.components.browser.state.state.EngineState
|
||||||
|
import mozilla.components.browser.state.store.BrowserStore
|
||||||
|
import mozilla.components.browser.toolbar.BrowserToolbar
|
||||||
|
import mozilla.components.lib.state.helpers.AbstractBinding
|
||||||
|
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
|
||||||
|
import org.mozilla.fenix.components.AppStore
|
||||||
|
import org.mozilla.fenix.components.Components
|
||||||
|
import org.mozilla.fenix.utils.Settings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for observing [BrowserStore] and show an in-app crash reporter for tabs with content crashes.
|
||||||
|
*
|
||||||
|
* @param browserStore [BrowserStore] observed for any changes related to [EngineState.crashed].
|
||||||
|
* @param appStore [AppStore] that tracks all content crashes in the current app session until the user
|
||||||
|
* decides to either send or dismiss all crash reports.
|
||||||
|
* @param toolbar [BrowserToolbar] that will be expanded when showing the in-app crash reporter.
|
||||||
|
* @param isToolbarPlacedAtTop [Boolean] based allowing the in-app crash reporter to be shown as
|
||||||
|
* immediately below or above the toolbar.
|
||||||
|
* @param crashReporterView [CrashReporterFragment] which will be shown if the current tab is marked as crashed.
|
||||||
|
* @param components [Components] allowing interactions with other app features.
|
||||||
|
* @param settings [Settings] allowing to check whether crash reporting is enabled or not.
|
||||||
|
* @param navController [NavController] used to navigate to other parts of the app.
|
||||||
|
* @param sessionId [String] Id of the tab or custom tab which should be observed for [EngineState.crashed]
|
||||||
|
* depending on which [crashReporterView] will be shown or hidden.
|
||||||
|
*/
|
||||||
|
class CrashContentIntegration(
|
||||||
|
private val browserStore: BrowserStore,
|
||||||
|
private val appStore: AppStore,
|
||||||
|
private val toolbar: BrowserToolbar,
|
||||||
|
private val isToolbarPlacedAtTop: Boolean,
|
||||||
|
private val crashReporterView: CrashReporterFragment,
|
||||||
|
private val components: Components,
|
||||||
|
private val settings: Settings,
|
||||||
|
private val navController: NavController,
|
||||||
|
private val sessionId: String?
|
||||||
|
) : AbstractBinding<BrowserState>(browserStore) {
|
||||||
|
override suspend fun onState(flow: Flow<BrowserState>) {
|
||||||
|
flow.mapNotNull { state -> state.findTabOrCustomTabOrSelectedTab(sessionId) }
|
||||||
|
.ifChanged { tab -> tab.engineState.crashed }
|
||||||
|
.collect { tab ->
|
||||||
|
if (tab.engineState.crashed) {
|
||||||
|
toolbar.expand()
|
||||||
|
|
||||||
|
crashReporterView.apply {
|
||||||
|
val controller = CrashReporterController(
|
||||||
|
sessionId = tab.id,
|
||||||
|
currentNumberOfTabs = if (tab.content.private) {
|
||||||
|
browserStore.state.privateTabs.size
|
||||||
|
} else {
|
||||||
|
browserStore.state.normalTabs.size
|
||||||
|
},
|
||||||
|
components = components,
|
||||||
|
settings = settings,
|
||||||
|
navController = navController,
|
||||||
|
appStore = appStore
|
||||||
|
)
|
||||||
|
|
||||||
|
show(controller)
|
||||||
|
|
||||||
|
with(layoutParams as MarginLayoutParams) {
|
||||||
|
if (isToolbarPlacedAtTop) {
|
||||||
|
topMargin = toolbar.height
|
||||||
|
} else {
|
||||||
|
bottomMargin = toolbar.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
crashReporterView.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,72 @@
|
|||||||
|
/* 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.components.appstate
|
||||||
|
|
||||||
|
import io.mockk.mockk
|
||||||
|
import mozilla.components.lib.crash.Crash.NativeCodeCrash
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.components.appstate.AppAction.AddNonFatalCrash
|
||||||
|
import org.mozilla.fenix.components.appstate.AppAction.RemoveAllNonFatalCrashes
|
||||||
|
import org.mozilla.fenix.components.appstate.AppAction.RemoveNonFatalCrash
|
||||||
|
import org.mozilla.fenix.components.appstate.AppAction.UpdateInactiveExpanded
|
||||||
|
|
||||||
|
class AppStoreReducerTest {
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a new value for inactiveTabsExpanded WHEN UpdateInactiveExpanded is called THEN update the current value`() {
|
||||||
|
val initialState = AppState(
|
||||||
|
inactiveTabsExpanded = true
|
||||||
|
)
|
||||||
|
|
||||||
|
var updatedState = AppStoreReducer.reduce(initialState, UpdateInactiveExpanded(false))
|
||||||
|
assertFalse(updatedState.inactiveTabsExpanded)
|
||||||
|
|
||||||
|
updatedState = AppStoreReducer.reduce(updatedState, UpdateInactiveExpanded(true))
|
||||||
|
assertTrue(updatedState.inactiveTabsExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a Crash WHEN AddNonFatalCrash is called THEN add that Crash to the current list`() {
|
||||||
|
val initialState = AppState()
|
||||||
|
val crash1: NativeCodeCrash = mockk()
|
||||||
|
val crash2: NativeCodeCrash = mockk()
|
||||||
|
|
||||||
|
var updatedState = AppStoreReducer.reduce(initialState, AddNonFatalCrash(crash1))
|
||||||
|
assertTrue(listOf(crash1).containsAll(updatedState.nonFatalCrashes))
|
||||||
|
|
||||||
|
updatedState = AppStoreReducer.reduce(updatedState, AddNonFatalCrash(crash2))
|
||||||
|
assertTrue(listOf(crash1, crash2).containsAll(updatedState.nonFatalCrashes))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a Crash WHEN RemoveNonFatalCrash is called THEN remove that Crash from the current list`() {
|
||||||
|
val crash1: NativeCodeCrash = mockk()
|
||||||
|
val crash2: NativeCodeCrash = mockk()
|
||||||
|
val initialState = AppState(
|
||||||
|
nonFatalCrashes = listOf(crash1, crash2)
|
||||||
|
)
|
||||||
|
|
||||||
|
var updatedState = AppStoreReducer.reduce(initialState, RemoveNonFatalCrash(crash1))
|
||||||
|
assertTrue(listOf(crash2).containsAll(updatedState.nonFatalCrashes))
|
||||||
|
|
||||||
|
updatedState = AppStoreReducer.reduce(updatedState, RemoveNonFatalCrash(mockk()))
|
||||||
|
assertTrue(listOf(crash2).containsAll(updatedState.nonFatalCrashes))
|
||||||
|
|
||||||
|
updatedState = AppStoreReducer.reduce(updatedState, RemoveNonFatalCrash(crash2))
|
||||||
|
assertTrue(updatedState.nonFatalCrashes.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN crashes exist in State WHEN RemoveAllNonFatalCrashes is called THEN clear the current list of crashes`() {
|
||||||
|
val initialState = AppState(
|
||||||
|
nonFatalCrashes = listOf(mockk(), mockk())
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedState = AppStoreReducer.reduce(initialState, RemoveAllNonFatalCrashes)
|
||||||
|
|
||||||
|
assertTrue(updatedState.nonFatalCrashes.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
/* 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.crashes
|
||||||
|
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import mozilla.components.browser.state.action.CrashAction
|
||||||
|
import mozilla.components.browser.state.state.BrowserState
|
||||||
|
import mozilla.components.browser.state.state.createTab
|
||||||
|
import mozilla.components.browser.state.store.BrowserStore
|
||||||
|
import mozilla.components.browser.toolbar.BrowserToolbar
|
||||||
|
import mozilla.components.support.test.libstate.ext.waitUntilIdle
|
||||||
|
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.components.AppStore
|
||||||
|
import org.mozilla.fenix.components.Components
|
||||||
|
import org.mozilla.fenix.utils.Settings
|
||||||
|
|
||||||
|
class CrashContentIntegrationTest {
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@get:Rule
|
||||||
|
val coroutinesTestRule = MainCoroutineRule()
|
||||||
|
|
||||||
|
private val sessionId = "sessionId"
|
||||||
|
private lateinit var browserStore: BrowserStore
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
browserStore = BrowserStore(
|
||||||
|
BrowserState(
|
||||||
|
tabs = listOf(
|
||||||
|
createTab("url", id = sessionId)
|
||||||
|
),
|
||||||
|
selectedTabId = sessionId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a tab WHEN its content crashes THEN expand the toolbar and show the in-content crash reporter`() {
|
||||||
|
val crashReporterLayoutParams: MarginLayoutParams = mockk(relaxed = true)
|
||||||
|
val crashReporterView: CrashReporterFragment = mockk(relaxed = true) {
|
||||||
|
every { layoutParams } returns crashReporterLayoutParams
|
||||||
|
}
|
||||||
|
val toolbar: BrowserToolbar = mockk(relaxed = true) {
|
||||||
|
every { height } returns 33
|
||||||
|
}
|
||||||
|
val components: Components = mockk()
|
||||||
|
val settings: Settings = mockk()
|
||||||
|
val appStore: AppStore = mockk()
|
||||||
|
val integration = CrashContentIntegration(
|
||||||
|
browserStore = browserStore,
|
||||||
|
appStore = appStore,
|
||||||
|
toolbar = toolbar,
|
||||||
|
isToolbarPlacedAtTop = true,
|
||||||
|
crashReporterView = crashReporterView,
|
||||||
|
components = components,
|
||||||
|
settings = settings,
|
||||||
|
navController = mockk(),
|
||||||
|
sessionId = sessionId
|
||||||
|
)
|
||||||
|
val controllerCaptor = slot<CrashReporterController>()
|
||||||
|
integration.start()
|
||||||
|
browserStore.dispatch(CrashAction.SessionCrashedAction(sessionId))
|
||||||
|
browserStore.waitUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
toolbar.expand()
|
||||||
|
crashReporterLayoutParams.topMargin = 33
|
||||||
|
crashReporterView.show(capture(controllerCaptor))
|
||||||
|
}
|
||||||
|
assertEquals(sessionId, controllerCaptor.captured.sessionId)
|
||||||
|
assertEquals(components, controllerCaptor.captured.components)
|
||||||
|
assertEquals(settings, controllerCaptor.captured.settings)
|
||||||
|
assertEquals(appStore, controllerCaptor.captured.appStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a tab is marked as crashed WHEN the crashed state changes THEN hide the in-content crash reporter`() {
|
||||||
|
val crashReporterView: CrashReporterFragment = mockk(relaxed = true)
|
||||||
|
val integration = CrashContentIntegration(
|
||||||
|
browserStore = browserStore,
|
||||||
|
appStore = mockk(),
|
||||||
|
toolbar = mockk(),
|
||||||
|
isToolbarPlacedAtTop = true,
|
||||||
|
crashReporterView = crashReporterView,
|
||||||
|
components = mockk(),
|
||||||
|
settings = mockk(),
|
||||||
|
navController = mockk(),
|
||||||
|
sessionId = sessionId,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration.start()
|
||||||
|
browserStore.dispatch(CrashAction.RestoreCrashedSessionAction(sessionId))
|
||||||
|
browserStore.waitUntilIdle()
|
||||||
|
|
||||||
|
verify { crashReporterView.hide() }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
/* 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.crashes
|
||||||
|
|
||||||
|
import android.view.View.GONE
|
||||||
|
import android.view.View.VISIBLE
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.spyk
|
||||||
|
import io.mockk.verify
|
||||||
|
import mozilla.components.support.test.robolectric.testContext
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mozilla.fenix.R
|
||||||
|
import org.mozilla.fenix.crashes.CrashReporterFragment.Companion.TAP_INCREASE_DP
|
||||||
|
import org.mozilla.fenix.ext.increaseTapArea
|
||||||
|
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(FenixRobolectricTestRunner::class)
|
||||||
|
class CrashReporterFragmentTest {
|
||||||
|
@Test
|
||||||
|
fun `WHEN show is called THEN remember the controller, inflate and display the View`() {
|
||||||
|
val view = spyk(CrashReporterFragment(testContext))
|
||||||
|
val controller: CrashReporterController = mockk()
|
||||||
|
|
||||||
|
view.show(controller)
|
||||||
|
|
||||||
|
assertTrue(view.controller === controller)
|
||||||
|
verify {
|
||||||
|
view.inflateViewIfNecessary()
|
||||||
|
view.visibility = VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WHEN hide is called THEN remove the View from layout`() {
|
||||||
|
val view = spyk(CrashReporterFragment(testContext))
|
||||||
|
|
||||||
|
view.hide()
|
||||||
|
|
||||||
|
verify { view.visibility = GONE }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the View is not shown WHEN needing to be shown THEN inflate the layout and bind all widgets`() {
|
||||||
|
val controller: CrashReporterController = mockk(relaxed = true)
|
||||||
|
val view = CrashReporterFragment(testContext)
|
||||||
|
view.controller = controller
|
||||||
|
assertFalse(view.isBindingInitialized)
|
||||||
|
|
||||||
|
mockkStatic("org.mozilla.fenix.ext.ViewKt") {
|
||||||
|
view.inflateViewIfNecessary()
|
||||||
|
|
||||||
|
assertTrue(view.isBindingInitialized)
|
||||||
|
assertEquals(
|
||||||
|
testContext.getString(R.string.tab_crash_title_2, testContext.getString(R.string.app_name)),
|
||||||
|
view.binding.title.text
|
||||||
|
)
|
||||||
|
verify {
|
||||||
|
view.binding.restoreTabButton.increaseTapArea(TAP_INCREASE_DP)
|
||||||
|
view.binding.closeTabButton.increaseTapArea(TAP_INCREASE_DP)
|
||||||
|
}
|
||||||
|
|
||||||
|
view.binding.sendCrashCheckbox.isChecked = true
|
||||||
|
view.binding.restoreTabButton.callOnClick()
|
||||||
|
verify { controller.handleCloseAndRestore(true) }
|
||||||
|
|
||||||
|
view.binding.sendCrashCheckbox.isChecked = false
|
||||||
|
view.binding.closeTabButton.callOnClick()
|
||||||
|
verify { controller.handleCloseAndRemove(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the View is not shown WHEN needing to be shown THEN delegate the process to helper methods`() {
|
||||||
|
val view = spyk(CrashReporterFragment(testContext))
|
||||||
|
|
||||||
|
view.inflateViewIfNecessary()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
view.inflate()
|
||||||
|
view.bindViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN the View is to already shown WHEN needing to be shown again THEN return early and avoid duplicating the widgets setup`() {
|
||||||
|
val view = spyk(CrashReporterFragment(testContext))
|
||||||
|
view.inflate() // mock that the View is already inflated
|
||||||
|
|
||||||
|
view.inflateViewIfNecessary() // try inflating it again
|
||||||
|
|
||||||
|
verify(exactly = 1) { view.inflate() }
|
||||||
|
verify(exactly = 0) { view.bindViews() }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue