[fenix] For https://github.com/mozilla-mobile/fenix/issues/26511: Parallelize work for setting the wallpaper

Split loading the bitmap from storage and actually setting it in two operations
with one that can run in parallel with onCreateView for HomeFragment and one
that can be used serially on the main thread to actually set the wallpaper.

This seems like the best compromise to ensure that everytime the homescreen is
shown it will have the wallpaper set but does affect the performance - there is
a delay in showing HomeFragment to account for waiting for the wallpaper to be
set.
In testing the new delay seems close to the one from the initial wallpapers
implementation. See more in https://github.com/mozilla-mobile/fenix/pull/26794.
pull/600/head
Mugurell 2 years ago committed by mergify[bot]
parent 62ca4f4ca7
commit d6330b6dac

@ -213,6 +213,7 @@ class HomeFragment : Fragment() {
if (shouldEnableWallpaper()) { if (shouldEnableWallpaper()) {
wallpapersObserver = WallpapersObserver( wallpapersObserver = WallpapersObserver(
appStore = components.appStore, appStore = components.appStore,
settings = requireContext().settings(),
wallpapersUseCases = components.useCases.wallpaperUseCases, wallpapersUseCases = components.useCases.wallpaperUseCases,
wallpaperImageView = binding.wallpaperImageView, wallpaperImageView = binding.wallpaperImageView,
).also { ).also {
@ -412,7 +413,6 @@ class HomeFragment : Fragment() {
getMenuButton()?.dismissMenu() getMenuButton()?.dismissMenu()
if (shouldEnableWallpaper()) { 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 // Running this on the Main thread helps to ensure that the just updated configuration
// will be used when the wallpaper is scaled to match. // will be used when the wallpaper is scaled to match.
// Otherwise the portrait wallpaper may remain shown on landscape, // Otherwise the portrait wallpaper may remain shown on landscape,
@ -757,6 +757,12 @@ class HomeFragment : Fragment() {
lifecycleScope.launch(IO) { lifecycleScope.launch(IO) {
requireComponents.reviewPromptController.promptReview(requireActivity()) requireComponents.reviewPromptController.promptReview(requireActivity())
} }
if (shouldEnableWallpaper()) {
runBlockingIncrement {
wallpapersObserver?.applyCurrentWallpaper()
}
}
} }
private fun dispatchModeChanges(mode: Mode) { private fun dispatchModeChanges(mode: Mode) {

@ -4,21 +4,27 @@
package org.mozilla.fenix.home package org.mozilla.fenix.home
import android.graphics.Bitmap
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.lib.state.Store import mozilla.components.lib.state.Store
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.ext.scaleToBottomOfView import org.mozilla.fenix.ext.scaleToBottomOfView
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.wallpapers.Wallpaper import org.mozilla.fenix.wallpapers.Wallpaper
import org.mozilla.fenix.wallpapers.WallpapersUseCases import org.mozilla.fenix.wallpapers.WallpapersUseCases
@ -28,18 +34,44 @@ import org.mozilla.fenix.wallpapers.WallpapersUseCases
* when the [LifecycleOwner] is destroyed. * when the [LifecycleOwner] is destroyed.
* *
* @param appStore Holds the details abut the current wallpaper. * @param appStore Holds the details abut the current wallpaper.
* @param settings Used for checking user's option for what wallpaper to use.
* @param wallpapersUseCases Used for interacting with the wallpaper feature. * @param wallpapersUseCases Used for interacting with the wallpaper feature.
* @param wallpaperImageView Serves as the target when applying wallpapers. * @param wallpaperImageView Serves as the target when applying wallpapers.
* @param backgroundWorkDispatcher Used for scheduling the wallpaper update when the state is updated
* with a new wallpaper.
*/ */
class WallpapersObserver( class WallpapersObserver(
private val appStore: AppStore, private val appStore: AppStore,
private val settings: Settings,
private val wallpapersUseCases: WallpapersUseCases, private val wallpapersUseCases: WallpapersUseCases,
private val wallpaperImageView: ImageView, private val wallpaperImageView: ImageView,
backgroundWorkDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
@VisibleForTesting @VisibleForTesting
internal var observeWallpapersStoreSubscription: Store.Subscription<AppState, AppAction>? = null internal var observeWallpapersStoreSubscription: Store.Subscription<AppState, AppAction>? = null
/**
* Coroutine scope for updating the wallpapers when an update is observed.
* Allows for easy cleanup when the client of this is destroyed.
*/
@VisibleForTesting
internal var wallpapersScope = CoroutineScope(backgroundWorkDispatcher)
/**
* Setting the wallpaper assumes two steps:
* - load - running on IO
* - set - running on Main
* This property caches the result of [loadWallpaper] to be later used by [applyCurrentWallpaper].
*/
@Volatile
@VisibleForTesting @VisibleForTesting
internal var wallpapersScope = CoroutineScope(Dispatchers.Main.immediate) internal var currentWallpaperImage: Bitmap? = null
/**
* Listener for when the first observed wallpaper is loaded and available to be set.
*/
@VisibleForTesting
internal val isWallpaperLoaded = CompletableDeferred<Unit>()
init { init {
observeWallpaperUpdates() observeWallpaperUpdates()
@ -49,8 +81,20 @@ class WallpapersObserver(
* Immediately apply the current wallpaper automatically adjusted to support * Immediately apply the current wallpaper automatically adjusted to support
* the current configuration - portrait or landscape. * the current configuration - portrait or landscape.
*/ */
fun applyCurrentWallpaper() { internal suspend fun applyCurrentWallpaper() {
showWallpaper() isWallpaperLoaded.await()
withContext(Dispatchers.Main.immediate) {
with(currentWallpaperImage) {
when (this) {
null -> wallpaperImageView.isVisible = false
else -> {
scaleToBottomOfView(wallpaperImageView)
wallpaperImageView.isVisible = true
}
}
}
}
} }
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
@ -63,37 +107,42 @@ class WallpapersObserver(
var lastObservedValue: Wallpaper? = null var lastObservedValue: Wallpaper? = null
observeWallpapersStoreSubscription = appStore.observeManually { state -> observeWallpapersStoreSubscription = appStore.observeManually { state ->
val currentValue = state.wallpaperState.currentWallpaper val currentValue = state.wallpaperState.currentWallpaper
// Use the persisted wallpaper name to wait until a state update
// that contains the wallpaper that the user chose.
// Avoids setting the AppState default wallpaper if we know that another wallpaper is chosen.
if (currentValue.name != settings.currentWallpaperName) {
return@observeManually
}
// Use the wallpaper name to differentiate between updates to properly support // Use the wallpaper name to differentiate between updates to properly support
// the restored from settings wallpaper being the same as the one downloaded // the restored from settings wallpaper being the same as the one downloaded
// case in which details like "collection" may be different. // case in which details like "collection" may be different.
// Avoids setting the same wallpaper twice.
if (currentValue.name != lastObservedValue?.name) { if (currentValue.name != lastObservedValue?.name) {
lastObservedValue = currentValue lastObservedValue = currentValue
showWallpaper(currentValue) wallpapersScope.launch {
loadWallpaper(currentValue)
applyCurrentWallpaper()
}
} }
}.also { }.also {
it.resume() it.resume()
} }
} }
/**
* Load the bitmap of [wallpaper] and cache it in [currentWallpaperImage].
*/
@WorkerThread
@VisibleForTesting @VisibleForTesting
internal fun showWallpaper(wallpaper: Wallpaper = appStore.state.wallpaperState.currentWallpaper) { internal suspend fun loadWallpaper(wallpaper: Wallpaper) {
wallpapersScope.launch { currentWallpaperImage = when (wallpaper) {
when (wallpaper) { Wallpaper.Default -> null
// We only want to update the wallpaper when it's different from the default one else -> wallpapersUseCases.loadBitmap(wallpaper)
// 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
}
}
}
} }
isWallpaperLoaded.complete(Unit)
} }
} }

@ -7,177 +7,186 @@ package org.mozilla.fenix.home
import android.graphics.Bitmap import android.graphics.Bitmap
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import io.mockk.Called
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle 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.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertNull
import org.junit.Ignore import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.WallpaperAction.UpdateCurrentWallpaper import org.mozilla.fenix.components.appstate.AppAction.WallpaperAction.UpdateCurrentWallpaper
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.wallpapers.Wallpaper import org.mozilla.fenix.wallpapers.Wallpaper
import org.mozilla.fenix.wallpapers.WallpapersUseCases import org.mozilla.fenix.wallpapers.WallpapersUseCases
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class WallpapersObserverTest { class WallpapersObserverTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test @Test
fun `WHEN the observer is created THEN start observing the store`() { fun `WHEN the observer is created THEN start observing the store`() {
val appStore: AppStore = mockk(relaxed = true) { val appStore: AppStore = mockk(relaxed = true) {
every { observeManually(any()) } answers { mockk(relaxed = true) } every { observeManually(any()) } answers { mockk(relaxed = true) }
} }
val observer = WallpapersObserver(appStore, mockk(), mockk()) val observer = getObserver(appStore)
assertNotNull(observer.observeWallpapersStoreSubscription) assertNotNull(observer.observeWallpapersStoreSubscription)
} }
@Test @Test
fun `WHEN asked to apply the wallpaper THEN show it`() { fun `GIVEN a certain wallpaper is chosen WHEN the state is updated with that wallpaper THEN load it it`() = runTestOnMain {
val appStore = AppStore() val appStore = AppStore()
val observer = spyk(WallpapersObserver(appStore, mockk(), mockk())) { val settings: Settings = mockk { every { currentWallpaperName } returns "Test" }
every { showWallpaper(any()) } just Runs val wallpaper: Wallpaper = mockk { every { name } returns "Test" }
} val observer = spyk(
getObserver(appStore, settings, mockk(relaxed = true), mockk(relaxed = true)),
observer.applyCurrentWallpaper() )
verify { 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()) // 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()
appStore.dispatch(UpdateCurrentWallpaper(wallpaper)).joinBlocking()
appStore.waitUntilIdle()
verify { observer.wallpapersScope.cancel() } coVerify { observer.loadWallpaper(any()) }
verify { observer.observeWallpapersStoreSubscription!!.unsubscribe() } coVerify { observer.applyCurrentWallpaper() }
} }
@Test @Test
fun `WHEN the wallpaper is updated THEN show the wallpaper`() { fun `GIVEN a certain wallpaper is chosen WHEN the state updated with another wallpaper THEN avoid loading it`() = runTestOnMain {
val appStore = AppStore() val appStore = AppStore()
val observer = spyk(WallpapersObserver(appStore, mockk(relaxed = true), mockk(relaxed = true))) { val settings: Settings = mockk { every { currentWallpaperName } returns "Test" }
every { showWallpaper(any()) } just Runs val otherWallpaper: Wallpaper = mockk { every { name } returns "Other" }
} val observer = spyk(
getObserver(appStore, settings, mockk(relaxed = true), mockk(relaxed = true)),
)
// Ignore the call on the real instance and call again "observeWallpaperUpdates" // 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. // on the spy to be able to verify the "showWallpaper" call in the spy.
observer.observeWallpaperUpdates() observer.observeWallpaperUpdates()
appStore.dispatch(UpdateCurrentWallpaper(otherWallpaper)).joinBlocking()
val newWallpaper: Wallpaper = mockk(relaxed = true)
appStore.dispatch(UpdateCurrentWallpaper(newWallpaper))
appStore.waitUntilIdle() appStore.waitUntilIdle()
verify { observer.showWallpaper(newWallpaper) }
coVerify(exactly = 0) { observer.loadWallpaper(any()) }
coVerify(exactly = 0) { observer.applyCurrentWallpaper() }
} }
@Test @Test
@Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/26760") fun `GIVEN a wallpaper is SHOWN WHEN the wallpaper is updated to the current one THEN don't try showing the same wallpaper again`() {
fun `WHEN the wallpaper is updated to a new one THEN show the wallpaper`() {
val appStore = AppStore() val appStore = AppStore()
val wallpapersUseCases: WallpapersUseCases = mockk { val settings: Settings = mockk { every { currentWallpaperName } returns "Test" }
coEvery { loadBitmap(any()) } returns null val wallpaper: Wallpaper = mockk { every { name } returns "Test" }
} val wallpapersUseCases: WallpapersUseCases = mockk { coEvery { loadBitmap(any()) } returns null }
val observer = spyk(WallpapersObserver(appStore, wallpapersUseCases, mockk(relaxed = true))) { val observer = spyk(getObserver(appStore, settings, wallpapersUseCases, mockk(relaxed = true))) {
every { showWallpaper(any()) } just Runs coEvery { loadWallpaper(any()) } just Runs
coEvery { applyCurrentWallpaper() } just Runs
} }
// Ignore the call on the real instance and call again "observeWallpaperUpdates" // 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. // on the spy to be able to verify the "showWallpaper" call in the spy.
observer.observeWallpaperUpdates() observer.observeWallpaperUpdates()
verify { observer.showWallpaper(Wallpaper.Default) } appStore.dispatch(UpdateCurrentWallpaper(wallpaper)).joinBlocking()
appStore.waitUntilIdle()
coVerify(exactly = 1) { observer.loadWallpaper(any()) }
coVerify(exactly = 1) { observer.applyCurrentWallpaper() }
val wallpaper: Wallpaper = mockk(relaxed = true) appStore.dispatch(UpdateCurrentWallpaper(wallpaper)).joinBlocking()
appStore.dispatch(UpdateCurrentWallpaper(wallpaper))
appStore.waitUntilIdle() appStore.waitUntilIdle()
verify { observer.showWallpaper(wallpaper) } coVerify(exactly = 1) { observer.loadWallpaper(any()) }
coVerify(exactly = 1) { observer.applyCurrentWallpaper() }
} }
@Test @Test
fun `WHEN the wallpaper is updated to the current one THEN don't try showing the same wallpaper again`() { fun `GIVEN the store was observed for updates WHEN the lifecycle owner is destroyed THEN stop observing the store`() {
val appStore = AppStore() val observer = getObserver(mockk(relaxed = true))
val wallpapersUseCases: WallpapersUseCases = mockk { observer.observeWallpapersStoreSubscription = mockk(relaxed = true)
coEvery { loadBitmap(any()) } returns null observer.wallpapersScope = mockk {
} every { cancel() } just Runs
val observer = spyk(WallpapersObserver(appStore, wallpapersUseCases, mockk(relaxed = true))) {
every { 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) observer.onDestroy(mockk())
appStore.dispatch(UpdateCurrentWallpaper(wallpaper))
appStore.waitUntilIdle()
verify { observer.showWallpaper(wallpaper) }
appStore.dispatch(UpdateCurrentWallpaper(wallpaper)) verify { observer.wallpapersScope.cancel() }
appStore.waitUntilIdle() verify { observer.observeWallpapersStoreSubscription!!.unsubscribe() }
verify(exactly = 1) { observer.showWallpaper(wallpaper) }
} }
@Test @Test
fun `GIVEN no wallpaper is provided WHEN asked to show the wallpaper THEN show the current one`() { fun `GIVEN a wallpaper image is available WHEN asked to apply the current wallpaper THEN show set it to the wallpaper ImageView`() = runTestOnMain {
val wallpaper: Wallpaper = mockk() val imageView: ImageView = mockk(relaxed = true)
val appStore: AppStore = mockk(relaxed = true) { val observer = getObserver(wallpaperImageView = imageView)
every { state.wallpaperState.currentWallpaper } returns wallpaper val image: Bitmap = mockk()
} observer.currentWallpaperImage = image
val observer = spyk(WallpapersObserver(appStore, mockk(relaxed = true), mockk(relaxed = true))) observer.isWallpaperLoaded.complete(Unit)
observer.showWallpaper() observer.applyCurrentWallpaper()
verify { observer.showWallpaper(wallpaper) } verify { imageView.setImageBitmap(image) }
verify { imageView.isVisible = true }
} }
fun `GiVEN the current wallpaper is the default one WHEN showing it THEN hide the wallpaper view`() { fun `GIVEN no wallpaper image is available WHEN asked to apply the current wallpaper THEN hide the wallpaper ImageView`() = runTestOnMain {
val wallpapersUseCases: WallpapersUseCases = mockk() val imageView: ImageView = mockk()
val wallpaperView: ImageView = mockk(relaxed = true) val observer = getObserver(wallpaperImageView = imageView)
val observer = WallpapersObserver(mockk(relaxed = true), wallpapersUseCases, wallpaperView) observer.isWallpaperLoaded.complete(Unit)
observer.showWallpaper(Wallpaper.Default) observer.applyCurrentWallpaper()
verify { wallpaperView.isVisible = false } verify { imageView.isVisible = false }
verify { wallpapersUseCases wasNot Called } verify(exactly = 0) { imageView.setImageBitmap(any()) }
} }
@Test @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`() { fun `GIVEN the default wallpaper WHEN asked to load it THEN cache that the current image is null`() = runTestOnMain {
val wallpaper: Wallpaper = mockk() val observer = getObserver()
val bitmap: Bitmap = mockk() observer.currentWallpaperImage = 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) observer.loadWallpaper(Wallpaper.Default)
verify { wallpaperView.isVisible = true } assertNull(observer.currentWallpaperImage)
verify { wallpaperView.setImageBitmap(bitmap) }
} }
@Test @Test
fun `GIVEN the observer THEN use the main thread for showing the wallpaper`() { fun `GIVEN a custom wallpaper WHEN asked to load it THEN cache it's bitmap`() = runTestOnMain {
val wallpapersUseCases: WallpapersUseCases = mockk() val bitmap: Bitmap = mockk()
val wallpaperView: ImageView = mockk(relaxed = true) val wallpaper: Wallpaper = mockk()
val usecases: WallpapersUseCases = mockk {
coEvery { loadBitmap(wallpaper) } returns bitmap
}
val observer = getObserver(wallpapersUseCases = usecases)
val observer = WallpapersObserver(mockk(relaxed = true), wallpapersUseCases, wallpaperView) observer.loadWallpaper(wallpaper)
// Check that the context that would be used is Dispatchers.Main.immediate assertEquals(bitmap, observer.currentWallpaperImage)
// Unfortunately I could not also test that this is actually used when "showWallpaper" is called.
assertTrue(observer.wallpapersScope.toString().contains("Dispatchers.Main.immediate"))
} }
private fun getObserver(
appStore: AppStore = mockk(relaxed = true),
settings: Settings = mockk(),
wallpapersUseCases: WallpapersUseCases = mockk(),
wallpaperImageView: ImageView = mockk(),
backgroundWorkDispatcher: CoroutineDispatcher = coroutinesTestRule.testDispatcher,
) = WallpapersObserver(
appStore = appStore,
settings = settings,
wallpapersUseCases = wallpapersUseCases,
wallpaperImageView = wallpaperImageView,
backgroundWorkDispatcher = backgroundWorkDispatcher,
)
} }

Loading…
Cancel
Save