For #26555 - Observe and update the wallpaper before HomeScreen is visible.

By using Store.observeManually in a standalone coroutine we can observe the
store and update the wallpapers even before onStart (in manual tests is right
around onStart, certainly before the other widgets shown on homescreen).

Created a new WallpapersObserver to have the functionality easier to reason
about and be easier to test.
This commit is contained in:
Mugurell 2022-08-25 19:20:23 +03:00 committed by mergify[bot]
parent d314c1102b
commit ab3f6b5e4b
4 changed files with 348 additions and 46 deletions

View File

@ -45,9 +45,7 @@ import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import mozilla.components.browser.menu.view.MenuButton
import mozilla.components.browser.state.selector.findTab
@ -67,7 +65,6 @@ import mozilla.components.feature.top.sites.TopSitesFrecencyConfig
import mozilla.components.feature.top.sites.TopSitesProviderConfig
import mozilla.components.lib.state.ext.consumeFlow
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.lib.state.ext.flow
import mozilla.components.service.glean.private.NoExtras
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.content.res.resolveAttribute
@ -91,7 +88,6 @@ import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.scaleToBottomOfView
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gleanplumb.DefaultMessageController
import org.mozilla.fenix.gleanplumb.MessagingFeature
@ -119,7 +115,6 @@ import org.mozilla.fenix.tabstray.TabsTrayAccessPoint
import org.mozilla.fenix.utils.Settings.Companion.TOP_SITES_PROVIDER_MAX_THRESHOLD
import org.mozilla.fenix.utils.ToolbarPopupWindow
import org.mozilla.fenix.utils.allowUndo
import org.mozilla.fenix.wallpapers.Wallpaper
import java.lang.ref.WeakReference
import kotlin.math.min
@ -167,6 +162,8 @@ class HomeFragment : Fragment() {
private var sessionControlView: SessionControlView? = null
private var appBarLayout: AppBarLayout? = null
private lateinit var currentMode: CurrentMode
@VisibleForTesting
internal lateinit var wallpapersObserver: WallpapersObserver
private val topSitesFeature = ViewBoundFeatureWrapper<TopSitesFeature>()
private val messagingFeature = ViewBoundFeatureWrapper<MessagingFeature>()
@ -212,9 +209,15 @@ class HomeFragment : Fragment() {
val activity = activity as HomeActivity
val components = requireComponents
// See https://github.com/mozilla-mobile/fenix/issues/26555 for context as to why
// this is commented out for now
observeWallpaperChanges()
if (shouldEnableWallpaper()) {
wallpapersObserver = WallpapersObserver(
appStore = components.appStore,
wallpapersUseCases = components.useCases.wallpaperUseCases,
wallpaperImageView = binding.wallpaperImageView,
).also {
viewLifecycleOwner.lifecycle.addObserver(it)
}
}
currentMode = CurrentMode(
requireContext(),
@ -410,7 +413,17 @@ class HomeFragment : Fragment() {
super.onConfigurationChanged(newConfig)
getMenuButton()?.dismissMenu()
setWallpaperToCurrent()
if (shouldEnableWallpaper()) {
// Setting the wallpaper is a potentially expensive operation - can take 100ms.
// Running this on the Main thread helps to ensure that the just updated configuration
// will be used when the wallpaper is scaled to match.
// Otherwise the portrait wallpaper may remain shown on landscape,
// see https://github.com/mozilla-mobile/fenix/issues/26638
runBlockingIncrement {
wallpapersObserver.applyCurrentWallpaper()
}
}
}
/**
@ -927,43 +940,8 @@ class HomeFragment : Fragment() {
?.isVisible = tabCount > 0
}
@Suppress("UnusedPrivateMember")
private fun observeWallpaperChanges() {
if (shouldEnableWallpaper()) {
requireComponents.appStore.flow()
.ifChanged { state -> state.wallpaperState.currentWallpaper }
.onEach { state ->
showWallpaper(state.wallpaperState.currentWallpaper)
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
}
private fun setWallpaperToCurrent() {
if (shouldEnableWallpaper()) {
val wallpaper = requireComponents.appStore.state.wallpaperState.currentWallpaper
runBlockingIncrement {
showWallpaper(wallpaper)
}
}
}
private suspend fun showWallpaper(wallpaper: Wallpaper) = when (wallpaper) {
// We only want to update the wallpaper when it's different from the default one
// as the default is applied already on xml by default.
Wallpaper.Default -> {
binding.wallpaperImageView.visibility = View.GONE
}
else -> {
val bitmap = requireComponents.useCases.wallpaperUseCases.loadBitmap(wallpaper)
bitmap?.let {
it.scaleToBottomOfView(binding.wallpaperImageView)
binding.wallpaperImageView.visibility = View.VISIBLE
}
}
}
private fun shouldEnableWallpaper() =
@VisibleForTesting
internal fun shouldEnableWallpaper() =
(activity as? HomeActivity)?.themeManager?.currentTheme?.isPrivate?.not() ?: false
companion object {

View File

@ -0,0 +1,96 @@
/* 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.home
import android.widget.ImageView
import androidx.annotation.VisibleForTesting
import androidx.core.view.isVisible
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import mozilla.components.lib.state.Store
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.ext.scaleToBottomOfView
import org.mozilla.fenix.wallpapers.Wallpaper
import org.mozilla.fenix.wallpapers.WallpapersUseCases
/**
* [LifecycleObserver] that will immediately start observing the store for wallpapers updates
* to apply them to the passed in [wallpaperImageView] and automatically stop observing for updates
* when the [LifecycleOwner] is destroyed.
*
* @param appStore Holds the details abut the current wallpaper.
* @param wallpapersUseCases Used for interacting with the wallpaper feature.
* @param wallpaperImageView Serves as the target when applying wallpapers.
*/
class WallpapersObserver(
private val appStore: AppStore,
private val wallpapersUseCases: WallpapersUseCases,
private val wallpaperImageView: ImageView,
) : DefaultLifecycleObserver {
@VisibleForTesting
internal var observeWallpapersStoreSubscription: Store.Subscription<AppState, AppAction>? = null
@VisibleForTesting
internal var wallpapersScope = CoroutineScope(Dispatchers.IO)
init {
observeWallpaperUpdates()
}
/**
* Immediately apply the current wallpaper automatically adjusted to support
* the current configuration - portrait or landscape.
*/
suspend fun applyCurrentWallpaper() {
showWallpaper()
}
override fun onDestroy(owner: LifecycleOwner) {
observeWallpapersStoreSubscription?.unsubscribe()
wallpapersScope.cancel()
}
@VisibleForTesting
internal fun observeWallpaperUpdates() {
var lastObservedValue: Wallpaper? = null
observeWallpapersStoreSubscription = appStore.observeManually { state ->
val currentValue = state.wallpaperState.currentWallpaper
// Use the wallpaper name to differentiate between updates to properly support
// the restored from settings wallpaper being the same as the one downloaded
// case in which details like "collection" may be different.
if (currentValue.name != lastObservedValue?.name) {
lastObservedValue = currentValue
wallpapersScope.launch { showWallpaper(currentValue) }
}
}.also {
it.resume()
}
}
@VisibleForTesting
internal suspend fun showWallpaper(wallpaper: Wallpaper = appStore.state.wallpaperState.currentWallpaper) {
when (wallpaper) {
// We only want to update the wallpaper when it's different from the default one
// as the default is applied already on xml by default.
Wallpaper.Default -> {
wallpaperImageView.isVisible = false
}
else -> {
val bitmap = wallpapersUseCases.loadBitmap(wallpaper)
bitmap?.let {
it.scaleToBottomOfView(wallpaperImageView)
wallpaperImageView.isVisible = true
}
}
}
}
}

View File

@ -5,11 +5,13 @@
package org.mozilla.fenix.home
import android.content.Context
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import mozilla.components.browser.menu.view.MenuButton
import mozilla.components.browser.state.state.SearchState
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
@ -21,6 +23,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.FenixApplication
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.components.Core
import org.mozilla.fenix.ext.application
import org.mozilla.fenix.ext.components
@ -103,4 +106,54 @@ class HomeFragmentTest {
verify(exactly = 1) { menuButton.dismissMenu() }
}
@Test
fun `GIVEN the user is in normal mode WHEN configuration changes THEN the wallpaper is reapplied`() = runTest {
homeFragment.getMenuButton = { null }
val observer: WallpapersObserver = mockk(relaxed = true)
homeFragment.wallpapersObserver = observer
val activity: HomeActivity = mockk {
every { themeManager.currentTheme.isPrivate } returns false
}
every { homeFragment.activity } returns activity
homeFragment.onConfigurationChanged(mockk(relaxed = true))
coVerify { observer.applyCurrentWallpaper() }
}
@Test
fun `GIVEN the user is in private mode WHEN configuration changes THEN the wallpaper not updated`() = runTest {
homeFragment.getMenuButton = { null }
val observer: WallpapersObserver = mockk(relaxed = true)
homeFragment.wallpapersObserver = observer
val activity: HomeActivity = mockk {
every { themeManager.currentTheme.isPrivate } returns true
}
every { homeFragment.activity } returns activity
homeFragment.onConfigurationChanged(mockk(relaxed = true))
coVerify(exactly = 0) { observer.applyCurrentWallpaper() }
}
@Test
fun `GIVEN the user is in normal mode WHEN checking if should enable wallpaper THEN return true`() {
val activity: HomeActivity = mockk {
every { themeManager.currentTheme.isPrivate } returns false
}
every { homeFragment.activity } returns activity
assertTrue(homeFragment.shouldEnableWallpaper())
}
@Test
fun `GIVEN the user is in private mode WHEN checking if should enable wallpaper THEN return false`() {
val activity: HomeActivity = mockk {
every { themeManager.currentTheme.isPrivate } returns true
}
every { homeFragment.activity } returns activity
assertFalse(homeFragment.shouldEnableWallpaper())
}
}

View File

@ -0,0 +1,175 @@
/* 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.home
import android.graphics.Bitmap
import android.widget.ImageView
import androidx.core.view.isVisible
import io.mockk.Called
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.cancel
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.WallpaperAction.UpdateCurrentWallpaper
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.wallpapers.Wallpaper
import org.mozilla.fenix.wallpapers.WallpapersUseCases
@RunWith(FenixRobolectricTestRunner::class)
class WallpapersObserverTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `WHEN the observer is created THEN start observing the store`() {
val appStore: AppStore = mockk(relaxed = true) {
every { observeManually(any()) } answers { mockk(relaxed = true) }
}
val observer = WallpapersObserver(appStore, mockk(), mockk())
assertNotNull(observer.observeWallpapersStoreSubscription)
}
@Test
fun `WHEN asked to apply the wallpaper THEN show it`() = runTestOnMain {
val appStore = AppStore()
val observer = spyk(WallpapersObserver(appStore, mockk(), mockk())) {
coEvery { showWallpaper(any()) } just Runs
}
observer.applyCurrentWallpaper()
coVerify { observer.showWallpaper(any()) }
}
@Test
fun `GIVEN the store was observed for updates WHEN the lifecycle owner is destroyed THEN stop observing the store`() {
val observer = WallpapersObserver(mockk(relaxed = true), mockk(), mockk())
observer.observeWallpapersStoreSubscription = mockk(relaxed = true)
observer.wallpapersScope = mockk {
every { cancel() } just Runs
}
observer.onDestroy(mockk())
verify { observer.wallpapersScope.cancel() }
verify { observer.observeWallpapersStoreSubscription!!.unsubscribe() }
}
@Test
fun `WHEN the wallpaper is updated THEN show the wallpaper`() = runTestOnMain {
val appStore = AppStore()
val observer = spyk(WallpapersObserver(appStore, mockk(relaxed = true), mockk(relaxed = true))) {
coEvery { showWallpaper(any()) } just Runs
}
// Ignore the call on the real instance and call again "observeWallpaperUpdates"
// on the spy to be able to verify the "showWallpaper" call in the spy.
observer.observeWallpaperUpdates()
val newWallpaper: Wallpaper = mockk(relaxed = true)
appStore.dispatch(UpdateCurrentWallpaper(newWallpaper))
appStore.waitUntilIdle()
coVerify { observer.showWallpaper(newWallpaper) }
}
@Test
fun `WHEN the wallpaper is updated to a new one THEN show the wallpaper`() = runTestOnMain {
val appStore = AppStore()
val wallpapersUseCases: WallpapersUseCases = mockk {
coEvery { loadBitmap(any()) } returns null
}
val observer = spyk(WallpapersObserver(appStore, wallpapersUseCases, mockk(relaxed = true))) {
coEvery { showWallpaper(any()) } just Runs
}
// Ignore the call on the real instance and call again "observeWallpaperUpdates"
// on the spy to be able to verify the "showWallpaper" call in the spy.
observer.observeWallpaperUpdates()
coVerify { observer.showWallpaper(Wallpaper.Default) }
val wallpaper: Wallpaper = mockk(relaxed = true)
appStore.dispatch(UpdateCurrentWallpaper(wallpaper))
appStore.waitUntilIdle()
coVerify { observer.showWallpaper(wallpaper) }
}
@Test
fun `WHEN the wallpaper is updated to the current one THEN don't try showing the same wallpaper again`() = runTestOnMain {
val appStore = AppStore()
val wallpapersUseCases: WallpapersUseCases = mockk {
coEvery { loadBitmap(any()) } returns null
}
val observer = spyk(WallpapersObserver(appStore, wallpapersUseCases, mockk(relaxed = true))) {
coEvery { showWallpaper(any()) } just Runs
}
// Ignore the call on the real instance and call again "observeWallpaperUpdates"
// on the spy to be able to verify the "showWallpaper" call in the spy.
observer.observeWallpaperUpdates()
val wallpaper: Wallpaper = mockk(relaxed = true)
appStore.dispatch(UpdateCurrentWallpaper(wallpaper))
appStore.waitUntilIdle()
coVerify { observer.showWallpaper(wallpaper) }
appStore.dispatch(UpdateCurrentWallpaper(wallpaper))
appStore.waitUntilIdle()
coVerify(exactly = 1) { observer.showWallpaper(wallpaper) }
}
@Test
fun `GIVEN no wallpaper is provided WHEN asked to show the wallpaper THEN show the current one`() = runTestOnMain {
val wallpaper: Wallpaper = mockk()
val appStore: AppStore = mockk(relaxed = true) {
every { state.wallpaperState.currentWallpaper } returns wallpaper
}
val observer = spyk(WallpapersObserver(appStore, mockk(relaxed = true), mockk(relaxed = true)))
observer.showWallpaper()
coVerify { observer.showWallpaper(wallpaper) }
}
fun `GiVEN the current wallpaper is the default one WHEN showing it THEN hide the wallpaper view`() = runTestOnMain {
val wallpapersUseCases: WallpapersUseCases = mockk()
val wallpaperView: ImageView = mockk(relaxed = true)
val observer = WallpapersObserver(mockk(relaxed = true), wallpapersUseCases, wallpaperView)
observer.showWallpaper(Wallpaper.Default)
verify { wallpaperView.isVisible = false }
verify { wallpapersUseCases wasNot Called }
}
@Test
fun `GiVEN the current wallpaper is different than the default one WHEN showing it THEN load it's bitmap in the visible wallpaper view`() = runTestOnMain {
val wallpaper: Wallpaper = mockk()
val bitmap: Bitmap = mockk()
val wallpapersUseCases: WallpapersUseCases = mockk {
coEvery { loadBitmap(any()) } returns bitmap
}
val wallpaperView: ImageView = mockk(relaxed = true)
val observer = WallpapersObserver(mockk(relaxed = true), wallpapersUseCases, wallpaperView)
observer.showWallpaper(wallpaper)
verify { wallpaperView.isVisible = true }
verify { wallpaperView.setImageBitmap(bitmap) }
}
}