Skip to content

Commit 200189f

Browse files
authored
Replace site-settings snackbar with My Site connectivity banner (#22834)
* Replace site-settings snackbar with My Site connectivity banner Follow-up to #22785. The snackbar from a failed editor capabilities fetch is non-actionable and disappears, leaving the user with no way to recover. On a cold launch with flaky network, it pops up before the user has done anything. Move the fetch into a new SiteConnectivityBannerViewModelSlice that posts a persistent SingleActionCard banner into the My Site header (last position, below the reauth banner) when the fetch fails AND there's no cache. Tapping the banner retries the fetch (bypassing the per-session dedup). Successful fetch — or a cached value — clears the banner. The slice cancels the in-flight fetch on site switch and on each new fetchCapabilities call, and discards results whose site no longer matches the active selection — postValue isn't a suspension point, so cancellation alone won't catch a fetch that has already returned but not yet posted. Banner taps are no-ops while a retry is in flight, so rapid tapping doesn't queue concurrent network calls. * Suppress connectivity banner when device is fully offline Review feedback from @nbradbury: enabling airplane mode immediately after login surfaces three overlapping connectivity warnings. The global "No connection" indicator already conveys the offline state, so the slice's banner adds noise rather than information when the device itself has no network. Inject NetworkUtilsWrapper and suppress the banner emission when isNetworkAvailable() is false. The fetch still runs (and fails) unchanged — failed fetches don't populate the per-session dedup, so onResume retries naturally when the user comes back online. The banner remains useful in the partial-failure case it was designed for: the device has connectivity but the site's REST endpoint is unreachable (DNS, captive portal, JPC site offline, etc.).
1 parent d0a7db5 commit 200189f

6 files changed

Lines changed: 368 additions & 142 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt

Lines changed: 17 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import javax.inject.Named
4545
import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice
4646
import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker
4747
import org.wordpress.android.ui.utils.UiString
48-
import org.wordpress.android.repositories.EditorSettingsRepository
48+
import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice
4949

5050
@Suppress("LargeClass", "LongMethod", "LongParameterList")
5151
class MySiteViewModel @Inject constructor(
@@ -66,8 +66,8 @@ class MySiteViewModel @Inject constructor(
6666
private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice,
6767
private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice,
6868
private val siteCapabilityChecker: SiteCapabilityChecker,
69-
private val editorSettingsRepository: EditorSettingsRepository,
7069
private val gutenbergEditorPreloader: GutenbergEditorPreloader,
70+
private val siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice,
7171
) : ScopedViewModel(mainDispatcher) {
7272
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
7373
private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
@@ -78,12 +78,6 @@ class MySiteViewModel @Inject constructor(
7878
as they're already built on site select. */
7979
private var isSiteSelected = false
8080

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

8983
val onSnackbarMessage = merge(
@@ -132,15 +126,17 @@ class MySiteViewModel @Inject constructor(
132126
applicationPasswordViewModelSlice.uiModel,
133127
accountDataViewModelSlice.uiModel,
134128
dashboardCardsViewModelSlice.uiModel,
135-
dashboardItemsViewModelSlice.uiModel
129+
dashboardItemsViewModelSlice.uiModel,
130+
siteConnectivityBannerViewModelSlice.uiModel,
136131
) { siteInfoHeaderCard,
137132
applicationPAsswordModel,
138133
accountData,
139134
dashboardCards,
140-
siteItems ->
135+
siteItems,
136+
connectivityBanner ->
141137
val nonNullSiteInfoHeaderCard =
142138
siteInfoHeaderCard ?: return@merge buildNoSiteState(accountData?.url, accountData?.name)
143-
val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel)
139+
val headerList = listOfNotNull(nonNullSiteInfoHeaderCard, applicationPAsswordModel, connectivityBanner)
144140
return@merge if (!dashboardCards.isNullOrEmpty<MySiteCardAndItem>())
145141
SiteSelected(dashboardData = headerList + dashboardCards)
146142
else if (!siteItems.isNullOrEmpty<MySiteCardAndItem>())
@@ -156,6 +152,7 @@ class MySiteViewModel @Inject constructor(
156152
dashboardCardsViewModelSlice.initialize(viewModelScope)
157153
dashboardItemsViewModelSlice.initialize(viewModelScope)
158154
accountDataViewModelSlice.initialize(viewModelScope)
155+
siteConnectivityBannerViewModelSlice.initialize(viewModelScope)
159156
}
160157

161158
private fun shouldShowDashboard(site: SiteModel): Boolean {
@@ -176,12 +173,10 @@ class MySiteViewModel @Inject constructor(
176173
siteCapabilityChecker.clearCacheForSite(site.siteId)
177174
}
178175
buildDashboardOrSiteItems(site, forceRefresh = isPullToRefresh)
179-
launch {
180-
fetchEditorCapabilitiesWithSnackbar(
181-
site,
182-
isUserInitiated = isPullToRefresh
183-
)
184-
}
176+
siteConnectivityBannerViewModelSlice.fetchCapabilities(
177+
site,
178+
isUserInitiated = isPullToRefresh
179+
)
185180
} ?: run {
186181
accountDataViewModelSlice.onRefresh()
187182
}
@@ -193,45 +188,15 @@ class MySiteViewModel @Inject constructor(
193188
selectedSiteRepository.updateSiteSettingsIfNecessary()
194189
selectedSiteRepository.getSelectedSite()?.let {
195190
buildDashboardOrSiteItems(it)
196-
launch {
197-
fetchEditorCapabilitiesWithSnackbar(
198-
it,
199-
isUserInitiated = false
200-
)
201-
}
191+
siteConnectivityBannerViewModelSlice.fetchCapabilities(
192+
it,
193+
isUserInitiated = false
194+
)
202195
} ?: run {
203196
accountDataViewModelSlice.onResume()
204197
}
205198
}
206199

207-
private suspend fun fetchEditorCapabilitiesWithSnackbar(
208-
site: SiteModel,
209-
isUserInitiated: Boolean
210-
) {
211-
if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) {
212-
return
213-
}
214-
val ok = editorSettingsRepository
215-
.fetchEditorCapabilitiesForSite(site)
216-
if (ok) {
217-
fetchedCapabilitiesForSite.add(site.id)
218-
}
219-
val hasCache = editorSettingsRepository
220-
.hasCachedCapabilities(site)
221-
if (!ok && (isUserInitiated || !hasCache)) {
222-
_onSnackbarMessage.postValue(
223-
Event(
224-
SnackbarMessageHolder(
225-
UiString.UiStringRes(
226-
R.string
227-
.site_settings_fetch_failed
228-
)
229-
)
230-
)
231-
)
232-
}
233-
}
234-
235200
private fun checkAndShowJetpackFullPluginInstallOnboarding() {
236201
selectedSiteRepository.getSelectedSite()?.let { selectedSite ->
237202
if (getShowJetpackFullPluginInstallOnboardingUseCase.execute(selectedSite)) {
@@ -352,6 +317,7 @@ class MySiteViewModel @Inject constructor(
352317
private fun onSitePicked(site: SiteModel) {
353318
siteInfoHeaderCardViewModelSlice.buildCard(site)
354319
applicationPasswordViewModelSlice.buildCard(site)
320+
siteConnectivityBannerViewModelSlice.clearBanner()
355321
dashboardItemsViewModelSlice.clearValue()
356322
dashboardCardsViewModelSlice.clearValue()
357323
dashboardCardsViewModelSlice.resetShownTracker()
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package org.wordpress.android.ui.mysite.cards.connectivity
2+
3+
import androidx.lifecycle.LiveData
4+
import androidx.lifecycle.MutableLiveData
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Job
7+
import kotlinx.coroutines.launch
8+
import org.wordpress.android.R
9+
import org.wordpress.android.fluxc.model.SiteModel
10+
import org.wordpress.android.repositories.EditorSettingsRepository
11+
import org.wordpress.android.ui.mysite.MySiteCardAndItem
12+
import org.wordpress.android.util.NetworkUtilsWrapper
13+
import javax.inject.Inject
14+
15+
class SiteConnectivityBannerViewModelSlice @Inject constructor(
16+
private val editorSettingsRepository: EditorSettingsRepository,
17+
private val networkUtilsWrapper: NetworkUtilsWrapper,
18+
) {
19+
private lateinit var scope: CoroutineScope
20+
private var currentJob: Job? = null
21+
private var currentSite: SiteModel? = null
22+
23+
private val _uiModel = MutableLiveData<MySiteCardAndItem?>()
24+
val uiModel: LiveData<MySiteCardAndItem?> = _uiModel
25+
26+
/* Site capabilities rarely change, so once we've successfully fetched them for a site we
27+
skip subsequent non-user-initiated fetches in this slice's lifetime. Failed fetches do
28+
not populate this set, so a transient network failure recovers on the next onResume.
29+
User-initiated calls (PTR, banner retry) always bypass this gate. */
30+
private val fetchedCapabilitiesForSite = mutableSetOf<Int>()
31+
32+
fun initialize(scope: CoroutineScope) {
33+
this.scope = scope
34+
}
35+
36+
fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) {
37+
currentJob?.cancel()
38+
currentSite = site
39+
currentJob = scope.launch {
40+
if (site.id in fetchedCapabilitiesForSite && !isUserInitiated) {
41+
return@launch
42+
}
43+
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
44+
if (ok) {
45+
fetchedCapabilitiesForSite.add(site.id)
46+
}
47+
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
48+
// Bail if the user switched sites while we were suspended — postValue
49+
// isn't a suspension point, so cancellation alone won't catch this.
50+
if (currentSite?.id != site.id) return@launch
51+
// Suppress the banner when the device is offline — the global "no
52+
// connection" banner already covers this case, and stacking warnings
53+
// for the same root cause is just noise.
54+
val suppressForOffline = !ok && !networkUtilsWrapper.isNetworkAvailable()
55+
_uiModel.postValue(if (ok || hasCache || suppressForOffline) null else buildBanner())
56+
}
57+
}
58+
59+
fun clearBanner() {
60+
currentJob?.cancel()
61+
currentSite = null
62+
_uiModel.postValue(null)
63+
}
64+
65+
private fun buildBanner(): MySiteCardAndItem.Item.SingleActionCard =
66+
MySiteCardAndItem.Item.SingleActionCard(
67+
textResource = R.string.site_connectivity_banner_text,
68+
imageResource = R.drawable.ic_cloud_off_themed_24dp,
69+
onActionClick = {
70+
val site = currentSite
71+
if (site != null && currentJob?.isActive != true) {
72+
fetchCapabilities(site, isUserInitiated = true)
73+
}
74+
},
75+
showLearnMore = false,
76+
centerImageVertically = true,
77+
)
78+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
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"
8+
android:fillColor="?attr/colorOnSurface"/>
9+
</vector>

WordPress/src/main/res/values/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@
705705
<string name="site_settings_use_third_party_blocks">Use Third-Party Blocks (Beta)</string>
706706
<string name="site_settings_use_third_party_blocks_summary">Load third-party blocks from plugins installed on your site.</string>
707707
<string name="site_settings_use_third_party_blocks_unsupported">Your site doesn\'t support loading third-party blocks in the editor.</string>
708-
<string name="site_settings_fetch_failed">Failed to fetch site settings – some editor functionality may be limited.</string>
708+
<string name="site_connectivity_banner_text">Unable to connect to your site. Some functionality might be limited.</string>
709709
<string name="site_settings_password_updated">Password updated</string>
710710
<string name="site_settings_update_password_message">To reconnect the app to your self-hosted site, enter the site\'s new password here.</string>
711711
<string name="site_settings_homepage_settings">Homepage Settings</string>

WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt

Lines changed: 4 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import org.junit.Test
1414
import org.junit.runner.RunWith
1515
import org.mockito.Mock
1616
import org.mockito.junit.MockitoJUnitRunner
17-
import org.mockito.kotlin.any
1817
import org.mockito.kotlin.atLeastOnce
1918
import org.mockito.kotlin.never
2019
import org.mockito.kotlin.times
@@ -40,7 +39,7 @@ import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPass
4039
import org.wordpress.android.ui.mysite.cards.siteinfo.SiteInfoHeaderCardViewModelSlice
4140
import org.wordpress.android.ui.mysite.items.DashboardItemsViewModelSlice
4241
import org.wordpress.android.ui.mysite.items.listitem.SiteCapabilityChecker
43-
import org.wordpress.android.repositories.EditorSettingsRepository
42+
import org.wordpress.android.ui.mysite.cards.connectivity.SiteConnectivityBannerViewModelSlice
4443
import org.wordpress.android.ui.pages.SnackbarMessageHolder
4544
import org.wordpress.android.ui.posts.GutenbergEditorPreloader
4645
import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource
@@ -106,7 +105,7 @@ class MySiteViewModelTest : BaseUnitTest() {
106105
lateinit var gutenbergEditorPreloader: GutenbergEditorPreloader
107106

108107
@Mock
109-
lateinit var editorSettingsRepository: EditorSettingsRepository
108+
lateinit var siteConnectivityBannerViewModelSlice: SiteConnectivityBannerViewModelSlice
110109

111110
private lateinit var viewModel: MySiteViewModel
112111
private lateinit var uiModels: MutableList<MySiteViewModel.State>
@@ -143,7 +142,7 @@ class MySiteViewModelTest : BaseUnitTest() {
143142
whenever(dashboardCardsViewModelSlice.uiModel).thenReturn(MutableLiveData())
144143
whenever(dashboardItemsViewModelSlice.uiModel).thenReturn(MutableLiveData())
145144
whenever(applicationPasswordViewModelSlice.uiModel).thenReturn(MutableLiveData())
146-
whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(true)
145+
whenever(siteConnectivityBannerViewModelSlice.uiModel).thenReturn(MutableLiveData())
147146

148147
viewModel = MySiteViewModel(
149148
testDispatcher(),
@@ -163,8 +162,8 @@ class MySiteViewModelTest : BaseUnitTest() {
163162
dashboardItemsViewModelSlice,
164163
applicationPasswordViewModelSlice,
165164
siteCapabilityChecker,
166-
editorSettingsRepository,
167165
gutenbergEditorPreloader,
166+
siteConnectivityBannerViewModelSlice,
168167
)
169168
uiModels = mutableListOf()
170169
snackbars = mutableListOf()
@@ -362,91 +361,6 @@ class MySiteViewModelTest : BaseUnitTest() {
362361
verify(dashboardCardsViewModelSlice).clearValue()
363362
}
364363

365-
@Test
366-
fun `given selected site, when onResume invoked twice, then editor capabilities are fetched once`() = test {
367-
initSelectedSite()
368-
369-
viewModel.onResume()
370-
advanceUntilIdle()
371-
viewModel.onResume()
372-
advanceUntilIdle()
373-
374-
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
375-
}
376-
377-
@Test
378-
fun `given selected site, when onResume then non-PTR refresh, then editor capabilities are fetched once`() =
379-
test {
380-
initSelectedSite()
381-
382-
viewModel.onResume()
383-
advanceUntilIdle()
384-
viewModel.refresh(isPullToRefresh = false)
385-
advanceUntilIdle()
386-
387-
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
388-
}
389-
390-
@Test
391-
fun `given selected site, when onResume then PTR refresh, then editor capabilities are fetched twice`() = test {
392-
initSelectedSite()
393-
394-
viewModel.onResume()
395-
advanceUntilIdle()
396-
viewModel.refresh(isPullToRefresh = true)
397-
advanceUntilIdle()
398-
399-
verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest)
400-
}
401-
402-
@Test
403-
fun `given PTR refresh, when onResume invoked after, then editor capabilities are not re-fetched`() = test {
404-
initSelectedSite()
405-
406-
viewModel.refresh(isPullToRefresh = true)
407-
advanceUntilIdle()
408-
viewModel.onResume()
409-
advanceUntilIdle()
410-
411-
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
412-
}
413-
414-
@Test
415-
fun `given fetch failed, when onResume invoked again, then editor capabilities are re-fetched`() = test {
416-
initSelectedSite()
417-
whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false, true)
418-
419-
viewModel.onResume()
420-
advanceUntilIdle()
421-
viewModel.onResume()
422-
advanceUntilIdle()
423-
424-
verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest)
425-
}
426-
427-
@Test
428-
fun `given site switched, when onResume invoked, then editor capabilities are fetched for the new site`() =
429-
test {
430-
initSelectedSite()
431-
val otherSite = SiteModel().apply {
432-
id = TEST_SITE_ID + 1
433-
url = TEST_URL
434-
name = TEST_SITE_NAME
435-
siteId = (TEST_SITE_ID + 1).toLong()
436-
}
437-
438-
viewModel.onResume()
439-
advanceUntilIdle()
440-
whenever(selectedSiteRepository.getSelectedSite()).thenReturn(otherSite)
441-
viewModel.onResume()
442-
advanceUntilIdle()
443-
444-
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest)
445-
verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(otherSite)
446-
}
447-
448-
449-
450364
/* LAND ON THE EDITOR A/B EXPERIMENT */
451365
@Test
452366
fun `when performFirstStepAfterSiteCreation called, then home page editor is shown`() = test {

0 commit comments

Comments
 (0)