Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import javax.inject.Named
import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice
import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker
import org.wordpress.android.ui.utils.UiString
import org.wordpress.android.repositories.EditorSettingsRepository
import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice

@Suppress("LargeClass", "LongMethod", "LongParameterList")
class MySiteViewModel @Inject constructor(
Expand All @@ -66,8 +66,8 @@ class MySiteViewModel @Inject constructor(
private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice,
private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice,
private val siteCapabilityChecker: SiteCapabilityChecker,
private val editorSettingsRepository: EditorSettingsRepository,
private val gutenbergEditorPreloader: GutenbergEditorPreloader,
private val siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice,
) : ScopedViewModel(mainDispatcher) {
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
Expand All @@ -78,12 +78,6 @@ class MySiteViewModel @Inject constructor(
as they're already built on site select. */
private var isSiteSelected = false

/* Editor capabilities rarely change, so once we've successfully fetched them for a site we
skip subsequent non-user-initiated fetches in this ViewModel session. Failed fetches do
not populate this set, so a transient network failure recovers on the next onResume.
User-initiated refreshes (e.g. pull-to-refresh) always bypass this gate. */
private val fetchedCapabilitiesForSite = mutableSetOf<Int>()

val onScrollTo: MutableLiveData<Event<Int>> = MutableLiveData()

val onSnackbarMessage = merge(
Expand Down Expand Up @@ -132,15 +126,17 @@ class MySiteViewModel @Inject constructor(
applicationPasswordViewModelSlice.uiModel,
accountDataViewModelSlice.uiModel,
dashboardCardsViewModelSlice.uiModel,
dashboardItemsViewModelSlice.uiModel
dashboardItemsViewModelSlice.uiModel,
siteConnectivityBannerViewModelSlice.uiModel,
) { siteInfoHeaderCard,
applicationPAsswordModel,
accountData,
dashboardCards,
siteItems ->
siteItems,
connectivityBanner ->
val nonNullSiteInfoHeaderCard =
siteInfoHeaderCard ?: return@merge buildNoSiteState(accountData?.url, accountData?.name)
val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel)
val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel, connectivityBanner)
return@merge if (!dashboardCards.isNullOrEmpty<MySiteCardAndItem>())
SiteSelected(dashboardData = headerList + dashboardCards)
else if (!siteItems.isNullOrEmpty<MySiteCardAndItem>())
Expand All @@ -156,6 +152,7 @@ class MySiteViewModel @Inject constructor(
dashboardCardsViewModelSlice.initialize(viewModelScope)
dashboardItemsViewModelSlice.initialize(viewModelScope)
accountDataViewModelSlice.initialize(viewModelScope)
siteConnectivityBannerViewModelSlice.initialize(viewModelScope)
}

private fun shouldShowDashboard(site: SiteModel): Boolean {
Expand All @@ -176,12 +173,10 @@ class MySiteViewModel @Inject constructor(
siteCapabilityChecker.clearCacheForSite(site.siteId)
}
buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh)
launch {
fetchEditorCapabilitiesWithSnackbar(
site,
isUserInitiated = isPullToRefresh
)
}
siteConnectivityBannerViewModelSlice.fetchCapabilities(
site,
isUserInitiated = isPullToRefresh
)
} ?: run {
accountDataViewModelSlice.onRefresh()
}
Expand All @@ -193,45 +188,15 @@ class MySiteViewModel @Inject constructor(
selectedSiteRepository.updateSiteSettingsIfNecessary()
selectedSiteRepository.getSelectedSite()?.let {
buildDashboardOrSiteItems(it)
launch {
fetchEditorCapabilitiesWithSnackbar(
it,
isUserInitiated = false
)
}
siteConnectivityBannerViewModelSlice.fetchCapabilities(
it,
isUserInitiated = false
)
} ?: run {
accountDataViewModelSlice.onResume()
}
}

private suspend fun fetchEditorCapabilitiesWithSnackbar(
site: SiteModel,
isUserInitiated: Boolean
) {
if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) {
return
}
val ok = editorSettingsRepository
.fetchEditorCapabilitiesForSite(site)
if (ok) {
fetchedCapabilitiesForSite.add(site.id)
}
val hasCache = editorSettingsRepository
.hasCachedCapabilities(site)
if (!ok && (isUserInitiated || !hasCache)) {
_onSnackbarMessage.postValue(
Event(
SnackbarMessageHolder(
UiString.UiStringRes(
R.string
.site_settings_fetch_failed
)
)
)
)
}
}

private fun checkAndShowJetpackFullPluginInstallOnboarding() {
selectedSiteRepository.getSelectedSite()?.let { selectedSite ->
if (getShowJetpackFullPluginInstallOnboardingUseCase.execute(selectedSite)) {
Expand Down Expand Up @@ -352,6 +317,7 @@ class MySiteViewModel @Inject constructor(
private fun onSitePicked(site: SiteModel) {
siteInfoHeaderCardViewModelSlice.buildCard(site)
applicationPasswordViewModelSlice.buildCard(site)
siteConnectivityBannerViewModelSlice.clearBanner()
dashboardItemsViewModelSlice.clearValue()
dashboardCardsViewModelSlice.clearValue()
dashboardCardsViewModelSlice.resetShownTracker()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package org.wordpress.android.ui.mysite.cards.connectivity

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.wordpress.android.R
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.repositories.EditorSettingsRepository
import org.wordpress.android.ui.mysite.MySiteCardAndItem
import org.wordpress.android.util.NetworkUtilsWrapper
import javax.inject.Inject

class SiteConnectivityBannerViewModelSlice @Inject constructor(
private val editorSettingsRepository: EditorSettingsRepository,
private val networkUtilsWrapper: NetworkUtilsWrapper,
) {
private lateinit var scope: CoroutineScope
private var currentJob: Job? = null
private var currentSite: SiteModel? = null

private val _uiModel = MutableLiveData<MySiteCardAndItem?>()
val uiModel: LiveData<MySiteCardAndItem?> = _uiModel

/* Site capabilities rarely change, so once we've successfully fetched them for a site we
skip subsequent non-user-initiated fetches in this slice's lifetime. Failed fetches do
not populate this set, so a transient network failure recovers on the next onResume.
User-initiated calls (PTR, banner retry) always bypass this gate. */
private val fetchedCapabilitiesForSite = mutableSetOf<Int>()

fun initialize(scope: CoroutineScope) {
this.scope = scope
}

fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) {
currentJob?.cancel()
currentSite = site
currentJob = scope.launch {
if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) {
return@launch
}
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
if (ok) {
fetchedCapabilitiesForSite.add(site.id)
}
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
// Bail if the user switched sites while we were suspended — postValue
// isn't a suspension point, so cancellation alone won't catch this.
if (currentSite?.id != site.id) return@launch
// Suppress the banner when the device is offline — the global "no
// connection" banner already covers this case, and stacking warnings
// for the same root cause is just noise.
val suppressForOffline = !ok && !networkUtilsWrapper.isNetworkAvailable()
_uiModel.postValue(if (ok || hasCache || suppressForOffline) null else buildBanner())
}
}

fun clearBanner() {
currentJob?.cancel()
currentSite = null
_uiModel.postValue(null)
}

private fun buildBanner(): MySiteCardAndItem.Item.SingleActionCard =
MySiteCardAndItem.Item.SingleActionCard(
textResource = R.string.site_connectivity_banner_text,
imageResource = R.drawable.ic_cloud_off_themed_24dp,
onActionClick = {
val site = currentSite
if (site != null && currentJob?.isActive != true) {
fetchCapabilities(site, isUserInitiated = true)
}
},
showLearnMore = false,
centerImageVertically = true,
)
}
9 changes: 9 additions & 0 deletions WordPress/src/main/res/drawable/ic_cloud_off_themed_24dp.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4c-1.48,0 -2.85,0.43 -4.01,1.17l1.46,1.46C10.21,6.23 11.08,6 12,6c3.04,0 5.5,2.46 5.5,5.5v0.5H19c1.66,0 3,1.34 3,3 0,1.13 -0.64,2.11 -1.56,2.62l1.45,1.45C23.16,18.16 24,16.68 24,15c0,-2.64 -2.05,-4.78 -4.65,-4.96zM3,5.27l2.75,2.74C2.56,8.15 0,10.77 0,14c0,3.31 2.69,6 6,6h11.73l2,2L21,20.73 4.27,4 3,5.27zM7.73,10l8,8H6c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4h1.73z"
android:fillColor="?attr/colorOnSurface"/>
</vector>
2 changes: 1 addition & 1 deletion WordPress/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@
<string name="site_settings_use_third_party_blocks">Use Third-Party Blocks (Beta)</string>
<string name="site_settings_use_third_party_blocks_summary">Load third-party blocks from plugins installed on your site.</string>
<string name="site_settings_use_third_party_blocks_unsupported">Your site doesn\'t support loading third-party blocks in the editor.</string>
<string name="site_settings_fetch_failed">Failed to fetch site settings – some editor functionality may be limited.</string>
<string name="site_connectivity_banner_text">Unable to connect to your site. Some functionality might be limited.</string>
<string name="site_settings_password_updated">Password updated</string>
<string name="site_settings_update_password_message">To reconnect the app to your self-hosted site, enter the site\'s new password here.</string>
<string name="site_settings_homepage_settings">Homepage Settings</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.kotlin.any
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.never
import org.mockito.kotlin.times
Expand All @@ -40,7 +39,7 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass
import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice
import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice
import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker
import org.wordpress.android.repositories.EditorSettingsRepository
import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice
import org.wordpress.android.ui.pages.SnackbarMessageHolder
import org.wordpress.android.ui.posts.GutenbergEditorPreloader
import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource
Expand Down Expand Up @@ -106,7 +105,7 @@ class MySiteViewModelTest : BaseUnitTest() {
lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader

@Mock
lateinit var editorSettingsRepository: EditorSettingsRepository
lateinit var siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice

private lateinit var viewModel: MySiteViewModel
private lateinit var uiModels: MutableList<MySiteViewModel.State>
Expand Down Expand Up @@ -143,7 +142,7 @@ class MySiteViewModelTest : BaseUnitTest() {
whenever(dashboardCardsViewModelSlice.uiModel).thenReturn(MutableLiveData())
whenever(dashboardItemsViewModelSlice.uiModel).thenReturn(MutableLiveData())
whenever(applicationPasswordViewModelSlice.uiModel).thenReturn(MutableLiveData())
whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(true)
whenever(siteConnectivityBannerViewModelSlice.uiModel).thenReturn(MutableLiveData())

viewModel = MySiteViewModel(
testDispatcher(),
Expand All @@ -163,8 +162,8 @@ class MySiteViewModelTest : BaseUnitTest() {
dashboardItemsViewModelSlice,
applicationPasswordViewModelSlice,
siteCapabilityChecker,
editorSettingsRepository,
gutenbergEditorPreloader,
siteConnectivityBannerViewModelSlice,
)
uiModels = mutableListOf()
snackbars = mutableListOf()
Expand Down Expand Up @@ -362,91 +361,6 @@ class MySiteViewModelTest : BaseUnitTest() {
verify(dashboardCardsViewModelSlice).clearValue()
}

@Test
fun `given selected site, when onResume invoked twice, then editor capabilities are fetched once`() = test {
initSelectedSite()

viewModel.onResume()
advanceUntilIdle()
viewModel.onResume()
advanceUntilIdle()

verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
}

@Test
fun `given selected site, when onResume then non-PTR refresh, then editor capabilities are fetched once`() =
test {
initSelectedSite()

viewModel.onResume()
advanceUntilIdle()
viewModel.refresh(isPullToRefresh = false)
advanceUntilIdle()

verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
}

@Test
fun `given selected site, when onResume then PTR refresh, then editor capabilities are fetched twice`() = test {
initSelectedSite()

viewModel.onResume()
advanceUntilIdle()
viewModel.refresh(isPullToRefresh = true)
advanceUntilIdle()

verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest)
}

@Test
fun `given PTR refresh, when onResume invoked after, then editor capabilities are not re-fetched`() = test {
initSelectedSite()

viewModel.refresh(isPullToRefresh = true)
advanceUntilIdle()
viewModel.onResume()
advanceUntilIdle()

verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
}

@Test
fun `given fetch failed, when onResume invoked again, then editor capabilities are re-fetched`() = test {
initSelectedSite()
whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false, true)

viewModel.onResume()
advanceUntilIdle()
viewModel.onResume()
advanceUntilIdle()

verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest)
}

@Test
fun `given site switched, when onResume invoked, then editor capabilities are fetched for the new site`() =
test {
initSelectedSite()
val otherSite = SiteModel().apply {
id = TEST_SITE_ID + 1
url = TEST_URL
name = TEST_SITE_NAME
siteId = (TEST_SITE_ID + 1).toLong()
}

viewModel.onResume()
advanceUntilIdle()
whenever(selectedSiteRepository.getSelectedSite()).thenReturn(otherSite)
viewModel.onResume()
advanceUntilIdle()

verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(otherSite)
}



/* LAND ON THE EDITOR A/B EXPERIMENT */
@Test
fun `when performFirstStepAfterSiteCreation called, then home page editor is shown`() = test {
Expand Down
Loading
Loading