From 19a9d76122215b7d76872c5c61f45a07b6398bb5 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:50:00 -0600 Subject: [PATCH 01/32] Unify editor capability detection behind a single per-site detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces EditorCapabilityDetector — one app-scoped owner of editor REST capability state, exposed as a per-site StateFlow — so the connectivity banner and editor preloader share a single, deduplicated probe instead of each re-deriving the state and racing the same async preconditions. Folds in the authenticated direct-host probe fallback so private Atomic sites detect correctly on trunk. Part of #22942. --- .../org/wordpress/android/AppInitializer.kt | 7 + .../repositories/EditorCapabilityDetector.kt | 158 ++++++++++++ .../ApplicationPasswordViewModelSlice.kt | 20 +- .../SiteConnectivityBannerViewModelSlice.kt | 83 ++---- .../ui/posts/GutenbergEditorPreloader.kt | 14 +- .../EditorCapabilityDetectorTest.kt | 207 +++++++++++++++ .../ApplicationPasswordViewModelSliceTest.kt | 33 +-- ...iteConnectivityBannerViewModelSliceTest.kt | 242 +++++------------- .../ui/posts/GutenbergEditorPreloaderTest.kt | 11 +- 9 files changed, 509 insertions(+), 266 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt index 300566324748..722d06809f77 100644 --- a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt +++ b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt @@ -71,6 +71,7 @@ import org.wordpress.android.networking.ConnectionChangeReceiver import org.wordpress.android.networking.OAuthAuthenticator import org.wordpress.android.networking.RestClientUtils import org.wordpress.android.push.GCMRegistrationScheduler +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.support.ZendeskHelper import org.wordpress.android.ui.ActivityId import org.wordpress.android.ui.debug.cookies.DebugCookieManager @@ -229,6 +230,9 @@ class AppInitializer @Inject constructor( @Inject lateinit var wpApiClientProvider: WpApiClientProvider + @Inject + lateinit var editorCapabilityDetector: EditorCapabilityDetector + @Inject lateinit var openWebLinksWithJetpackHelper: DeepLinkOpenWebLinksWithJetpackHelper @@ -717,6 +721,9 @@ class AppInitializer @Inject constructor( // Clear cached wordpress-rs services and API clients wpServiceProvider.clearAll() wpApiClientProvider.clearAllClients() + + // Drop per-site editor-capability detection state for the signed-out user + editorCapabilityDetector.clear() } /* diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt new file mode 100644 index 000000000000..a8f6484e4709 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt @@ -0,0 +1,158 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.modules.APPLICATION_SCOPE +import org.wordpress.android.util.NetworkUtilsWrapper +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Single owner of "what does this site's editor REST API support" as an + * observable, per-site state. + * + * Capability detection ([EditorSettingsRepository.fetchEditorCapabilitiesForSite]) + * has two async preconditions on Atomic sites — an application password and a + * recovered REST root — provisioned elsewhere on the My Site screen. Routing + * every consumer (connectivity banner, editor preloader) through this detector + * means one probe per site, shared and deduplicated, instead of each consumer + * re-deriving the same state and racing the same preconditions. + * + * State is keyed by [SiteModel.id] — the local DB row id, stable across the + * process lifetime — mirroring `GutenbergEditorPreloader`. + * + * ## Entry points + * - [stateFor] — the reactive entry point. Returns a shared [StateFlow]; the + * first access starts detection, later accesses reuse the cached result + * (capabilities rarely change). A failed probe is retried on the next access. + * - [awaitProbe] — the one-shot entry point for callers that just need the + * probe to have run (and its capabilities persisted) before continuing. + * - [refresh] — forces a re-probe, bypassing the once-per-site gate + * (pull-to-refresh, banner retry, newly established credentials). + * - [clear] — cancels all work and drops all state; wire into sign-out. + */ +@Singleton +class EditorCapabilityDetector @Inject constructor( + private val editorSettingsRepository: EditorSettingsRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper, + @Named(APPLICATION_SCOPE) private val appScope: CoroutineScope, +) { + private val states = + ConcurrentHashMap>() + private val jobs = ConcurrentHashMap() + + // Sites whose live probe succeeded this process — the dedup gate. Only a + // successful fetch latches; a failed one is left to retry on the next + // access, matching the connectivity banner's previous per-slice behaviour. + // Reset by refresh / clear. + private val probedOk = ConcurrentHashMap.newKeySet() + + /** + * The shared detection state for [site]. The first call starts detection; + * later calls return the same flow without re-probing once it has + * succeeded. Collect it to react to capability changes. + */ + @Synchronized + fun stateFor(site: SiteModel): StateFlow { + val flow = flowFor(site.id) + if (shouldProbe(site.id)) launchDetection(site) + return flow + } + + /** + * Ensures detection has run for [site] (so its capabilities are persisted) + * and returns the settled state. Respects the once-per-site gate; call + * [refresh] first to force a fresh probe. + */ + suspend fun awaitProbe(site: SiteModel): EditorCapabilityDetectionState { + stateFor(site) + jobs[site.id]?.join() + return states[site.id]?.value ?: EditorCapabilityDetectionState.Pending + } + + /** + * Forces a re-probe for [site], bypassing the once-per-site gate. A no-op + * while a probe is already in flight — that probe's result is fresh enough. + */ + @Synchronized + fun refresh(site: SiteModel) { + if (jobs[site.id]?.isActive == true) return + probedOk.remove(site.id) + launchDetection(site) + } + + /** Cancels all in-flight detection and drops all cached state (sign-out). */ + @Synchronized + fun clear() { + jobs.values.forEach { it.cancel() } + jobs.clear() + states.clear() + probedOk.clear() + } + + @Synchronized + private fun launchDetection(site: SiteModel) { + jobs[site.id]?.cancel() + val flow = flowFor(site.id) + jobs[site.id] = appScope.launch { + flow.value = detect(site) + } + } + + private fun flowFor(siteLocalId: Int): MutableStateFlow = + states.getOrPut(siteLocalId) { + MutableStateFlow(EditorCapabilityDetectionState.Pending) + } + + private fun shouldProbe(siteLocalId: Int): Boolean = + jobs[siteLocalId]?.isActive != true && siteLocalId !in probedOk + + private suspend fun detect(site: SiteModel): EditorCapabilityDetectionState { + val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) + if (ok) probedOk.add(site.id) + val hasCache = editorSettingsRepository.hasCachedCapabilities(site) + return when { + ok || hasCache -> EditorCapabilityDetectionState.Ready + editorSettingsRepository.isAwaitingApplicationPassword(site) -> + EditorCapabilityDetectionState.Pending + !networkUtilsWrapper.isNetworkAvailable() -> + EditorCapabilityDetectionState.TransientError + else -> EditorCapabilityDetectionState.Unreachable + } + } +} + +/** + * Observable lifecycle of editor-capability detection for one site — distinct + * from `org.wordpress.android.ui.posts.EditorCapabilityState`, which models a + * resolved settings-row capability. This is the *detection* state the + * connectivity banner and editor preloader subscribe to. + */ +sealed interface EditorCapabilityDetectionState { + /** + * Not determined yet — still probing, or waiting on an application password + * being minted asynchronously. Consumers hold; the banner stays hidden. + */ + data object Pending : EditorCapabilityDetectionState + + /** + * Capabilities are known (freshly detected, or cached from a prior run). + * Read them via [EditorSettingsRepository]'s getters. + */ + data object Ready : EditorCapabilityDetectionState + + /** + * Credentials are present but the transport probe failed — the site looks + * unreachable. The only state that surfaces the connectivity banner. + */ + data object Unreachable : EditorCapabilityDetectionState + + /** A transient failure (e.g. device offline). Retried on the next probe. */ + data object TransientError : EditorCapabilityDetectionState +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 3ae1fdaa37c0..83cf7b1bc981 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -9,14 +9,16 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.wordpress.android.R import androidx.annotation.VisibleForTesting +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.SiteActionBuilder import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper -import org.wordpress.android.ui.accounts.login.CredentialsChangedNotifier import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.mysite.MySiteCardAndItem import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickLinksItem.QuickLinkItem @@ -39,7 +41,8 @@ class ApplicationPasswordViewModelSlice @Inject constructor( private val selfHostedEndpointFinder: SelfHostedEndpointFinder, private val siteXMLRPCClient: SiteXMLRPCClient, private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, - private val credentialsChangedNotifier: CredentialsChangedNotifier, + private val dispatcher: Dispatcher, + private val editorCapabilityDetector: EditorCapabilityDetector, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { lateinit var scope: CoroutineScope @@ -111,7 +114,11 @@ class ApplicationPasswordViewModelSlice @Inject constructor( if (!createResult.isError && createResult.credentials != null) { wpApiClientProvider.clearSelfHostedClient(storedSite.id) appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${storedSite.url}") - credentialsChangedNotifier.notifyChanged(storedSite.id) + // The first-login capability probe can lose the race to this async mint. storedSite + // was just mutated in place with the new credentials (SiteStore + // .persistApplicationPasswordCredentials), so re-probe against this exact instance — + // no stale-SiteModel re-read, and capabilities settle without a manual pull-to-refresh. + editorCapabilityDetector.refresh(storedSite) // The mint goes through the Jetpack tunnel and never runs discovery — without this // step, freshly minted Atomic sites end up with working creds but a NULL // wpApiRestUrl in the local DB. Run in the background so the card hides immediately. @@ -254,10 +261,9 @@ class ApplicationPasswordViewModelSlice @Inject constructor( } site.xmlRpcUrl = xmlRpcEndpoint - // Persist only the rediscovered column — mirrors healApiRestUrlIfMissing. A full-row - // updateSite would rewrite ~80 columns from this in-memory model for a one-field change - // (risking clobbering other out-of-band values), so write just xmlRpcUrl. - siteStore.persistXmlRpcUrl(site.id, xmlRpcEndpoint) + dispatcher.dispatch( + SiteActionBuilder.newUpdateSiteAction(site) + ) buildCard(site) } catch ( @Suppress("SwallowedException") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt index 5a93cd7df624..3123e1c5e9d9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt @@ -7,83 +7,51 @@ 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.accounts.login.CredentialsChangedNotifier +import org.wordpress.android.repositories.EditorCapabilityDetectionState +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.mysite.MySiteCardAndItem -import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject class SiteConnectivityBannerViewModelSlice @Inject constructor( - private val editorSettingsRepository: EditorSettingsRepository, - private val networkUtilsWrapper: NetworkUtilsWrapper, - private val credentialsChangedNotifier: CredentialsChangedNotifier, - private val selectedSiteRepository: SelectedSiteRepository, + private val editorCapabilityDetector: EditorCapabilityDetector, ) { private lateinit var scope: CoroutineScope - private var currentJob: Job? = null + private var collectJob: Job? = null private var currentSite: SiteModel? = null private val _uiModel = MutableLiveData() val uiModel: LiveData = _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() - fun initialize(scope: CoroutineScope) { this.scope = scope - // Re-run detection the moment an application password is established for the selected site - // (e.g. the headless mint finished after our first fetch lost the race), instead of waiting - // for the next resume/refresh. Re-read the selected site so we see the just-persisted - // credentials; isUserInitiated = false so a replayed event is a no-op once cached. - scope.launch { - credentialsChangedNotifier.events.collect { siteLocalId -> - val site = selectedSiteRepository.getSelectedSite() - if (site != null && site.id == siteLocalId) { - fetchCapabilities(site, isUserInitiated = false) - } - } - } } + /** + * Subscribes the banner to [site]'s editor-capability detection state. The + * banner is a thin view over that state — it surfaces only when detection + * reports the site [Unreachable][EditorCapabilityDetectionState.Unreachable]. + * Every other state (probing, pending credentials, offline, ready) leaves it + * hidden, so the dedup, offline-suppression, and pending-credential handling + * that used to live here now belong to the one detector. [isUserInitiated] + * (pull-to-refresh, banner retry) forces a fresh probe. + */ fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) { - currentJob?.cancel() + collectJob?.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) + if (isUserInitiated) editorCapabilityDetector.refresh(site) + collectJob = scope.launch { + editorCapabilityDetector.stateFor(site).collect { state -> + // Bail if the user switched sites while suspended — postValue is + // not a suspension point, so cancellation alone won't catch this. + if (currentSite?.id != site.id) return@collect + val showBanner = state is EditorCapabilityDetectionState.Unreachable + _uiModel.postValue(if (showBanner) buildBanner() else null) } - 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() - // Atomic sites probe the direct host with an application password that's minted - // asynchronously on this same screen, so a first-login fetch can fail purely because - // the credential isn't ready yet. Treat that as pending, not a connection failure — - // the application-password card owns that state and a later fetch will succeed. - val suppressForPendingAuth = - !ok && editorSettingsRepository.isAwaitingApplicationPassword(site) - // Show the banner only as a last resort — not when detection succeeded, when we have - // cached capabilities, or while a transient non-error state (offline / pending creds) - // already explains the failure. - val suppressBanner = ok || hasCache || suppressForOffline || suppressForPendingAuth - _uiModel.postValue(if (suppressBanner) null else buildBanner()) } } fun clearBanner() { - currentJob?.cancel() + collectJob?.cancel() currentSite = null _uiModel.postValue(null) } @@ -93,10 +61,7 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor( 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) - } + currentSite?.let { fetchCapabilities(it, isUserInitiated = true) } }, showLearnMore = false, centerImageVertically = true, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 5a9a116b8769..27f3b8632ec5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -11,7 +11,7 @@ import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.util.AppLog import org.wordpress.gutenberg.model.EditorDependencies @@ -63,7 +63,7 @@ class GutenbergEditorPreloader @Inject constructor( private val gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder, private val siteSettingsProvider: SiteSettingsProvider, private val editorServiceProvider: EditorServiceProvider, - private val editorSettingsRepository: EditorSettingsRepository, + private val editorCapabilityDetector: EditorCapabilityDetector, private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { @@ -99,8 +99,11 @@ class GutenbergEditorPreloader @Inject constructor( siteApiRestUrlRecoverer.discoverApiRootUrl(site.url) ?.let { site.wpApiRestUrl = it } } - editorSettingsRepository - .fetchEditorCapabilitiesForSite(site) + // Detect (and persist) editor capabilities via the shared + // detector so the preloader and connectivity banner can't + // double-probe. We only need the probe to have run before + // building config, so the settled state itself is ignored. + editorCapabilityDetector.awaitProbe(site) // Preloading produces EditorDependencies, which the editor // consumes alongside its own per-launch EditorConfiguration. // Cookies and network-logging are per-launch concerns the @@ -146,6 +149,9 @@ class GutenbergEditorPreloader @Inject constructor( @MainThread fun refreshPreloading(site: SiteModel, scope: CoroutineScope) { clearSite(site) + // Pull-to-refresh: force a fresh capability probe so the awaitProbe in + // preloadIfNeeded re-detects instead of returning the cached result. + editorCapabilityDetector.refresh(site) preloadIfNeeded(site, scope) } diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt new file mode 100644 index 000000000000..ea597c101dd3 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt @@ -0,0 +1,207 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.util.NetworkUtilsWrapper + +private const val TEST_SITE_LOCAL_ID = 7 + +@ExperimentalCoroutinesApi +class EditorCapabilityDetectorTest : BaseUnitTest(StandardTestDispatcher()) { + @Mock + lateinit var editorSettingsRepository: EditorSettingsRepository + + @Mock + lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + private lateinit var site: SiteModel + private lateinit var detector: EditorCapabilityDetector + + @Before + fun setUp() { + site = SiteModel().apply { id = TEST_SITE_LOCAL_ID } + detector = EditorCapabilityDetector( + editorSettingsRepository, + networkUtilsWrapper, + testScope(), + ) + } + + // region state mapping + + @Test + fun `given probe succeeds, then state is Ready`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + val flow = detector.stateFor(site) + advanceUntilIdle() + + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) + } + + @Test + fun `given probe fails but capabilities are cached, then state is Ready`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(true) + + val flow = detector.stateFor(site) + advanceUntilIdle() + + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) + } + + @Test + fun `given probe fails while awaiting an application password, then state is Pending`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) + whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(true) + + val flow = detector.stateFor(site) + advanceUntilIdle() + + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Pending) + } + + @Test + fun `given probe fails while offline, then state is TransientError`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) + whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + val flow = detector.stateFor(site) + advanceUntilIdle() + + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.TransientError) + } + + @Test + fun `given probe fails online with no pending auth, then state is Unreachable`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) + whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) + whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + val flow = detector.stateFor(site) + advanceUntilIdle() + + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Unreachable) + } + + // endregion + + // region deduplication + + @Test + fun `given a prior successful probe, when stateFor is called again, then it does not re-probe`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + detector.stateFor(site) + advanceUntilIdle() + detector.stateFor(site) + advanceUntilIdle() + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `given a prior failed probe, when stateFor is called again, then it re-probes`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false, true) + whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) + whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + val flow = detector.stateFor(site) + advanceUntilIdle() + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Unreachable) + detector.stateFor(site) + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) + assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) + } + + // endregion + + // region refresh + + @Test + fun `given a prior successful probe, when refresh is called, then it re-probes`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + detector.stateFor(site) + advanceUntilIdle() + detector.refresh(site) + advanceUntilIdle() + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `given a probe in flight, when refresh is called, then it does not start a second probe`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + detector.stateFor(site) // StandardTestDispatcher: job is launched but not yet run + detector.refresh(site) // a probe is already in flight — must be a no-op + advanceUntilIdle() + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) + } + + // endregion + + // region awaitProbe + + @Test + fun `given awaitProbe, then it runs detection and returns the settled state`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + val state = detector.awaitProbe(site) + + assertThat(state).isEqualTo(EditorCapabilityDetectionState.Ready) + verify(editorSettingsRepository).fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `given a prior successful probe, when awaitProbe is called again, then it does not re-probe`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + detector.awaitProbe(site) + detector.awaitProbe(site) + + verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) + } + + // endregion + + // region clear + + @Test + fun `given a probed site, when clear is called, then the next probe runs again`() = test { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) + + detector.awaitProbe(site) + detector.clear() + detector.awaitProbe(site) + + verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) + } + + @Test + fun `given no interaction, then no probe runs`() = test { + verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(site) + } + + // endregion +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index 6276f50d4a58..b19afec7af00 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -19,6 +19,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.mockito.kotlin.mock +import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.SitesModel import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError @@ -30,8 +31,8 @@ import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper -import org.wordpress.android.ui.accounts.login.CredentialsChangedNotifier import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.mysite.MySiteCardAndItem import kotlin.test.assertNotNull @@ -71,7 +72,10 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer @Mock - lateinit var credentialsChangedNotifier: CredentialsChangedNotifier + lateinit var dispatcher: Dispatcher + + @Mock + lateinit var editorCapabilityDetector: EditorCapabilityDetector private lateinit var siteTest: SiteModel @@ -92,7 +96,8 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { selfHostedEndpointFinder, siteXMLRPCClient, siteApiRestUrlRecoverer, - credentialsChangedNotifier, + dispatcher, + editorCapabilityDetector, testDispatcher() ).apply { initialize(testScope()) @@ -177,12 +182,14 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { } @Test - fun `given headless mint succeeds, then notify credentials changed`() = runTest { + fun `given headless mint succeeds, then re-probe editor capabilities for the minted site`() = runTest { stubMintSuccess() applicationPasswordViewModelSlice.buildCard(siteTest) - verify(credentialsChangedNotifier).notifyChanged(TEST_SITE_ID) + // The just-minted credentials live on this exact SiteModel instance, so the detector + // re-probes against it — no stale-SiteModel re-read, capabilities settle without a refresh. + verify(editorCapabilityDetector).refresh(siteTest) } @Test @@ -243,7 +250,6 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { assertNotNull(applicationPasswordCard) verify(siteStore).createApplicationPassword(any()) verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) - verify(credentialsChangedNotifier, never()).notifyChanged(any()) } @Test @@ -375,10 +381,8 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { } @Test - fun `given xmlRpc rediscovery and auth check succeed, then persist the discovered xmlRpcUrl`() = + fun `given xmlRpc rediscovery and auth check succeed, then update site and dispatch`() = runTest { - // @Before seeds siteTest.xmlRpcUrl; clear it so the final assertion proves rediscovery set it. - siteTest.xmlRpcUrl = null val xmlRpcUrl = "https://www.test.com/xmlrpc.php" whenever( selfHostedEndpointFinder @@ -389,17 +393,16 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { eq(xmlRpcUrl), any(), any() ) ).thenReturn(SitesModel(listOf(SiteModel()))) - whenever(siteStore.persistXmlRpcUrl(any(), any())).thenReturn(SiteStore.OnSiteChanged(0)) applicationPasswordViewModelSlice .attemptXmlRpcRediscovery(siteTest) - verify(siteStore).persistXmlRpcUrl(siteTest.id, xmlRpcUrl) + verify(dispatcher).dispatch(any()) assert(siteTest.xmlRpcUrl == xmlRpcUrl) } @Test - fun `given xmlRpc rediscovery succeeds but auth check fails, then do not persist`() = + fun `given xmlRpc rediscovery succeeds but auth check fails, then do not dispatch`() = runTest { siteTest.xmlRpcUrl = null val xmlRpcUrl = "https://www.test.com/xmlrpc.php" @@ -419,12 +422,12 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { applicationPasswordViewModelSlice .attemptXmlRpcRediscovery(siteTest) - verify(siteStore, never()).persistXmlRpcUrl(any(), any()) + verify(dispatcher, never()).dispatch(any()) assert(siteTest.xmlRpcUrl.isNullOrEmpty()) } @Test - fun `given xmlRpc rediscovery fails, then do not persist`() = + fun `given xmlRpc rediscovery fails, then do not dispatch`() = runTest { siteTest.xmlRpcUrl = null whenever( @@ -439,7 +442,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { verify(selfHostedEndpointFinder) .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) - verify(siteStore, never()).persistXmlRpcUrl(any(), any()) + verify(dispatcher, never()).dispatch(any()) assert(siteTest.xmlRpcUrl.isNullOrEmpty()) } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt index 59ed881f498e..280d8135a4da 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt @@ -1,29 +1,24 @@ package org.wordpress.android.ui.mysite.cards.connectivity -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock -import org.mockito.Mockito.lenient import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.eq -import org.mockito.kotlin.times +import org.mockito.kotlin.any +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.repositories.EditorSettingsRepository -import org.wordpress.android.ui.accounts.login.CredentialsChangedNotifier +import org.wordpress.android.repositories.EditorCapabilityDetectionState +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.mysite.MySiteCardAndItem -import org.wordpress.android.ui.mysite.SelectedSiteRepository -import org.wordpress.android.util.NetworkUtilsWrapper private const val TEST_SITE_LOCAL_ID = 42 @@ -31,18 +26,7 @@ private const val TEST_SITE_LOCAL_ID = 42 @RunWith(MockitoJUnitRunner::class) class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Mock - lateinit var editorSettingsRepository: EditorSettingsRepository - - @Mock - lateinit var networkUtilsWrapper: NetworkUtilsWrapper - - @Mock - lateinit var credentialsChangedNotifier: CredentialsChangedNotifier - - @Mock - lateinit var selectedSiteRepository: SelectedSiteRepository - - private val credentialsChangedFlow = MutableSharedFlow(extraBufferCapacity = 1) + lateinit var editorCapabilityDetector: EditorCapabilityDetector private lateinit var siteTest: SiteModel private lateinit var slice: SiteConnectivityBannerViewModelSlice @@ -51,34 +35,23 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Before fun setUp() { siteTest = SiteModel().apply { id = TEST_SITE_LOCAL_ID } - // Default network state is available; tests that need offline override per-test. Lenient - // because tests where the fetch succeeds never reach the network check. - lenient().`when`(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) - whenever(credentialsChangedNotifier.events).thenReturn(credentialsChangedFlow) - slice = SiteConnectivityBannerViewModelSlice( - editorSettingsRepository, - networkUtilsWrapper, - credentialsChangedNotifier, - selectedSiteRepository, - ) + slice = SiteConnectivityBannerViewModelSlice(editorCapabilityDetector) slice.initialize(testScope()) slice.uiModel.observeForever { emittedBanners.add(it) } } - @Test - fun `given fetch succeeds, when fetchCapabilities invoked, then banner is null`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) - - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - - assertThat(emittedBanners.last()).isNull() + private fun stubState( + site: SiteModel, + state: EditorCapabilityDetectionState, + ): MutableStateFlow { + val flow = MutableStateFlow(state) + whenever(editorCapabilityDetector.stateFor(site)).thenReturn(flow) + return flow } @Test - fun `given fetch fails with no cache, when fetchCapabilities invoked, then banner is shown`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `given detection unreachable, when fetchCapabilities invoked, then banner is shown`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -89,181 +62,101 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { } @Test - fun `given fetch fails with no cache but device offline, when fetchCapabilities invoked, then banner is null`() = - test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - - // Global offline indicator covers this case — suppress to avoid stacked warnings. - assertThat(emittedBanners.last()).isNull() - } - - @Test - fun `given fetch fails but app password pending, when fetchCapabilities invoked, then banner is null`() = - test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.isAwaitingApplicationPassword(siteTest)).thenReturn(true) - - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - - // Credentials are still being minted — pending, not a connection failure. - assertThat(emittedBanners.last()).isNull() - } - - @Test - fun `when credentials change for the selected site, then capabilities are re-fetched`() = test { - whenever(selectedSiteRepository.getSelectedSite()).thenReturn(siteTest) - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + fun `given detection ready, when fetchCapabilities invoked, then banner is null`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Ready) - credentialsChangedFlow.emit(TEST_SITE_LOCAL_ID) + slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - verify(editorSettingsRepository).fetchEditorCapabilitiesForSite(siteTest) + assertThat(emittedBanners.last()).isNull() } @Test - fun `given fetch fails but cache exists, when fetchCapabilities invoked, then banner is null`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(true) + fun `given detection pending, when fetchCapabilities invoked, then banner is null`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Pending) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() + // Pending = probing or awaiting credentials — never a false "can't connect". assertThat(emittedBanners.last()).isNull() } @Test - fun `given prior successful fetch, when fetchCapabilities invoked again non-user-initiated, then fetch skipped`() = - test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) - - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) - } - - @Test - fun `given prior failed fetch, when fetchCapabilities invoked again non-user-initiated, then fetch retries`() = - test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false, true) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) - - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - assertThat(emittedBanners.last()).isNotNull - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) - assertThat(emittedBanners.last()).isNull() - } - - @Test - fun `given prior successful fetch, when user-initiated fetchCapabilities invoked, then fetch runs again`() = - test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) + fun `given detection transient error, when fetchCapabilities invoked, then banner is null`() = test { + stubState(siteTest, EditorCapabilityDetectionState.TransientError) - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - slice.fetchCapabilities(siteTest, isUserInitiated = true) - advanceUntilIdle() + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) - } + // Transient (e.g. offline) is covered by the global indicator — don't stack a warning. + assertThat(emittedBanners.last()).isNull() + } @Test - fun `given banner showing, when retry tapped, then fetch runs and bypasses session dedup`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `given unreachable then recovered to ready, when state changes, then banner clears`() = test { + val flow = stubState(siteTest, EditorCapabilityDetectionState.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard + assertThat(emittedBanners.last()).isNotNull - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) - banner.onActionClick() + flow.value = EditorCapabilityDetectionState.Ready advanceUntilIdle() - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) assertThat(emittedBanners.last()).isNull() } @Test - fun `when clearBanner invoked, then banner is null`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `given user-initiated, when fetchCapabilities invoked, then detector is refreshed`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Ready) - slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() - assertThat(emittedBanners.last()).isNotNull - slice.clearBanner() + slice.fetchCapabilities(siteTest, isUserInitiated = true) advanceUntilIdle() - assertThat(emittedBanners.last()).isNull() + verify(editorCapabilityDetector).refresh(siteTest) } @Test - fun `given two different sites, when fetched in sequence, then both fetches run`() = test { - val otherSite = SiteModel().apply { id = TEST_SITE_LOCAL_ID + 1 } - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(true) - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(otherSite)).thenReturn(true) + fun `given non-user-initiated, when fetchCapabilities invoked, then detector is not refreshed`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - slice.fetchCapabilities(otherSite, isUserInitiated = false) - advanceUntilIdle() - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(eq(siteTest)) - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(eq(otherSite)) + verify(editorCapabilityDetector, never()).refresh(any()) } @Test - fun `given fetch in flight, when clearBanner invoked, then banner stays null after fetch completes`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `given banner showing, when retry tapped, then detector is refreshed`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) - slice.clearBanner() advanceUntilIdle() + val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard - assertThat(emittedBanners.last()).isNull() + banner.onActionClick() + advanceUntilIdle() + + verify(editorCapabilityDetector).refresh(siteTest) } @Test - fun `given retry in flight, when banner tapped again, then second tap is a no-op`() = test { - val gate = CompletableDeferred() - var callCount = 0 - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).doSuspendableAnswer { - callCount++ - if (callCount == 1) false else gate.await() - } - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `when clearBanner invoked, then banner is null`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - val banner = emittedBanners.last() as MySiteCardAndItem.Item.SingleActionCard - - banner.onActionClick() // first tap — retry suspends on gate - banner.onActionClick() // second tap — should be ignored - gate.complete(true) + assertThat(emittedBanners.last()).isNotNull + slice.clearBanner() advanceUntilIdle() - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(siteTest) + assertThat(emittedBanners.last()).isNull() } @Test - fun `given banner cleared, when retry tapped, then no fetch runs`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(siteTest)).thenReturn(false) + fun `given banner cleared, when retry tapped, then no refresh runs`() = test { + stubState(siteTest, EditorCapabilityDetectionState.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -275,27 +168,26 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { banner.onActionClick() advanceUntilIdle() - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(siteTest) + verify(editorCapabilityDetector, never()).refresh(any()) } @Test - fun `given fetch in flight for site A, when fetch starts for site B, then site A result is discarded`() = test { + fun `given site switched, when old site becomes unreachable, then banner ignores it`() = test { val siteB = SiteModel().apply { id = TEST_SITE_LOCAL_ID + 1 } - val gateA = CompletableDeferred() - // Site A's fetch suspends on a gate so we can interleave site B's call before A completes. - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteTest)).doSuspendableAnswer { - gateA.await() - } - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(siteB)).thenReturn(true) + val flowA = stubState(siteTest, EditorCapabilityDetectionState.Ready) + stubState(siteB, EditorCapabilityDetectionState.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = false) - advanceUntilIdle() // siteA suspended in fetch + advanceUntilIdle() slice.fetchCapabilities(siteB, isUserInitiated = false) - advanceUntilIdle() // siteB completes; currentSite is now siteB - gateA.complete(false) // release siteA — its result must NOT post a banner advanceUntilIdle() - // No banner card should ever have been emitted for site A. - assertThat(emittedBanners.filterIsInstance()).isEmpty() + // Site A's probe resolves to Unreachable after we've switched to B — must not surface. + flowA.value = EditorCapabilityDetectionState.Unreachable + advanceUntilIdle() + + assertThat( + emittedBanners.filterIsInstance() + ).isEmpty() } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt index b009b9f3cc2d..40a1895ef3ac 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -19,7 +19,7 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.repositories.EditorSettingsRepository +import org.wordpress.android.repositories.EditorCapabilityDetector import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.gutenberg.model.EditorAssetBundle import org.wordpress.gutenberg.model.EditorConfiguration @@ -49,7 +49,7 @@ class GutenbergEditorPreloaderTest : lateinit var editorServiceProvider: EditorServiceProvider @Mock - lateinit var editorSettingsRepository: EditorSettingsRepository + lateinit var editorCapabilityDetector: EditorCapabilityDetector @Mock lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer @@ -75,7 +75,7 @@ class GutenbergEditorPreloaderTest : gutenbergKitSettingsBuilder = gutenbergKitSettingsBuilder, siteSettingsProvider = siteSettingsProvider, editorServiceProvider = editorServiceProvider, - editorSettingsRepository = editorSettingsRepository, + editorCapabilityDetector = editorCapabilityDetector, siteApiRestUrlRecoverer = siteApiRestUrlRecoverer, bgDispatcher = testDispatcher() ) @@ -194,7 +194,7 @@ class GutenbergEditorPreloaderTest : } @Test - fun `successful preload fetches editor capabilities`() = test { + fun `successful preload detects editor capabilities via the detector`() = test { val site = createSite() enablePreloading(site) stubSuccessfulPreload() @@ -203,8 +203,7 @@ class GutenbergEditorPreloaderTest : preloader.preloadIfNeeded(site, this) advanceUntilIdle() - verify(editorSettingsRepository) - .fetchEditorCapabilitiesForSite(site) + verify(editorCapabilityDetector).awaitProbe(site) } @Test From 91059e151a7e53714a4ad89b96ce540b38f097a4 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:51:40 -0600 Subject: [PATCH 02/32] Add release note for editor capability detection rework --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index f1ec4f3f18b2..ab4a56249f69 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,7 +2,7 @@ 26.9 ----- - +* [*] Reworked editor capability detection to be more reliable and prevent a false "Unable to connect to your site" banner on private Atomic sites. 26.8 ----- From 2907e6f3a13d30d64148debbb65b62a0238c6375 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:20:12 -0600 Subject: [PATCH 03/32] Remove CredentialsChangedNotifier event bus, superseded by the detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #22926's first-login hardening signalled credential establishment through a process-global CredentialsChangedNotifier that the banner collected against a re-read of getSelectedSite() — the staleness race called out in #22942. EditorCapabilityDetector.refresh(storedSite), called from the mint path on the exact mutated SiteModel, replaces that coordination, so drop the notifier and its wiring in ApplicationPasswordLoginHelper. --- .../login/ApplicationPasswordLoginHelper.kt | 2 -- .../login/CredentialsChangedNotifier.kt | 34 ------------------- .../ApplicationPasswordLoginHelperTest.kt | 7 +--- 3 files changed, 1 insertion(+), 42 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/ui/accounts/login/CredentialsChangedNotifier.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt index 5a9c5e493269..7b5c6c01fe0d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt @@ -46,7 +46,6 @@ class ApplicationPasswordLoginHelper @Inject constructor( private val discoverSuccessWrapper: DiscoverSuccessWrapper, private val crashLogging: CrashLogging, private val wpApiClientProvider: WpApiClientProvider, - private val credentialsChangedNotifier: CredentialsChangedNotifier, ) { private var processedAppPasswordData: String? = null @@ -149,7 +148,6 @@ class ApplicationPasswordLoginHelper @Inject constructor( } wpApiClientProvider.clearSelfHostedClient(site.id) dispatcherWrapper.updateApplicationPassword(site) - credentialsChangedNotifier.notifyChanged(site.id) trackSuccessful(effectiveUrlLogin.siteUrl) trackCreated(creationSource, success = true) processedAppPasswordData = effectiveUrlLogin.siteUrl diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/CredentialsChangedNotifier.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/CredentialsChangedNotifier.kt deleted file mode 100644 index 0046dc400595..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/CredentialsChangedNotifier.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.wordpress.android.ui.accounts.login - -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * App-scoped signal that an application password was newly established for a site — either by the - * headless Jetpack-tunnel mint on the My Site screen or by the interactive application-password - * login. Lets credential-dependent work (e.g. editor capability detection) re-run as soon as the - * password exists, instead of waiting for the next My Site resume/refresh. - * - * Emits the site's local id; collectors should re-read a fresh SiteModel so they observe the - * just-persisted credentials rather than a stale in-memory copy. - */ -@Singleton -class CredentialsChangedNotifier @Inject constructor() { - // replay = 1 so a collector that subscribes just after an emit still sees it — closes the - // emit-before-collect race. DROP_OLDEST keeps tryEmit non-suspending without an unbounded buffer. - private val _events = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val events: SharedFlow = _events.asSharedFlow() - - /** Signals that [siteLocalId]'s application-password credentials were just established. */ - fun notifyChanged(siteLocalId: Int) { - _events.tryEmit(siteLocalId) - } -} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt index 6c97ee23eb3b..b1df5c270090 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelperTest.kt @@ -76,9 +76,6 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() { @Mock lateinit var wpApiClientProvider: WpApiClientProvider - @Mock - lateinit var credentialsChangedNotifier: CredentialsChangedNotifier - private lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper @Before @@ -95,8 +92,7 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() { apiRootUrlCache, discoverSuccessWrapper, crashLogging, - wpApiClientProvider, - credentialsChangedNotifier + wpApiClientProvider ) } @@ -210,7 +206,6 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() { verify(siteStore).sites verify(dispatcherWrapper).updateApplicationPassword(eq(siteModel)) verify(wpApiClientProvider).clearSelfHostedClient(eq(siteModel.id)) - verify(credentialsChangedNotifier).notifyChanged(eq(siteModel.id)) } @Test From 2473ae9c57270cc6669c163351165b6cbd789665 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:06:47 -0600 Subject: [PATCH 04/32] Fold provisioning + detection into one single-flight SiteProvisioningSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the EditorCapabilityDetector into SiteProvisioningSource: one per-site, single-flight pipeline that ensures application-password credentials, recovers the REST root, and detects editor capabilities — each stage awaited before the next. Because the capability probe is now structurally downstream of credential provisioning, it can never run before the mint, so the first-login race is gone by construction rather than mitigated. The application-password card, connectivity banner, and editor preloader all render slices of the one SiteReadiness state. The mint/validate mechanics move out of ApplicationPasswordViewModelSlice (now a renderer) into the source's ensureAuth stage; the per-site single-flight subsumes the card's old 409-safety guard. The duplicate wpApiRestUrl heal collapses into the source's recoverRestUrl stage. --- .../org/wordpress/android/AppInitializer.kt | 8 +- .../repositories/EditorCapabilityDetector.kt | 158 ------- .../repositories/SiteProvisioningSource.kt | 252 ++++++++++ .../ApplicationPasswordViewModelSlice.kt | 145 +++--- .../SiteConnectivityBannerViewModelSlice.kt | 25 +- .../ui/posts/GutenbergEditorPreloader.kt | 31 +- .../EditorCapabilityDetectorTest.kt | 207 --------- .../SiteProvisioningSourceTest.kt | 237 ++++++++++ .../ApplicationPasswordViewModelSliceTest.kt | 431 +++--------------- ...iteConnectivityBannerViewModelSliceTest.kt | 86 ++-- .../ui/posts/GutenbergEditorPreloaderTest.kt | 33 +- 11 files changed, 695 insertions(+), 918 deletions(-) delete mode 100644 WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt create mode 100644 WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt delete mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt index 722d06809f77..2113fe519073 100644 --- a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt +++ b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt @@ -71,7 +71,7 @@ import org.wordpress.android.networking.ConnectionChangeReceiver import org.wordpress.android.networking.OAuthAuthenticator import org.wordpress.android.networking.RestClientUtils import org.wordpress.android.push.GCMRegistrationScheduler -import org.wordpress.android.repositories.EditorCapabilityDetector +import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.support.ZendeskHelper import org.wordpress.android.ui.ActivityId import org.wordpress.android.ui.debug.cookies.DebugCookieManager @@ -231,7 +231,7 @@ class AppInitializer @Inject constructor( lateinit var wpApiClientProvider: WpApiClientProvider @Inject - lateinit var editorCapabilityDetector: EditorCapabilityDetector + lateinit var siteProvisioningSource: SiteProvisioningSource @Inject lateinit var openWebLinksWithJetpackHelper: DeepLinkOpenWebLinksWithJetpackHelper @@ -722,8 +722,8 @@ class AppInitializer @Inject constructor( wpServiceProvider.clearAll() wpApiClientProvider.clearAllClients() - // Drop per-site editor-capability detection state for the signed-out user - editorCapabilityDetector.clear() + // Drop per-site provisioning + capability state for the signed-out user + siteProvisioningSource.clear() } /* diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt deleted file mode 100644 index a8f6484e4709..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorCapabilityDetector.kt +++ /dev/null @@ -1,158 +0,0 @@ -package org.wordpress.android.repositories - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.modules.APPLICATION_SCOPE -import org.wordpress.android.util.NetworkUtilsWrapper -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -/** - * Single owner of "what does this site's editor REST API support" as an - * observable, per-site state. - * - * Capability detection ([EditorSettingsRepository.fetchEditorCapabilitiesForSite]) - * has two async preconditions on Atomic sites — an application password and a - * recovered REST root — provisioned elsewhere on the My Site screen. Routing - * every consumer (connectivity banner, editor preloader) through this detector - * means one probe per site, shared and deduplicated, instead of each consumer - * re-deriving the same state and racing the same preconditions. - * - * State is keyed by [SiteModel.id] — the local DB row id, stable across the - * process lifetime — mirroring `GutenbergEditorPreloader`. - * - * ## Entry points - * - [stateFor] — the reactive entry point. Returns a shared [StateFlow]; the - * first access starts detection, later accesses reuse the cached result - * (capabilities rarely change). A failed probe is retried on the next access. - * - [awaitProbe] — the one-shot entry point for callers that just need the - * probe to have run (and its capabilities persisted) before continuing. - * - [refresh] — forces a re-probe, bypassing the once-per-site gate - * (pull-to-refresh, banner retry, newly established credentials). - * - [clear] — cancels all work and drops all state; wire into sign-out. - */ -@Singleton -class EditorCapabilityDetector @Inject constructor( - private val editorSettingsRepository: EditorSettingsRepository, - private val networkUtilsWrapper: NetworkUtilsWrapper, - @Named(APPLICATION_SCOPE) private val appScope: CoroutineScope, -) { - private val states = - ConcurrentHashMap>() - private val jobs = ConcurrentHashMap() - - // Sites whose live probe succeeded this process — the dedup gate. Only a - // successful fetch latches; a failed one is left to retry on the next - // access, matching the connectivity banner's previous per-slice behaviour. - // Reset by refresh / clear. - private val probedOk = ConcurrentHashMap.newKeySet() - - /** - * The shared detection state for [site]. The first call starts detection; - * later calls return the same flow without re-probing once it has - * succeeded. Collect it to react to capability changes. - */ - @Synchronized - fun stateFor(site: SiteModel): StateFlow { - val flow = flowFor(site.id) - if (shouldProbe(site.id)) launchDetection(site) - return flow - } - - /** - * Ensures detection has run for [site] (so its capabilities are persisted) - * and returns the settled state. Respects the once-per-site gate; call - * [refresh] first to force a fresh probe. - */ - suspend fun awaitProbe(site: SiteModel): EditorCapabilityDetectionState { - stateFor(site) - jobs[site.id]?.join() - return states[site.id]?.value ?: EditorCapabilityDetectionState.Pending - } - - /** - * Forces a re-probe for [site], bypassing the once-per-site gate. A no-op - * while a probe is already in flight — that probe's result is fresh enough. - */ - @Synchronized - fun refresh(site: SiteModel) { - if (jobs[site.id]?.isActive == true) return - probedOk.remove(site.id) - launchDetection(site) - } - - /** Cancels all in-flight detection and drops all cached state (sign-out). */ - @Synchronized - fun clear() { - jobs.values.forEach { it.cancel() } - jobs.clear() - states.clear() - probedOk.clear() - } - - @Synchronized - private fun launchDetection(site: SiteModel) { - jobs[site.id]?.cancel() - val flow = flowFor(site.id) - jobs[site.id] = appScope.launch { - flow.value = detect(site) - } - } - - private fun flowFor(siteLocalId: Int): MutableStateFlow = - states.getOrPut(siteLocalId) { - MutableStateFlow(EditorCapabilityDetectionState.Pending) - } - - private fun shouldProbe(siteLocalId: Int): Boolean = - jobs[siteLocalId]?.isActive != true && siteLocalId !in probedOk - - private suspend fun detect(site: SiteModel): EditorCapabilityDetectionState { - val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) - if (ok) probedOk.add(site.id) - val hasCache = editorSettingsRepository.hasCachedCapabilities(site) - return when { - ok || hasCache -> EditorCapabilityDetectionState.Ready - editorSettingsRepository.isAwaitingApplicationPassword(site) -> - EditorCapabilityDetectionState.Pending - !networkUtilsWrapper.isNetworkAvailable() -> - EditorCapabilityDetectionState.TransientError - else -> EditorCapabilityDetectionState.Unreachable - } - } -} - -/** - * Observable lifecycle of editor-capability detection for one site — distinct - * from `org.wordpress.android.ui.posts.EditorCapabilityState`, which models a - * resolved settings-row capability. This is the *detection* state the - * connectivity banner and editor preloader subscribe to. - */ -sealed interface EditorCapabilityDetectionState { - /** - * Not determined yet — still probing, or waiting on an application password - * being minted asynchronously. Consumers hold; the banner stays hidden. - */ - data object Pending : EditorCapabilityDetectionState - - /** - * Capabilities are known (freshly detected, or cached from a prior run). - * Read them via [EditorSettingsRepository]'s getters. - */ - data object Ready : EditorCapabilityDetectionState - - /** - * Credentials are present but the transport probe failed — the site looks - * unreachable. The only state that surfaces the connectivity banner. - */ - data object Unreachable : EditorCapabilityDetectionState - - /** A transient failure (e.g. device offline). Retried on the next probe. */ - data object TransientError : EditorCapabilityDetectionState -} diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt new file mode 100644 index 000000000000..e5c8ad7792fd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -0,0 +1,252 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.APPLICATION_SCOPE +import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * The single source of truth for getting a site ready to use: it provisions + * application-password credentials, recovers the REST API root, and detects + * editor capabilities — **in that order, each stage awaited before the next**. + * + * Those three things have a genuine ordering dependency (you can't probe the + * REST API of a private Atomic host until you've minted a credential for it), + * and they used to be spread across the connectivity banner, the editor + * preloader, and the application-password card, each triggering its slice of + * the work independently and racing the others. Running them as one serialized + * per-site pipeline makes the race structurally impossible: [detectCapabilities] + * is downstream of [ensureAuth], so the probe can never run before the mint. + * + * Per site there is at most one in-flight pipeline (single-flight, keyed by + * [SiteModel.id]); concurrent callers join it rather than starting a second — + * which also subsumes the application-password card's old single-flight guard + * (two concurrent mints hit a 409 that destroys the winner's credentials). + * + * ## Entry points + * - [stateFor] — reactive: returns a shared [StateFlow]; the first access runs + * the pipeline, later accesses reuse a [SiteReadiness.Ready] result. + * - [await] — one-shot: runs the pipeline (if needed) and returns the result. + * - [invalidate] — forces a re-run, bypassing the once-per-site gate + * (pull-to-refresh, retry). + * - [clear] — cancels all work and drops all state; wire into sign-out. + */ +@Singleton +@Suppress("LongParameterList") +class SiteProvisioningSource @Inject constructor( + private val siteStore: SiteStore, + private val applicationPasswordLoginHelper: ApplicationPasswordLoginHelper, + private val applicationPasswordValidator: ApplicationPasswordValidator, + private val wpApiClientProvider: WpApiClientProvider, + private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, + private val editorSettingsRepository: EditorSettingsRepository, + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val appLogWrapper: AppLogWrapper, + @Named(APPLICATION_SCOPE) private val appScope: CoroutineScope, +) { + private val states = ConcurrentHashMap>() + private val jobs = ConcurrentHashMap() + + // Sites whose pipeline reached Ready this process — the dedup gate. Only a fully-ready site + // latches; auth-needed / unreachable / transient outcomes are left to re-run on the next + // access (so a later resume re-mints or re-probes). Reset by invalidate / clear. + private val ready = ConcurrentHashMap.newKeySet() + + /** + * The shared readiness state for [site]. The first call starts the pipeline; + * later calls return the same flow without re-running once it reached + * [SiteReadiness.Ready]. Collect it to react to provisioning / capability changes. + */ + @Synchronized + fun stateFor(site: SiteModel): StateFlow { + val flow = flowFor(site.id) + if (shouldRun(site.id)) launchPipeline(site) + return flow + } + + /** + * Runs the pipeline for [site] (if it hasn't reached Ready) and returns the + * settled readiness. Respects the once-per-site gate; call [invalidate] first + * to force a fresh run. + */ + suspend fun await(site: SiteModel): SiteReadiness { + stateFor(site) + jobs[site.id]?.join() + return states[site.id]?.value ?: SiteReadiness.Probing + } + + /** + * Forces a re-run for [site], bypassing the once-per-site gate. A no-op while + * a run is already in flight — that run already reflects current state. + */ + @Synchronized + fun invalidate(site: SiteModel) { + if (jobs[site.id]?.isActive == true) return + ready.remove(site.id) + launchPipeline(site) + } + + /** Cancels all in-flight pipelines and drops all cached state (sign-out). */ + @Synchronized + fun clear() { + jobs.values.forEach { it.cancel() } + jobs.clear() + states.clear() + ready.clear() + } + + @Synchronized + private fun launchPipeline(site: SiteModel) { + jobs[site.id]?.cancel() + val flow = flowFor(site.id) + jobs[site.id] = appScope.launch { + val readiness = runPipeline(site) + flow.value = readiness + if (readiness is SiteReadiness.Ready) ready.add(site.id) + } + } + + private fun flowFor(siteLocalId: Int): MutableStateFlow = + states.getOrPut(siteLocalId) { MutableStateFlow(SiteReadiness.Probing) } + + private fun shouldRun(siteLocalId: Int): Boolean = + jobs[siteLocalId]?.isActive != true && siteLocalId !in ready + + private suspend fun runPipeline(site: SiteModel): SiteReadiness { + // Re-read from the store so we provision against the persisted SiteModel and mutate that + // instance in place — later stages (URL recovery, capability probe) see the fresh creds. + val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ?: site + return when (val auth = ensureAuth(storedSite)) { + SiteAuthState.Provisioned -> { + recoverRestUrl(storedSite) + detectCapabilities(storedSite) + } + else -> SiteReadiness.NeedsAuth(auth) + } + } + + /** + * Stage 1 — ensure the site has working application-password credentials. + * Validates stored creds with Basic auth against the direct host; on a + * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack + * tunnel (the only path that works for Atomic / Jetpack-WPCom-REST sites). + */ + private suspend fun ensureAuth(site: SiteModel): SiteAuthState { + val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) + if (hadCredentials) { + when (applicationPasswordValidator.validate(site)) { + ApplicationPasswordValidator.Outcome.Valid -> + return SiteAuthState.Provisioned + ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { + // Don't punish flaky networks — treat as in-progress and retry next run. + appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}") + return SiteAuthState.Provisioning + } + ApplicationPasswordValidator.Outcome.Invalid -> { + // Stored creds are stale (revoked, deleted) — clear them so the mint below + // creates fresh ones, and invalidate the cached client. + appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") + siteStore.deleteStoredApplicationPasswordCredentials(site) + wpApiClientProvider.clearSelfHostedClient(site.id) + } + } + } + val createResult = siteStore.createApplicationPassword(site) + if (!createResult.isError && createResult.credentials != null) { + wpApiClientProvider.clearSelfHostedClient(site.id) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}") + return SiteAuthState.Provisioned + } + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})" + ) + return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials) + } + + /** + * Stage 2 — recover the REST API root for Atomic sites minted through the + * Jetpack tunnel, which never runs discovery and so leaves `wpApiRestUrl` + * null. One owner for the heal that the card and preloader used to duplicate. + */ + private suspend fun recoverRestUrl(site: SiteModel) { + if (!site.wpApiRestUrl.isNullOrEmpty()) return + siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl -> + site.wpApiRestUrl = apiRootUrl + siteApiRestUrlRecoverer.persistApiRootUrl(site.id, apiRootUrl) + } + } + + /** + * Stage 3 — probe the REST API for editor-capability support and persist it. + * Reached only once auth is [SiteAuthState.Provisioned], so credentials are + * guaranteed present: a failure here is a real transport problem, not a + * pending mint. + */ + private suspend fun detectCapabilities(site: SiteModel): SiteReadiness { + val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) + val hasCache = editorSettingsRepository.hasCachedCapabilities(site) + return when { + ok || hasCache -> SiteReadiness.Ready + !networkUtilsWrapper.isNetworkAvailable() -> SiteReadiness.TransientError + else -> SiteReadiness.Unreachable + } + } +} + +/** + * Whether a site's application password is usable. Owned by [SiteProvisioningSource]; + * rendered by the application-password card. + */ +sealed interface SiteAuthState { + /** Credentials are usable (validated, or freshly minted). */ + data object Provisioned : SiteAuthState + + /** Not usable yet, but not a terminal failure — a mint is implied / a transient + * validation error occurred. The card stays hidden; the next run retries. */ + data object Provisioning : SiteAuthState + + /** Terminal: the mint failed. [hadCredentials] distinguishes a re-authentication + * (creds went bad) from a first-time authentication prompt. */ + data class Unprovisionable(val hadCredentials: Boolean) : SiteAuthState +} + +/** + * The combined per-site readiness the [SiteProvisioningSource] exposes. The + * connectivity banner renders [Unreachable], the application-password card + * renders [NeedsAuth], and the editor preloader awaits a non-[Probing] value — + * each a slice of the one state, so they can't disagree. + */ +sealed interface SiteReadiness { + /** The pipeline is running and hasn't produced a result yet. */ + data object Probing : SiteReadiness + + /** Stopped at the auth stage — credentials aren't usable. Carries the + * [SiteAuthState] so the card can pick re-auth vs. first-auth. */ + data class NeedsAuth(val auth: SiteAuthState) : SiteReadiness + + /** Provisioned and editor capabilities are known (detected or cached). */ + data object Ready : SiteReadiness + + /** Provisioned, but the capability probe failed — the site looks unreachable. + * The only state that surfaces the connectivity banner. */ + data object Unreachable : SiteReadiness + + /** Provisioned, but a transient failure (e.g. offline). Retried on the next run. */ + data object TransientError : SiteReadiness +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 83cf7b1bc981..226f93355c84 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -14,12 +14,12 @@ import org.wordpress.android.fluxc.generated.SiteActionBuilder import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient -import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.repositories.EditorCapabilityDetector +import org.wordpress.android.repositories.SiteAuthState +import org.wordpress.android.repositories.SiteProvisioningSource +import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper -import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.mysite.MySiteCardAndItem import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickLinksItem.QuickLinkItem import org.wordpress.android.ui.mysite.SiteNavigationAction @@ -32,17 +32,25 @@ import org.wordpress.android.viewmodel.Event import javax.inject.Inject import javax.inject.Named +/** + * Renders the application-password card from the site's readiness. Credential + * provisioning (validate / mint) now lives in [SiteProvisioningSource]; this + * slice is a view over the auth slice of that state: + * + * - [SiteAuthState.Unprovisionable] → a re-authentication banner (creds went + * bad) or a first-time "authenticate" card, looked up lazily. + * - provisioned (any non-[SiteReadiness.NeedsAuth] terminal state) → hidden, + * except true self-hosted sites missing an XML-RPC endpoint, which get the + * XML-RPC-disabled card plus a background rediscovery attempt. + */ class ApplicationPasswordViewModelSlice @Inject constructor( private val applicationPasswordLoginHelper: ApplicationPasswordLoginHelper, private val siteStore: SiteStore, private val appLogWrapper: AppLogWrapper, - private val wpApiClientProvider: WpApiClientProvider, - private val applicationPasswordValidator: ApplicationPasswordValidator, private val selfHostedEndpointFinder: SelfHostedEndpointFinder, private val siteXMLRPCClient: SiteXMLRPCClient, - private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, + private val siteProvisioningSource: SiteProvisioningSource, private val dispatcher: Dispatcher, - private val editorCapabilityDetector: EditorCapabilityDetector, @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { lateinit var scope: CoroutineScope @@ -60,102 +68,52 @@ class ApplicationPasswordViewModelSlice @Inject constructor( val uiModelMutable = MutableLiveData() val uiModel: LiveData = uiModelMutable - // Single-flight guard: buildCard is invoked from onResume / refresh / onSitePicked, which can - // fire close together. Without this, two coroutines both pass the "creds missing" check in - // ApplicationPasswordsManager and issue two server-side mints. Worse, the 409 conflict handler - // then deletes-and-recreates the winner's password, so the losing racer destroys working creds. - private var buildJob: Job? = null + private var collectJob: Job? = null + private var currentSite: SiteModel? = null fun buildCard(siteModel: SiteModel) { - if (buildJob?.isActive == true) { - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: Skipping buildCard for ${siteModel.url} - previous run still in flight" - ) - return - } - buildJob = scope.launch { - val storedSite = siteStore.sites.firstOrNull { it.id == siteModel.id } ?: siteModel - val hadCreds = !applicationPasswordLoginHelper.siteHasBadCredentials(storedSite) - - // Step 1: if we already have stored creds, validate them with Basic auth against the - // direct host. This actually exercises the application password (unlike - // WpApiClientProvider.getWpApiClient, which routes WPCom-flagged sites through the - // bearer-token path and would not catch a revoked password). - if (hadCreds) { - when (applicationPasswordValidator.validate(storedSite)) { - ApplicationPasswordValidator.Outcome.Valid -> { - // Heal in the background so the card hides immediately on a slow network. - scope.launch { healApiRestUrlIfMissing(storedSite) } - handleValidAuth(storedSite) - return@launch - } - ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { - // Don't punish flaky networks — leave the card hidden and try again next time. - uiModelMutable.postValue(null) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${storedSite.url}") - return@launch - } - ApplicationPasswordValidator.Outcome.Invalid -> { - // Stored creds are stale (revoked, deleted, etc.) — clear them so the next - // mint creates fresh ones, and invalidate the cached client. - appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${storedSite.url}, clearing") - siteStore.deleteStoredApplicationPasswordCredentials(storedSite) - wpApiClientProvider.clearSelfHostedClient(storedSite.id) - } - } - } - - // Step 2: mint a fresh application password via the FluxC Jetpack tunnel. wordpress-rs - // can't do this today — the WP.com REST proxy doesn't expose the application-passwords - // endpoint under /wp/v2/sites/{id}/... (see Automattic/wordpress-rs#1350) — so FluxC's - // Jetpack-tunnel client is the only working path for Atomic / Jetpack-WPCom-REST sites. - val createResult = siteStore.createApplicationPassword(storedSite) - if (!createResult.isError && createResult.credentials != null) { - wpApiClientProvider.clearSelfHostedClient(storedSite.id) - appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${storedSite.url}") - // The first-login capability probe can lose the race to this async mint. storedSite - // was just mutated in place with the new credentials (SiteStore - // .persistApplicationPasswordCredentials), so re-probe against this exact instance — - // no stale-SiteModel re-read, and capabilities settle without a manual pull-to-refresh. - editorCapabilityDetector.refresh(storedSite) - // The mint goes through the Jetpack tunnel and never runs discovery — without this - // step, freshly minted Atomic sites end up with working creds but a NULL - // wpApiRestUrl in the local DB. Run in the background so the card hides immediately. - scope.launch { healApiRestUrlIfMissing(storedSite) } - handleValidAuth(storedSite) - return@launch - } - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: Headless mint failed for ${storedSite.url} (notSupported=" + - "${createResult.error?.notSupported})" - ) - - // Step 3: mint failed. If we started with creds, show the reauth banner; otherwise the - // standard "authenticate" card. Either way, discovery is required to populate the URL. - if (hadCreds) { - buildReauthenticationBanner(storedSite) - } else { - buildAuthenticationCard(storedSite) + collectJob?.cancel() + currentSite = siteModel + collectJob = scope.launch { + siteProvisioningSource.stateFor(siteModel).collect { readiness -> + // Bail if the user switched sites while suspended — postValue is not a + // suspension point, so cancellation alone won't catch this. + if (currentSite?.id != siteModel.id) return@collect + renderCard(siteModel, readiness) } } } - private suspend fun healApiRestUrlIfMissing(site: SiteModel) { - if (!site.wpApiRestUrl.isNullOrEmpty()) return - siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl -> - site.wpApiRestUrl = apiRootUrl - siteApiRestUrlRecoverer.persistApiRootUrl(site.id, apiRootUrl) + private suspend fun renderCard(site: SiteModel, readiness: SiteReadiness) { + when (readiness) { + is SiteReadiness.NeedsAuth -> when (val auth = readiness.auth) { + is SiteAuthState.Unprovisionable -> + if (auth.hadCredentials) buildReauthenticationBanner(site) else buildAuthenticationCard(site) + SiteAuthState.Provisioning -> { + // Mint in flight / transient validation error — hide and let the next run retry. + uiModelMutable.postValue(null) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Provisioning in progress for ${site.url}") + } + SiteAuthState.Provisioned -> Unit // unreachable: Provisioned never wraps in NeedsAuth + } + // Any terminal provisioned state — the credentials are usable, so the only card left to + // show is the self-hosted XML-RPC fallback. Capability outcome (Ready/Unreachable) is the + // connectivity banner's concern, not this card's. + SiteReadiness.Ready, + SiteReadiness.Unreachable, + SiteReadiness.TransientError -> handleProvisioned(site) + SiteReadiness.Probing -> Unit // leave the card unchanged while the pipeline runs } } - private fun handleValidAuth(site: SiteModel) { + private fun handleProvisioned(site: SiteModel) { + // Re-read the stored site so we see credentials/endpoints the pipeline just persisted. + val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ?: site // Only true self-hosted sites need the XML-RPC fallback path — Atomic and Jetpack-WPCom-REST // sites talk REST end-to-end and don't need XML-RPC. - if (!site.isUsingWpComRestApi && site.xmlRpcUrl.isNullOrEmpty()) { - buildXmlRpcDisabledCard(site) - attemptXmlRpcRediscovery(site) + if (!storedSite.isUsingWpComRestApi && storedSite.xmlRpcUrl.isNullOrEmpty()) { + buildXmlRpcDisabledCard(storedSite) + attemptXmlRpcRediscovery(storedSite) } else { uiModelMutable.postValue(null) appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - authenticated") @@ -264,7 +222,8 @@ class ApplicationPasswordViewModelSlice @Inject constructor( dispatcher.dispatch( SiteActionBuilder.newUpdateSiteAction(site) ) - buildCard(site) + // Endpoint recovered — hide the XML-RPC-disabled card. + uiModelMutable.postValue(null) } catch ( @Suppress("SwallowedException") e: SelfHostedEndpointFinder.DiscoveryException diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt index 3123e1c5e9d9..fc17191982db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt @@ -7,13 +7,13 @@ 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.EditorCapabilityDetectionState -import org.wordpress.android.repositories.EditorCapabilityDetector +import org.wordpress.android.repositories.SiteProvisioningSource +import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.mysite.MySiteCardAndItem import javax.inject.Inject class SiteConnectivityBannerViewModelSlice @Inject constructor( - private val editorCapabilityDetector: EditorCapabilityDetector, + private val siteProvisioningSource: SiteProvisioningSource, ) { private lateinit var scope: CoroutineScope private var collectJob: Job? = null @@ -27,24 +27,23 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor( } /** - * Subscribes the banner to [site]'s editor-capability detection state. The - * banner is a thin view over that state — it surfaces only when detection - * reports the site [Unreachable][EditorCapabilityDetectionState.Unreachable]. - * Every other state (probing, pending credentials, offline, ready) leaves it - * hidden, so the dedup, offline-suppression, and pending-credential handling - * that used to live here now belong to the one detector. [isUserInitiated] - * (pull-to-refresh, banner retry) forces a fresh probe. + * Subscribes the banner to [site]'s readiness. The banner is a thin view over + * that state — it surfaces only when the site is provisioned but the capability + * probe failed ([SiteReadiness.Unreachable]). Every other state (probing, needs + * auth, offline, ready) leaves it hidden: when credentials are the problem the + * application-password card owns it, and the banner stays out of the way. + * [isUserInitiated] (pull-to-refresh, retry) forces a fresh run. */ fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) { collectJob?.cancel() currentSite = site - if (isUserInitiated) editorCapabilityDetector.refresh(site) + if (isUserInitiated) siteProvisioningSource.invalidate(site) collectJob = scope.launch { - editorCapabilityDetector.stateFor(site).collect { state -> + siteProvisioningSource.stateFor(site).collect { readiness -> // Bail if the user switched sites while suspended — postValue is // not a suspension point, so cancellation alone won't catch this. if (currentSite?.id != site.id) return@collect - val showBanner = state is EditorCapabilityDetectionState.Unreachable + val showBanner = readiness is SiteReadiness.Unreachable _uiModel.postValue(if (showBanner) buildBanner() else null) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index 27f3b8632ec5..e90c5e510f0d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.launch import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.repositories.EditorCapabilityDetector -import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.util.AppLog import org.wordpress.gutenberg.model.EditorDependencies import java.util.concurrent.ConcurrentHashMap @@ -63,8 +63,8 @@ class GutenbergEditorPreloader @Inject constructor( private val gutenbergKitSettingsBuilder: GutenbergKitSettingsBuilder, private val siteSettingsProvider: SiteSettingsProvider, private val editorServiceProvider: EditorServiceProvider, - private val editorCapabilityDetector: EditorCapabilityDetector, - private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, + private val siteProvisioningSource: SiteProvisioningSource, + private val siteStore: SiteStore, @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher ) { private sealed class PreloadState { @@ -95,15 +95,14 @@ class GutenbergEditorPreloader @Inject constructor( val siteId = site.id val job = scope.launch(bgDispatcher) { try { - if (site.wpApiRestUrl.isNullOrEmpty()) { - siteApiRestUrlRecoverer.discoverApiRootUrl(site.url) - ?.let { site.wpApiRestUrl = it } - } - // Detect (and persist) editor capabilities via the shared - // detector so the preloader and connectivity banner can't - // double-probe. We only need the probe to have run before - // building config, so the settled state itself is ignored. - editorCapabilityDetector.awaitProbe(site) + // The provisioning source mints creds, recovers the REST root, and + // detects capabilities — one shared, deduplicated run, so the preloader + // and connectivity banner can't double-probe. We only need it to have + // run; re-read the provisioned site so the config points at the + // recovered REST root. + siteProvisioningSource.await(site) + val provisionedSite = siteStore.sites + .firstOrNull { it.id == siteId } ?: site // Preloading produces EditorDependencies, which the editor // consumes alongside its own per-launch EditorConfiguration. // Cookies and network-logging are per-launch concerns the @@ -111,7 +110,7 @@ class GutenbergEditorPreloader @Inject constructor( // defaults here. val config = gutenbergKitSettingsBuilder .buildPostConfiguration( - site = site, + site = provisionedSite, accessToken = accountStore.accessToken, cookies = emptyMap(), isNetworkLoggingEnabled = false, @@ -149,9 +148,9 @@ class GutenbergEditorPreloader @Inject constructor( @MainThread fun refreshPreloading(site: SiteModel, scope: CoroutineScope) { clearSite(site) - // Pull-to-refresh: force a fresh capability probe so the awaitProbe in + // Pull-to-refresh: force a fresh provisioning run so the await in // preloadIfNeeded re-detects instead of returning the cached result. - editorCapabilityDetector.refresh(site) + siteProvisioningSource.invalidate(site) preloadIfNeeded(site, scope) } diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt deleted file mode 100644 index ea597c101dd3..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/repositories/EditorCapabilityDetectorTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package org.wordpress.android.repositories - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.wordpress.android.BaseUnitTest -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.util.NetworkUtilsWrapper - -private const val TEST_SITE_LOCAL_ID = 7 - -@ExperimentalCoroutinesApi -class EditorCapabilityDetectorTest : BaseUnitTest(StandardTestDispatcher()) { - @Mock - lateinit var editorSettingsRepository: EditorSettingsRepository - - @Mock - lateinit var networkUtilsWrapper: NetworkUtilsWrapper - - private lateinit var site: SiteModel - private lateinit var detector: EditorCapabilityDetector - - @Before - fun setUp() { - site = SiteModel().apply { id = TEST_SITE_LOCAL_ID } - detector = EditorCapabilityDetector( - editorSettingsRepository, - networkUtilsWrapper, - testScope(), - ) - } - - // region state mapping - - @Test - fun `given probe succeeds, then state is Ready`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - val flow = detector.stateFor(site) - advanceUntilIdle() - - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) - } - - @Test - fun `given probe fails but capabilities are cached, then state is Ready`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(true) - - val flow = detector.stateFor(site) - advanceUntilIdle() - - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) - } - - @Test - fun `given probe fails while awaiting an application password, then state is Pending`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) - whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(true) - - val flow = detector.stateFor(site) - advanceUntilIdle() - - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Pending) - } - - @Test - fun `given probe fails while offline, then state is TransientError`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) - whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) - - val flow = detector.stateFor(site) - advanceUntilIdle() - - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.TransientError) - } - - @Test - fun `given probe fails online with no pending auth, then state is Unreachable`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false) - whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) - whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) - - val flow = detector.stateFor(site) - advanceUntilIdle() - - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Unreachable) - } - - // endregion - - // region deduplication - - @Test - fun `given a prior successful probe, when stateFor is called again, then it does not re-probe`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - detector.stateFor(site) - advanceUntilIdle() - detector.stateFor(site) - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) - } - - @Test - fun `given a prior failed probe, when stateFor is called again, then it re-probes`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(false, true) - whenever(editorSettingsRepository.hasCachedCapabilities(site)).thenReturn(false) - whenever(editorSettingsRepository.isAwaitingApplicationPassword(site)).thenReturn(false) - whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) - - val flow = detector.stateFor(site) - advanceUntilIdle() - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Unreachable) - detector.stateFor(site) - advanceUntilIdle() - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) - assertThat(flow.value).isEqualTo(EditorCapabilityDetectionState.Ready) - } - - // endregion - - // region refresh - - @Test - fun `given a prior successful probe, when refresh is called, then it re-probes`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - detector.stateFor(site) - advanceUntilIdle() - detector.refresh(site) - advanceUntilIdle() - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) - } - - @Test - fun `given a probe in flight, when refresh is called, then it does not start a second probe`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - detector.stateFor(site) // StandardTestDispatcher: job is launched but not yet run - detector.refresh(site) // a probe is already in flight — must be a no-op - advanceUntilIdle() - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) - } - - // endregion - - // region awaitProbe - - @Test - fun `given awaitProbe, then it runs detection and returns the settled state`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - val state = detector.awaitProbe(site) - - assertThat(state).isEqualTo(EditorCapabilityDetectionState.Ready) - verify(editorSettingsRepository).fetchEditorCapabilitiesForSite(site) - } - - @Test - fun `given a prior successful probe, when awaitProbe is called again, then it does not re-probe`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - detector.awaitProbe(site) - detector.awaitProbe(site) - - verify(editorSettingsRepository, times(1)).fetchEditorCapabilitiesForSite(site) - } - - // endregion - - // region clear - - @Test - fun `given a probed site, when clear is called, then the next probe runs again`() = test { - whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(site)).thenReturn(true) - - detector.awaitProbe(site) - detector.clear() - detector.awaitProbe(site) - - verify(editorSettingsRepository, times(2)).fetchEditorCapabilitiesForSite(site) - } - - @Test - fun `given no interaction, then no probe runs`() = test { - verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(site) - } - - // endregion -} diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt new file mode 100644 index 000000000000..a695ed3b6582 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -0,0 +1,237 @@ +package org.wordpress.android.repositories + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCredentials +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator +import org.wordpress.android.util.NetworkUtilsWrapper + +private const val TEST_SITE_LOCAL_ID = 7 + +@ExperimentalCoroutinesApi +class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { + @Mock lateinit var siteStore: SiteStore + @Mock lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper + @Mock lateinit var applicationPasswordValidator: ApplicationPasswordValidator + @Mock lateinit var wpApiClientProvider: WpApiClientProvider + @Mock lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer + @Mock lateinit var editorSettingsRepository: EditorSettingsRepository + @Mock lateinit var networkUtilsWrapper: NetworkUtilsWrapper + @Mock lateinit var appLogWrapper: AppLogWrapper + + private lateinit var site: SiteModel + private lateinit var source: SiteProvisioningSource + + @Before + fun setUp() { + site = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://test.example.com" + // A non-null REST root so recoverRestUrl short-circuits unless a test clears it. + wpApiRestUrl = "https://test.example.com/wp-json" + } + source = SiteProvisioningSource( + siteStore, + applicationPasswordLoginHelper, + applicationPasswordValidator, + wpApiClientProvider, + siteApiRestUrlRecoverer, + editorSettingsRepository, + networkUtilsWrapper, + appLogWrapper, + testScope(), + ) + } + + private fun stubHasStoredCredentials(value: Boolean) = + whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(!value) + + private suspend fun stubValidate(outcome: ApplicationPasswordValidator.Outcome) = + whenever(applicationPasswordValidator.validate(any())).thenReturn(outcome) + + private suspend fun stubMintSuccess() = + whenever(siteStore.createApplicationPassword(any())).thenReturn( + OnApplicationPasswordCreated(site, ApplicationPasswordCredentials("user", "pass", uuid = "u")) + ) + + private suspend fun stubMintFailure() = + whenever(siteStore.createApplicationPassword(any())).thenReturn( + OnApplicationPasswordCreated(site, BaseNetworkError(GenericErrorType.UNKNOWN, "fail"), notSupported = false) + ) + + private suspend fun stubCapabilityProbe(ok: Boolean, cached: Boolean = false) { + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(ok) + // `ok || hasCache` short-circuits, so the cache is only read (and only needs stubbing) + // when the live probe failed — stubbing it on success would be an unnecessary stub. + if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached) + } + + // region auth stage + + @Test + fun `given valid stored credentials, then provisioned and ready`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Ready) + verify(siteStore, never()).createApplicationPassword(any()) + } + + @Test + fun `given no credentials, then mints and is ready`() = test { + stubHasStoredCredentials(false) + stubMintSuccess() + stubCapabilityProbe(ok = true) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Ready) + verify(siteStore).createApplicationPassword(any()) + } + + @Test + fun `given mint fails with no prior credentials, then needs first-time auth`() = test { + stubHasStoredCredentials(false) + stubMintFailure() + + val result = source.await(site) + + assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) + // Auth failed — the capability probe must not run. + verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(any()) + } + + @Test + fun `given stored credentials that fail to mint, then needs re-auth`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + + val result = source.await(site) + + assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + verify(siteStore).deleteStoredApplicationPasswordCredentials(any()) + } + + @Test + fun `given a transient validation error, then provisioning and no mint or probe`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.NetworkUnavailable) + + val result = source.await(site) + + assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Provisioning)) + verify(siteStore, never()).createApplicationPassword(any()) + verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(any()) + } + + // endregion + + // region url recovery + capability stages + + @Test + fun `given provisioned with a missing REST root, then it recovers and persists the url`() = test { + site.wpApiRestUrl = "" + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)) + .thenReturn("https://test.example.com/custom-rest") + stubCapabilityProbe(ok = true) + + source.await(site) + + verify(siteApiRestUrlRecoverer).persistApiRootUrl(eq(site.id), eq("https://test.example.com/custom-rest")) + } + + @Test + fun `given probe fails while offline, then transient error`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.TransientError) + } + + @Test + fun `given probe fails while online, then unreachable`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Unreachable) + } + + @Test + fun `given probe fails but capabilities are cached, then ready`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = false, cached = true) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Ready) + } + + // endregion + + // region single-flight / dedup / invalidate / clear + + @Test + fun `given a prior ready run, when awaited again, then it does not re-run`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + source.await(site) + source.await(site) + + verify(applicationPasswordValidator, times(1)).validate(any()) + } + + @Test + fun `given a prior ready run, when invalidated, then it re-runs`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + source.await(site) + source.invalidate(site) + source.await(site) + + verify(applicationPasswordValidator, times(2)).validate(any()) + } + + @Test + fun `given a ready site, when cleared, then the next run re-runs`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + source.await(site) + source.clear() + source.await(site) + + verify(applicationPasswordValidator, times(2)).validate(any()) + } + + // endregion +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index b19afec7af00..58b4ddc425ad 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -1,9 +1,9 @@ package org.wordpress.android.ui.mysite.cards.applicationpassword import junit.framework.TestCase.assertNull -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.flow.MutableStateFlow +import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -11,438 +11,143 @@ import org.mockito.Mock import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any -import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest -import org.mockito.kotlin.mock +import org.wordpress.android.R import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.model.SitesModel -import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError -import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient -import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCredentials -import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore -import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.repositories.EditorCapabilityDetector +import org.wordpress.android.repositories.SiteAuthState +import org.wordpress.android.repositories.SiteProvisioningSource +import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper -import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.mysite.MySiteCardAndItem -import kotlin.test.assertNotNull private const val TEST_URL = "https://www.test.com" -private const val TEST_SITE_NAME = "My Site" private const val TEST_SITE_ID = 1 -private const val TEST_SITE_ICON = "http://site.com/icon.jpg" -private const val TEST_URL_AUTH = "https://www.test.com/auth" -private const val TEST_URL_AUTH_SUFFIX = "?app_name=android-jetpack-client&success_url=callback://callback" +private const val TEST_AUTH_URL = "https://www.test.com/auth" @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { - @Mock - lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper - - @Mock - lateinit var siteStore: SiteStore - - @Mock - lateinit var appLogWrapper: AppLogWrapper - - @Mock - lateinit var wpApiClientProvider: WpApiClientProvider - - @Mock - lateinit var applicationPasswordValidator: ApplicationPasswordValidator - - @Mock - lateinit var selfHostedEndpointFinder: SelfHostedEndpointFinder - - @Mock - lateinit var siteXMLRPCClient: SiteXMLRPCClient - - @Mock - lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer - - @Mock - lateinit var dispatcher: Dispatcher - - @Mock - lateinit var editorCapabilityDetector: EditorCapabilityDetector + @Mock lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper + @Mock lateinit var siteStore: SiteStore + @Mock lateinit var appLogWrapper: AppLogWrapper + @Mock lateinit var selfHostedEndpointFinder: SelfHostedEndpointFinder + @Mock lateinit var siteXMLRPCClient: SiteXMLRPCClient + @Mock lateinit var siteProvisioningSource: SiteProvisioningSource + @Mock lateinit var dispatcher: Dispatcher private lateinit var siteTest: SiteModel - - private var applicationPasswordCard: MySiteCardAndItem? = null - - private lateinit var applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice + private var card: MySiteCardAndItem? = null + private lateinit var slice: ApplicationPasswordViewModelSlice @Before fun setUp() { MockitoAnnotations.openMocks(this) - - applicationPasswordViewModelSlice = ApplicationPasswordViewModelSlice( + slice = ApplicationPasswordViewModelSlice( applicationPasswordLoginHelper, siteStore, appLogWrapper, - wpApiClientProvider, - applicationPasswordValidator, selfHostedEndpointFinder, siteXMLRPCClient, - siteApiRestUrlRecoverer, + siteProvisioningSource, dispatcher, - editorCapabilityDetector, - testDispatcher() - ).apply { - initialize(testScope()) - } + testDispatcher(), + ).apply { initialize(testScope()) } siteTest = SiteModel().apply { id = TEST_SITE_ID url = TEST_URL - name = TEST_SITE_NAME - iconUrl = TEST_SITE_ICON - siteId = TEST_SITE_ID.toLong() - apiRestUsernamePlain = "testuser" - apiRestPasswordPlain = "testpass" - // Mark xmlRpcUrl so handleValidAuth's XML-RPC-disabled fallback doesn't fire — that - // path is exercised by the dedicated xmlRpcRediscovery tests below. - xmlRpcUrl = "https://www.test.com/xmlrpc.php" + // A WP.com-REST site by default, so a provisioned site hides the card (no XML-RPC path). + setIsWPCom(true) } - - applicationPasswordCard = null - applicationPasswordViewModelSlice.uiModel.observeForever { card -> - applicationPasswordCard = card - } - - // By default, treat the site as having no stored credentials. Tests that exercise the - // validate-stored-creds path override this. - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(true) + card = null + slice.uiModel.observeForever { card = it } } - private suspend fun stubMintFailure(notSupported: Boolean = false) { - whenever(siteStore.createApplicationPassword(any())).thenReturn( - OnApplicationPasswordCreated( - siteTest, - BaseNetworkError(GenericErrorType.UNKNOWN, "fail"), - notSupported = notSupported, - ) - ) + private fun stubReadiness(readiness: SiteReadiness): MutableStateFlow { + val flow = MutableStateFlow(readiness) + whenever(siteProvisioningSource.stateFor(siteTest)).thenReturn(flow) + return flow } - private suspend fun stubMintSuccess() { - whenever(siteStore.createApplicationPassword(any())).thenReturn( - OnApplicationPasswordCreated( - siteTest, - ApplicationPasswordCredentials("user", "pass", uuid = "u") - ) - ) - } - - @Test - fun `given proper site, when api discovery is success, then add the application password card`() = runTest { - stubMintFailure() + private suspend fun stubAuthorized() = whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn( - ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") - ) - - applicationPasswordViewModelSlice.buildCard(siteTest) - - assertNotNull(applicationPasswordCard) - verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) - } - - @Test - fun `given login scenario, when api discovery is empty, then show no card`() = runTest { - stubMintFailure() - whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed("test discovery failure")) - - applicationPasswordViewModelSlice.buildCard(siteTest) - - assertNull(applicationPasswordCard) - verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) - } - - @Test - fun `given headless mint succeeds, then hide card and skip discovery`() = runTest { - stubMintSuccess() - - applicationPasswordViewModelSlice.buildCard(siteTest) - - assertNull(applicationPasswordCard) - verify(siteStore).createApplicationPassword(any()) - verify(applicationPasswordLoginHelper, never()).getAuthorizationUrlComplete(any()) - } - - @Test - fun `given headless mint succeeds, then re-probe editor capabilities for the minted site`() = runTest { - stubMintSuccess() - - applicationPasswordViewModelSlice.buildCard(siteTest) - - // The just-minted credentials live on this exact SiteModel instance, so the detector - // re-probes against it — no stale-SiteModel re-read, capabilities settle without a refresh. - verify(editorCapabilityDetector).refresh(siteTest) - } + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Authorized(TEST_AUTH_URL)) @Test - fun `given headless mint succeeds, card hides without waiting for the recoverer`() = runTest { - stubMintSuccess() - val recoverGate = CompletableDeferred() - whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(any())) - .doSuspendableAnswer { recoverGate.await(); null } - - applicationPasswordViewModelSlice.buildCard(siteTest) + fun `given unprovisionable without prior creds, then show the create card`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) + stubAuthorized() - // Card has been hidden even though the recoverer is still suspended on the gate. - assertNull(applicationPasswordCard) - verify(siteApiRestUrlRecoverer).discoverApiRootUrl(siteTest.url) + slice.buildCard(siteTest) - // Release the recoverer so the test scope doesn't carry a dangling coroutine. - recoverGate.complete(Unit) + assertThat(card).isInstanceOf(MySiteCardAndItem.Card.QuickLinksItem::class.java) } @Test - fun `given valid stored creds, card hides without waiting for the recoverer`() = runTest { - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) - whenever(siteStore.sites).thenReturn( - listOf( - SiteModel().apply { - id = siteTest.id - url = TEST_URL - apiRestUsernamePlain = "user" - apiRestPasswordPlain = "password" - xmlRpcUrl = siteTest.xmlRpcUrl - } - ) - ) - whenever(applicationPasswordValidator.validate(any())) - .thenReturn(ApplicationPasswordValidator.Outcome.Valid) - val recoverGate = CompletableDeferred() - whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(any())) - .doSuspendableAnswer { recoverGate.await(); null } + fun `given unprovisionable with prior creds, then show the reauthentication card`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + stubAuthorized() - applicationPasswordViewModelSlice.buildCard(siteTest) + slice.buildCard(siteTest) - assertNull(applicationPasswordCard) - verify(siteApiRestUrlRecoverer).discoverApiRootUrl(TEST_URL) - - recoverGate.complete(Unit) + val banner = card as MySiteCardAndItem.Item.SingleActionCard + assertThat(banner.textResource).isEqualTo(R.string.application_password_reauthentication_banner) } @Test - fun `given headless mint returns NotSupported, then fall back to discovery`() = runTest { - stubMintFailure(notSupported = true) + fun `given unprovisionable but discovery fails, then no card`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn( - ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") - ) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed("bad discovery")) - applicationPasswordViewModelSlice.buildCard(siteTest) + slice.buildCard(siteTest) - assertNotNull(applicationPasswordCard) - verify(siteStore).createApplicationPassword(any()) - verify(applicationPasswordLoginHelper).getAuthorizationUrlComplete(eq(TEST_URL)) + assertNull(card) } @Test - fun `given site already authenticated and validation succeeds, then show no card`() = runTest { - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) - whenever(siteStore.sites).thenReturn( - listOf( - SiteModel().apply { - id = siteTest.id - url = TEST_URL - apiRestUsernamePlain = "user" - apiRestPasswordPlain = "password" - xmlRpcUrl = siteTest.xmlRpcUrl - } - ) - ) - whenever(applicationPasswordValidator.validate(any())) - .thenReturn(ApplicationPasswordValidator.Outcome.Valid) + fun `given provisioning, then no card`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Provisioning)) - applicationPasswordViewModelSlice.buildCard(siteTest) + slice.buildCard(siteTest) - assertNull(applicationPasswordCard) - verify(applicationPasswordValidator).validate(any()) - verify(siteStore, never()).createApplicationPassword(any()) - verify(applicationPasswordLoginHelper, times(0)).getAuthorizationUrlComplete(any()) + assertNull(card) } @Test - fun `given stored creds invalid, clear them and try headless mint`() = runTest { - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) - whenever(siteStore.sites).thenReturn( - listOf( - SiteModel().apply { - id = siteTest.id - url = TEST_URL - apiRestUsernamePlain = "stale-user" - apiRestPasswordPlain = "stale-pass" - xmlRpcUrl = siteTest.xmlRpcUrl - } - ) - ) - whenever(applicationPasswordValidator.validate(any())) - .thenReturn(ApplicationPasswordValidator.Outcome.Invalid) - stubMintSuccess() - - applicationPasswordViewModelSlice.buildCard(siteTest) + fun `given ready on a WPCom-REST site, then no card`() = test { + stubReadiness(SiteReadiness.Ready) - assertNull(applicationPasswordCard) - verify(siteStore).deleteStoredApplicationPasswordCredentials(any()) - // clearSelfHostedClient is invoked twice — once on invalidation, once after the fresh mint - verify(wpApiClientProvider, times(2)).clearSelfHostedClient(siteTest.id) - verify(siteStore).createApplicationPassword(any()) - } + slice.buildCard(siteTest) - @Test - fun `given stored creds invalid and mint fails, show reauthentication banner`() = runTest { - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) - whenever(siteStore.sites).thenReturn( - listOf( - SiteModel().apply { - id = siteTest.id - url = TEST_URL - apiRestUsernamePlain = "stale-user" - apiRestPasswordPlain = "stale-pass" - xmlRpcUrl = siteTest.xmlRpcUrl - } - ) - ) - whenever(applicationPasswordValidator.validate(any())) - .thenReturn(ApplicationPasswordValidator.Outcome.Invalid) - stubMintFailure(notSupported = true) - whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) - .thenReturn( - ApplicationPasswordLoginHelper.DiscoveryResult.Authorized("$TEST_URL_AUTH$TEST_URL_AUTH_SUFFIX") - ) - - applicationPasswordViewModelSlice.buildCard(siteTest) - - assertNotNull(applicationPasswordCard) - // Reauth banner uses SingleActionCard (not the QuickLinksItem create card) - assert(applicationPasswordCard is MySiteCardAndItem.Item.SingleActionCard) - } - - @Test - fun `given validation hits a network error, leave the card hidden without re-minting`() = runTest { - whenever(applicationPasswordLoginHelper.siteHasBadCredentials(any())).thenReturn(false) - whenever(siteStore.sites).thenReturn( - listOf( - SiteModel().apply { - id = siteTest.id - url = TEST_URL - apiRestUsernamePlain = "user" - apiRestPasswordPlain = "pass" - xmlRpcUrl = siteTest.xmlRpcUrl - } - ) - ) - whenever(applicationPasswordValidator.validate(any())) - .thenReturn(ApplicationPasswordValidator.Outcome.NetworkUnavailable) - - applicationPasswordViewModelSlice.buildCard(siteTest) - - assertNull(applicationPasswordCard) - verify(siteStore, never()).createApplicationPassword(any()) - verify(siteStore, never()).deleteStoredApplicationPasswordCredentials(any()) + assertNull(card) + verify(applicationPasswordLoginHelper, never()).getAuthorizationUrlComplete(any()) } @Test - fun `concurrent buildCard calls coalesce to a single mint`() = runTest { - // Gate the mint so the first buildCard suspends mid-call and the second arrives while it's - // still in flight. Without the single-flight guard, both calls would issue separate - // server-side mints and race the 409 conflict handler in ApplicationPasswordsManager. - val mintGate = CompletableDeferred() - whenever(siteStore.createApplicationPassword(any())).doSuspendableAnswer { - mintGate.await() - OnApplicationPasswordCreated( - siteTest, - ApplicationPasswordCredentials("user", "pass", uuid = "u") - ) + fun `given ready on a self-hosted site missing XML-RPC, then show the XML-RPC disabled card`() = test { + siteTest = SiteModel().apply { + id = TEST_SITE_ID + url = TEST_URL + // Not WP.com-REST and no XML-RPC endpoint — the one case the card still surfaces. } + stubReadiness(SiteReadiness.Ready) + // Let rediscovery fail so the card stays put for the assertion. + whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(TEST_URL)) + .thenThrow(mock()) - applicationPasswordViewModelSlice.buildCard(siteTest) - applicationPasswordViewModelSlice.buildCard(siteTest) - - mintGate.complete(Unit) - advanceUntilIdle() + slice.buildCard(siteTest) - verify(siteStore, times(1)).createApplicationPassword(any()) + val xmlRpcCard = card as MySiteCardAndItem.Item.SingleActionCard + assertThat(xmlRpcCard.textResource).isEqualTo(R.string.xmlrpc_disabled_card_text) } - - @Test - fun `given xmlRpc rediscovery and auth check succeed, then update site and dispatch`() = - runTest { - val xmlRpcUrl = "https://www.test.com/xmlrpc.php" - whenever( - selfHostedEndpointFinder - .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) - ).thenReturn(xmlRpcUrl) - whenever( - siteXMLRPCClient.fetchSites( - eq(xmlRpcUrl), any(), any() - ) - ).thenReturn(SitesModel(listOf(SiteModel()))) - - applicationPasswordViewModelSlice - .attemptXmlRpcRediscovery(siteTest) - - verify(dispatcher).dispatch(any()) - assert(siteTest.xmlRpcUrl == xmlRpcUrl) - } - - @Test - fun `given xmlRpc rediscovery succeeds but auth check fails, then do not dispatch`() = - runTest { - siteTest.xmlRpcUrl = null - val xmlRpcUrl = "https://www.test.com/xmlrpc.php" - whenever( - selfHostedEndpointFinder - .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) - ).thenReturn(xmlRpcUrl) - val errorResult = SitesModel().apply { - error = mock() - } - whenever( - siteXMLRPCClient.fetchSites( - eq(xmlRpcUrl), any(), any() - ) - ).thenReturn(errorResult) - - applicationPasswordViewModelSlice - .attemptXmlRpcRediscovery(siteTest) - - verify(dispatcher, never()).dispatch(any()) - assert(siteTest.xmlRpcUrl.isNullOrEmpty()) - } - - @Test - fun `given xmlRpc rediscovery fails, then do not dispatch`() = - runTest { - siteTest.xmlRpcUrl = null - whenever( - selfHostedEndpointFinder - .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) - ).thenThrow( - mock() - ) - - applicationPasswordViewModelSlice - .attemptXmlRpcRediscovery(siteTest) - - verify(selfHostedEndpointFinder) - .verifyOrDiscoverXMLRPCEndpoint(TEST_URL) - verify(dispatcher, never()).dispatch(any()) - assert(siteTest.xmlRpcUrl.isNullOrEmpty()) - } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt index 280d8135a4da..a1e9fa9b3d51 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt @@ -16,8 +16,9 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.repositories.EditorCapabilityDetectionState -import org.wordpress.android.repositories.EditorCapabilityDetector +import org.wordpress.android.repositories.SiteAuthState +import org.wordpress.android.repositories.SiteProvisioningSource +import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.mysite.MySiteCardAndItem private const val TEST_SITE_LOCAL_ID = 42 @@ -26,7 +27,7 @@ private const val TEST_SITE_LOCAL_ID = 42 @RunWith(MockitoJUnitRunner::class) class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Mock - lateinit var editorCapabilityDetector: EditorCapabilityDetector + lateinit var siteProvisioningSource: SiteProvisioningSource private lateinit var siteTest: SiteModel private lateinit var slice: SiteConnectivityBannerViewModelSlice @@ -35,23 +36,23 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Before fun setUp() { siteTest = SiteModel().apply { id = TEST_SITE_LOCAL_ID } - slice = SiteConnectivityBannerViewModelSlice(editorCapabilityDetector) + slice = SiteConnectivityBannerViewModelSlice(siteProvisioningSource) slice.initialize(testScope()) slice.uiModel.observeForever { emittedBanners.add(it) } } - private fun stubState( + private fun stubReadiness( site: SiteModel, - state: EditorCapabilityDetectionState, - ): MutableStateFlow { - val flow = MutableStateFlow(state) - whenever(editorCapabilityDetector.stateFor(site)).thenReturn(flow) + readiness: SiteReadiness, + ): MutableStateFlow { + val flow = MutableStateFlow(readiness) + whenever(siteProvisioningSource.stateFor(site)).thenReturn(flow) return flow } @Test - fun `given detection unreachable, when fetchCapabilities invoked, then banner is shown`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Unreachable) + fun `given unreachable, when fetchCapabilities invoked, then banner is shown`() = test { + stubReadiness(siteTest, SiteReadiness.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -62,8 +63,8 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { } @Test - fun `given detection ready, when fetchCapabilities invoked, then banner is null`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Ready) + fun `given ready, when fetchCapabilities invoked, then banner is null`() = test { + stubReadiness(siteTest, SiteReadiness.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -72,64 +73,73 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { } @Test - fun `given detection pending, when fetchCapabilities invoked, then banner is null`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Pending) + fun `given needs auth, when fetchCapabilities invoked, then banner is null`() = test { + // Credentials are the problem — the application-password card owns it, banner stays hidden. + stubReadiness(siteTest, SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - // Pending = probing or awaiting credentials — never a false "can't connect". assertThat(emittedBanners.last()).isNull() } @Test - fun `given detection transient error, when fetchCapabilities invoked, then banner is null`() = test { - stubState(siteTest, EditorCapabilityDetectionState.TransientError) + fun `given probing, when fetchCapabilities invoked, then banner is null`() = test { + stubReadiness(siteTest, SiteReadiness.Probing) + + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given transient error, when fetchCapabilities invoked, then banner is null`() = test { + stubReadiness(siteTest, SiteReadiness.TransientError) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - // Transient (e.g. offline) is covered by the global indicator — don't stack a warning. assertThat(emittedBanners.last()).isNull() } @Test fun `given unreachable then recovered to ready, when state changes, then banner clears`() = test { - val flow = stubState(siteTest, EditorCapabilityDetectionState.Unreachable) + val flow = stubReadiness(siteTest, SiteReadiness.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() assertThat(emittedBanners.last()).isNotNull - flow.value = EditorCapabilityDetectionState.Ready + flow.value = SiteReadiness.Ready advanceUntilIdle() assertThat(emittedBanners.last()).isNull() } @Test - fun `given user-initiated, when fetchCapabilities invoked, then detector is refreshed`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Ready) + fun `given user-initiated, when fetchCapabilities invoked, then source is invalidated`() = test { + stubReadiness(siteTest, SiteReadiness.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = true) advanceUntilIdle() - verify(editorCapabilityDetector).refresh(siteTest) + verify(siteProvisioningSource).invalidate(siteTest) } @Test - fun `given non-user-initiated, when fetchCapabilities invoked, then detector is not refreshed`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Ready) + fun `given non-user-initiated, when fetchCapabilities invoked, then source is not invalidated`() = test { + stubReadiness(siteTest, SiteReadiness.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() - verify(editorCapabilityDetector, never()).refresh(any()) + verify(siteProvisioningSource, never()).invalidate(any()) } @Test - fun `given banner showing, when retry tapped, then detector is refreshed`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Unreachable) + fun `given banner showing, when retry tapped, then source is invalidated`() = test { + stubReadiness(siteTest, SiteReadiness.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -138,12 +148,12 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { banner.onActionClick() advanceUntilIdle() - verify(editorCapabilityDetector).refresh(siteTest) + verify(siteProvisioningSource).invalidate(siteTest) } @Test fun `when clearBanner invoked, then banner is null`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Unreachable) + stubReadiness(siteTest, SiteReadiness.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -155,8 +165,8 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { } @Test - fun `given banner cleared, when retry tapped, then no refresh runs`() = test { - stubState(siteTest, EditorCapabilityDetectionState.Unreachable) + fun `given banner cleared, when retry tapped, then no invalidate runs`() = test { + stubReadiness(siteTest, SiteReadiness.Unreachable) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() @@ -164,26 +174,24 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { slice.clearBanner() advanceUntilIdle() - // Simulate a tap that landed before LiveData propagated the null clear. banner.onActionClick() advanceUntilIdle() - verify(editorCapabilityDetector, never()).refresh(any()) + verify(siteProvisioningSource, never()).invalidate(any()) } @Test fun `given site switched, when old site becomes unreachable, then banner ignores it`() = test { val siteB = SiteModel().apply { id = TEST_SITE_LOCAL_ID + 1 } - val flowA = stubState(siteTest, EditorCapabilityDetectionState.Ready) - stubState(siteB, EditorCapabilityDetectionState.Ready) + val flowA = stubReadiness(siteTest, SiteReadiness.Ready) + stubReadiness(siteB, SiteReadiness.Ready) slice.fetchCapabilities(siteTest, isUserInitiated = false) advanceUntilIdle() slice.fetchCapabilities(siteB, isUserInitiated = false) advanceUntilIdle() - // Site A's probe resolves to Unreachable after we've switched to B — must not surface. - flowA.value = EditorCapabilityDetectionState.Unreachable + flowA.value = SiteReadiness.Unreachable advanceUntilIdle() assertThat( diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt index 40a1895ef3ac..17ec4557b451 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -19,8 +19,8 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.datasets.SiteSettingsProvider import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore -import org.wordpress.android.repositories.EditorCapabilityDetector -import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.gutenberg.model.EditorAssetBundle import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies @@ -49,10 +49,10 @@ class GutenbergEditorPreloaderTest : lateinit var editorServiceProvider: EditorServiceProvider @Mock - lateinit var editorCapabilityDetector: EditorCapabilityDetector + lateinit var siteProvisioningSource: SiteProvisioningSource @Mock - lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer + lateinit var siteStore: SiteStore private val editorDependencies = EditorDependencies.empty @@ -75,8 +75,8 @@ class GutenbergEditorPreloaderTest : gutenbergKitSettingsBuilder = gutenbergKitSettingsBuilder, siteSettingsProvider = siteSettingsProvider, editorServiceProvider = editorServiceProvider, - editorCapabilityDetector = editorCapabilityDetector, - siteApiRestUrlRecoverer = siteApiRestUrlRecoverer, + siteProvisioningSource = siteProvisioningSource, + siteStore = siteStore, bgDispatcher = testDispatcher() ) } @@ -194,7 +194,7 @@ class GutenbergEditorPreloaderTest : } @Test - fun `successful preload detects editor capabilities via the detector`() = test { + fun `successful preload runs the provisioning source`() = test { val site = createSite() enablePreloading(site) stubSuccessfulPreload() @@ -203,7 +203,7 @@ class GutenbergEditorPreloaderTest : preloader.preloadIfNeeded(site, this) advanceUntilIdle() - verify(editorCapabilityDetector).awaitProbe(site) + verify(siteProvisioningSource).await(site) } @Test @@ -482,21 +482,4 @@ class GutenbergEditorPreloaderTest : // endregion - // region wpApiRestUrl recovery - - @Test - fun `successful preload invokes discovery only — slice owns persistence`() = test { - val site = createSite() - enablePreloading(site) - stubSuccessfulPreload() - stubEditorService() - - preloader.preloadIfNeeded(site, this) - advanceUntilIdle() - - verify(siteApiRestUrlRecoverer).discoverApiRootUrl(site.url) - verify(siteApiRestUrlRecoverer, never()).persistApiRootUrl(any(), any()) - } - - // endregion } From e688348c685887b8e2c33bbf7143bfc563f8b01a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:00:15 -0600 Subject: [PATCH 05/32] Read fresh / write targeted per stage; run XML-RPC recovery in parallel Removes the threaded, mutated SiteModel from the provisioning pipeline: each stage now reads the site fresh by local id and writes back only the column it changed (persistApiRootUrl / a new persistXmlRpcUrl), so the parallel branches can't clobber one another and there's no stale-model write (#22905). With writes targeted, XML-RPC endpoint recovery moves out of the application-password card into the pipeline as a parallel branch off ensureAuth -- independent of the REST capability probe, since it needs only the credentials. The card becomes a pure renderer that reads the recovered xmlRpcUrl fresh. Adds SiteSqlUtils.updateXmlRpcUrl and a SiteXmlRpcUrlRecoverer mirroring SiteApiRestUrlRecoverer. --- .../repositories/SiteProvisioningSource.kt | 121 +++++++++++------- .../accounts/login/SiteXmlRpcUrlRecoverer.kt | 64 +++++++++ .../ApplicationPasswordViewModelSlice.kt | 68 ++-------- .../SiteProvisioningSourceTest.kt | 44 ++++++- .../login/SiteXmlRpcUrlRecovererTest.kt | 96 ++++++++++++++ .../ApplicationPasswordViewModelSliceTest.kt | 30 ++--- 6 files changed, 290 insertions(+), 133 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index e5c8ad7792fd..1e1dbab82863 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -2,6 +2,8 @@ package org.wordpress.android.repositories import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -12,6 +14,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.APPLICATION_SCOPE import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper @@ -22,28 +25,35 @@ import javax.inject.Singleton /** * The single source of truth for getting a site ready to use: it provisions - * application-password credentials, recovers the REST API root, and detects - * editor capabilities — **in that order, each stage awaited before the next**. + * application-password credentials, recovers the REST API root, recovers the + * XML-RPC endpoint (self-hosted), and detects editor capabilities. * - * Those three things have a genuine ordering dependency (you can't probe the - * REST API of a private Atomic host until you've minted a credential for it), - * and they used to be spread across the connectivity banner, the editor - * preloader, and the application-password card, each triggering its slice of - * the work independently and racing the others. Running them as one serialized - * per-site pipeline makes the race structurally impossible: [detectCapabilities] - * is downstream of [ensureAuth], so the probe can never run before the mint. + * Capability detection can't run until a credential exists (you can't probe a + * private Atomic host's REST API unauthenticated), so **auth is awaited first**; + * after that, the REST-capability branch and the XML-RPC branch are independent + * and run **in parallel**. Routing every consumer (connectivity banner, editor + * preloader, application-password card) through this one pipeline means the + * first-login race is structurally impossible — the probe is downstream of the + * mint — and there's one shared, deduplicated run per site instead of each + * consumer racing the others. + * + * ### No model is held across stages + * Stages take a **`siteLocalId`**, read the `SiteModel` fresh from the store at + * the point of use, and write back **only the one column they changed** + * (`persistApiRootUrl` / `persistXmlRpcUrl`). Nothing keeps a mutated `SiteModel` + * around, so the two parallel branches can't clobber each other and there's no + * stale-model write (see #22905). The passed `SiteModel` is used for its id only. * * Per site there is at most one in-flight pipeline (single-flight, keyed by - * [SiteModel.id]); concurrent callers join it rather than starting a second — - * which also subsumes the application-password card's old single-flight guard - * (two concurrent mints hit a 409 that destroys the winner's credentials). + * [SiteModel.id]); concurrent callers join it — which also subsumes the + * application-password card's old single-flight guard (two concurrent mints hit + * a 409 that destroys the winner's credentials). * * ## Entry points - * - [stateFor] — reactive: returns a shared [StateFlow]; the first access runs - * the pipeline, later accesses reuse a [SiteReadiness.Ready] result. + * - [stateFor] — reactive: returns a shared [StateFlow]; first access runs the + * pipeline, later accesses reuse a [SiteReadiness.Ready] result. * - [await] — one-shot: runs the pipeline (if needed) and returns the result. - * - [invalidate] — forces a re-run, bypassing the once-per-site gate - * (pull-to-refresh, retry). + * - [invalidate] — forces a re-run (pull-to-refresh, retry). * - [clear] — cancels all work and drops all state; wire into sign-out. */ @Singleton @@ -54,6 +64,7 @@ class SiteProvisioningSource @Inject constructor( private val applicationPasswordValidator: ApplicationPasswordValidator, private val wpApiClientProvider: WpApiClientProvider, private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer, + private val siteXmlRpcUrlRecoverer: SiteXmlRpcUrlRecoverer, private val editorSettingsRepository: EditorSettingsRepository, private val networkUtilsWrapper: NetworkUtilsWrapper, private val appLogWrapper: AppLogWrapper, @@ -64,18 +75,18 @@ class SiteProvisioningSource @Inject constructor( // Sites whose pipeline reached Ready this process — the dedup gate. Only a fully-ready site // latches; auth-needed / unreachable / transient outcomes are left to re-run on the next - // access (so a later resume re-mints or re-probes). Reset by invalidate / clear. + // access. Reset by invalidate / clear. private val ready = ConcurrentHashMap.newKeySet() /** * The shared readiness state for [site]. The first call starts the pipeline; * later calls return the same flow without re-running once it reached - * [SiteReadiness.Ready]. Collect it to react to provisioning / capability changes. + * [SiteReadiness.Ready]. Only [SiteModel.id] is read from [site]. */ @Synchronized fun stateFor(site: SiteModel): StateFlow { val flow = flowFor(site.id) - if (shouldRun(site.id)) launchPipeline(site) + if (shouldRun(site.id)) launchPipeline(site.id) return flow } @@ -98,7 +109,7 @@ class SiteProvisioningSource @Inject constructor( fun invalidate(site: SiteModel) { if (jobs[site.id]?.isActive == true) return ready.remove(site.id) - launchPipeline(site) + launchPipeline(site.id) } /** Cancels all in-flight pipelines and drops all cached state (sign-out). */ @@ -111,13 +122,13 @@ class SiteProvisioningSource @Inject constructor( } @Synchronized - private fun launchPipeline(site: SiteModel) { - jobs[site.id]?.cancel() - val flow = flowFor(site.id) - jobs[site.id] = appScope.launch { - val readiness = runPipeline(site) + private fun launchPipeline(siteLocalId: Int) { + jobs[siteLocalId]?.cancel() + val flow = flowFor(siteLocalId) + jobs[siteLocalId] = appScope.launch { + val readiness = runPipeline(siteLocalId) flow.value = readiness - if (readiness is SiteReadiness.Ready) ready.add(site.id) + if (readiness is SiteReadiness.Ready) ready.add(siteLocalId) } } @@ -127,39 +138,40 @@ class SiteProvisioningSource @Inject constructor( private fun shouldRun(siteLocalId: Int): Boolean = jobs[siteLocalId]?.isActive != true && siteLocalId !in ready - private suspend fun runPipeline(site: SiteModel): SiteReadiness { - // Re-read from the store so we provision against the persisted SiteModel and mutate that - // instance in place — later stages (URL recovery, capability probe) see the fresh creds. - val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ?: site - return when (val auth = ensureAuth(storedSite)) { - SiteAuthState.Provisioned -> { - recoverRestUrl(storedSite) - detectCapabilities(storedSite) + private suspend fun runPipeline(siteLocalId: Int): SiteReadiness = + when (val auth = ensureAuth(siteLocalId)) { + SiteAuthState.Provisioned -> coroutineScope { + // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — + // each reads the site fresh and writes only its own column — so run them in + // parallel. recoverRestUrl precedes detectCapabilities within its branch because + // the probe needs the recovered REST root. + val capabilities = async { recoverRestUrl(siteLocalId); detectCapabilities(siteLocalId) } + val xmlRpc = async { recoverXmlRpc(siteLocalId) } + xmlRpc.await() + capabilities.await() } else -> SiteReadiness.NeedsAuth(auth) } - } /** * Stage 1 — ensure the site has working application-password credentials. * Validates stored creds with Basic auth against the direct host; on a * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack - * tunnel (the only path that works for Atomic / Jetpack-WPCom-REST sites). + * tunnel. The mint persists the credentials, so later stages read them back. */ - private suspend fun ensureAuth(site: SiteModel): SiteAuthState { + private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { + val site = siteStore.getSiteByLocalId(siteLocalId) + ?: return SiteAuthState.Unprovisionable(hadCredentials = false) val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { ApplicationPasswordValidator.Outcome.Valid -> return SiteAuthState.Provisioned ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { - // Don't punish flaky networks — treat as in-progress and retry next run. appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}") return SiteAuthState.Provisioning } ApplicationPasswordValidator.Outcome.Invalid -> { - // Stored creds are stale (revoked, deleted) — clear them so the mint below - // creates fresh ones, and invalidate the cached client. appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") siteStore.deleteStoredApplicationPasswordCredentials(site) wpApiClientProvider.clearSelfHostedClient(site.id) @@ -180,15 +192,29 @@ class SiteProvisioningSource @Inject constructor( } /** - * Stage 2 — recover the REST API root for Atomic sites minted through the - * Jetpack tunnel, which never runs discovery and so leaves `wpApiRestUrl` - * null. One owner for the heal that the card and preloader used to duplicate. + * Stage 2a — recover the REST API root for Atomic sites minted through the + * Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null). + * Persists the one column; the capability probe re-reads it. */ - private suspend fun recoverRestUrl(site: SiteModel) { + private suspend fun recoverRestUrl(siteLocalId: Int) { + val site = siteStore.getSiteByLocalId(siteLocalId) ?: return if (!site.wpApiRestUrl.isNullOrEmpty()) return siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl -> - site.wpApiRestUrl = apiRootUrl - siteApiRestUrlRecoverer.persistApiRootUrl(site.id, apiRootUrl) + siteApiRestUrlRecoverer.persistApiRootUrl(siteLocalId, apiRootUrl) + } + } + + /** + * Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted + * sites that don't have one. Discovers + authenticates against it, and on + * success persists the one column; the application-password card re-reads it. + */ + private suspend fun recoverXmlRpc(siteLocalId: Int) { + val site = siteStore.getSiteByLocalId(siteLocalId) ?: return + // WP.com / Atomic / Jetpack-WPCom-REST sites talk REST end-to-end and don't use XML-RPC. + if (site.isUsingWpComRestApi || !site.xmlRpcUrl.isNullOrEmpty()) return + siteXmlRpcUrlRecoverer.discoverAndVerifyXmlRpcUrl(site)?.let { endpoint -> + siteXmlRpcUrlRecoverer.persistXmlRpcUrl(siteLocalId, endpoint) } } @@ -198,7 +224,8 @@ class SiteProvisioningSource @Inject constructor( * guaranteed present: a failure here is a real transport problem, not a * pending mint. */ - private suspend fun detectCapabilities(site: SiteModel): SiteReadiness { + private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness { + val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) val hasCache = editorSettingsRepository.hasCachedCapabilities(site) return when { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt new file mode 100644 index 000000000000..b44dbefcf4e7 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.ui.accounts.login + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Heals [SiteModel.xmlRpcUrl] for true self-hosted sites whose XML-RPC endpoint was never + * discovered (or was previously gated). The REST counterpart is [SiteApiRestUrlRecoverer]; this + * follows the same shape so callers never hold a mutated [SiteModel]: + * + * - [discoverAndVerifyXmlRpcUrl] discovers the endpoint and confirms it works with an authenticated + * call, returning the verified URL (or `null` if discovery/verification fails). + * - [persistXmlRpcUrl] writes only that one column to the DB row for `localId`. + */ +@Singleton +class SiteXmlRpcUrlRecoverer @Inject constructor( + private val selfHostedEndpointFinder: SelfHostedEndpointFinder, + private val siteXMLRPCClient: SiteXMLRPCClient, + private val siteSqlUtils: SiteSqlUtils, + private val appLogWrapper: AppLogWrapper, + @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, +) { + @Suppress("SwallowedException") + suspend fun discoverAndVerifyXmlRpcUrl(site: SiteModel): String? = withContext(bgDispatcher) { + try { + val endpoint = selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url) + val result = siteXMLRPCClient.fetchSites( + endpoint, + site.apiRestUsernamePlain, + site.apiRestPasswordPlain, + ) + if (result.isError) { + appLogWrapper.w(AppLog.T.API, "XML-RPC verification failed for ${site.url}") + null + } else { + endpoint + } + } catch (e: SelfHostedEndpointFinder.DiscoveryException) { + appLogWrapper.w(AppLog.T.API, "XML-RPC discovery failed for ${site.url}") + null + } + } + + suspend fun persistXmlRpcUrl(localId: Int, xmlRpcUrl: String): Boolean = withContext(bgDispatcher) { + val rowsUpdated = siteSqlUtils.updateXmlRpcUrl(localId, xmlRpcUrl) + if (rowsUpdated == 0) { + appLogWrapper.w(AppLog.T.API, "Cannot persist xmlRpcUrl: no site with localId=$localId") + false + } else { + appLogWrapper.d(AppLog.T.API, "Persisted xmlRpcUrl=$xmlRpcUrl for localId=$localId") + true + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 226f93355c84..38545070abac 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -2,18 +2,11 @@ package org.wordpress.android.ui.mysite.cards.applicationpassword import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.wordpress.android.R -import androidx.annotation.VisibleForTesting -import org.wordpress.android.fluxc.Dispatcher -import org.wordpress.android.fluxc.generated.SiteActionBuilder import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder -import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.repositories.SiteAuthState @@ -27,31 +20,26 @@ import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.utils.ListItemInteraction import org.wordpress.android.ui.utils.UiString.UiStringRes import org.wordpress.android.util.AppLog -import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.viewmodel.Event import javax.inject.Inject -import javax.inject.Named /** - * Renders the application-password card from the site's readiness. Credential - * provisioning (validate / mint) now lives in [SiteProvisioningSource]; this - * slice is a view over the auth slice of that state: + * Renders the application-password card from the site's readiness. All the + * provisioning mechanics — validate, mint, REST-root recovery, XML-RPC + * recovery — live in [SiteProvisioningSource]; this slice is a thin view over + * the result: * * - [SiteAuthState.Unprovisionable] → a re-authentication banner (creds went * bad) or a first-time "authenticate" card, looked up lazily. * - provisioned (any non-[SiteReadiness.NeedsAuth] terminal state) → hidden, - * except true self-hosted sites missing an XML-RPC endpoint, which get the - * XML-RPC-disabled card plus a background rediscovery attempt. + * except true self-hosted sites whose XML-RPC endpoint the pipeline couldn't + * recover, which get the XML-RPC-disabled card. */ class ApplicationPasswordViewModelSlice @Inject constructor( private val applicationPasswordLoginHelper: ApplicationPasswordLoginHelper, private val siteStore: SiteStore, private val appLogWrapper: AppLogWrapper, - private val selfHostedEndpointFinder: SelfHostedEndpointFinder, - private val siteXMLRPCClient: SiteXMLRPCClient, private val siteProvisioningSource: SiteProvisioningSource, - private val dispatcher: Dispatcher, - @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { lateinit var scope: CoroutineScope @@ -107,13 +95,11 @@ class ApplicationPasswordViewModelSlice @Inject constructor( } private fun handleProvisioned(site: SiteModel) { - // Re-read the stored site so we see credentials/endpoints the pipeline just persisted. - val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ?: site - // Only true self-hosted sites need the XML-RPC fallback path — Atomic and Jetpack-WPCom-REST - // sites talk REST end-to-end and don't need XML-RPC. + // Read fresh: the pipeline's parallel XML-RPC branch may have just recovered the endpoint. + val storedSite = siteStore.getSiteByLocalId(site.id) ?: site + // Only true self-hosted sites need XML-RPC; if the pipeline couldn't recover it, surface it. if (!storedSite.isUsingWpComRestApi && storedSite.xmlRpcUrl.isNullOrEmpty()) { buildXmlRpcDisabledCard(storedSite) - attemptXmlRpcRediscovery(storedSite) } else { uiModelMutable.postValue(null) appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - authenticated") @@ -197,42 +183,6 @@ class ApplicationPasswordViewModelSlice @Inject constructor( ) } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun attemptXmlRpcRediscovery(site: SiteModel) { - scope.launch { - try { - val xmlRpcEndpoint = withContext(ioDispatcher) { - selfHostedEndpointFinder - .verifyOrDiscoverXMLRPCEndpoint(site.url) - } - - // Verify with an authenticated call - val result = withContext(ioDispatcher) { - siteXMLRPCClient.fetchSites( - xmlRpcEndpoint, - site.apiRestUsernamePlain, - site.apiRestPasswordPlain - ) - } - if (result.isError) { - return@launch - } - - site.xmlRpcUrl = xmlRpcEndpoint - dispatcher.dispatch( - SiteActionBuilder.newUpdateSiteAction(site) - ) - // Endpoint recovered — hide the XML-RPC-disabled card. - uiModelMutable.postValue(null) - } catch ( - @Suppress("SwallowedException") - e: SelfHostedEndpointFinder.DiscoveryException - ) { - // XML-RPC rediscovery failed; card remains visible - } - } - } - private fun onClick(site: SiteModel, alternativeUrl: String) { _onNavigation.postValue( Event( diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index a695ed3b6582..0c5fae5796de 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -23,6 +23,7 @@ import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer +import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator import org.wordpress.android.util.NetworkUtilsWrapper @@ -35,6 +36,7 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Mock lateinit var applicationPasswordValidator: ApplicationPasswordValidator @Mock lateinit var wpApiClientProvider: WpApiClientProvider @Mock lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer + @Mock lateinit var siteXmlRpcUrlRecoverer: SiteXmlRpcUrlRecoverer @Mock lateinit var editorSettingsRepository: EditorSettingsRepository @Mock lateinit var networkUtilsWrapper: NetworkUtilsWrapper @Mock lateinit var appLogWrapper: AppLogWrapper @@ -47,15 +49,19 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { site = SiteModel().apply { id = TEST_SITE_LOCAL_ID url = "https://test.example.com" - // A non-null REST root so recoverRestUrl short-circuits unless a test clears it. + // Non-null REST root + XML-RPC url so both recovery branches short-circuit unless a + // test clears them — keeping the auth/capability tests focused. wpApiRestUrl = "https://test.example.com/wp-json" + xmlRpcUrl = "https://test.example.com/xmlrpc.php" } + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(site) source = SiteProvisioningSource( siteStore, applicationPasswordLoginHelper, applicationPasswordValidator, wpApiClientProvider, siteApiRestUrlRecoverer, + siteXmlRpcUrlRecoverer, editorSettingsRepository, networkUtilsWrapper, appLogWrapper, @@ -81,8 +87,7 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { private suspend fun stubCapabilityProbe(ok: Boolean, cached: Boolean = false) { whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenReturn(ok) - // `ok || hasCache` short-circuits, so the cache is only read (and only needs stubbing) - // when the live probe failed — stubbing it on success would be an unnecessary stub. + // `ok || hasCache` short-circuits, so only stub the cache when the live probe failed. if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached) } @@ -116,7 +121,6 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { val result = source.await(site) assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) - // Auth failed — the capability probe must not run. verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(any()) } @@ -144,9 +148,18 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(any()) } + @Test + fun `given the site is gone from the store, then unprovisionable`() = test { + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(null) + + val result = source.await(site) + + assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) + } + // endregion - // region url recovery + capability stages + // region recovery + capability stages @Test fun `given provisioned with a missing REST root, then it recovers and persists the url`() = test { @@ -162,6 +175,27 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(siteApiRestUrlRecoverer).persistApiRootUrl(eq(site.id), eq("https://test.example.com/custom-rest")) } + @Test + fun `given a provisioned self-hosted site without XML-RPC, then it recovers and persists xmlRpcUrl`() = test { + val selfHosted = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://selfhosted.example.com" + wpApiRestUrl = "https://selfhosted.example.com/wp-json" // REST branch short-circuits + // not WP.com and no xmlRpcUrl → the XML-RPC branch runs + } + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(selfHosted) + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + whenever(siteXmlRpcUrlRecoverer.discoverAndVerifyXmlRpcUrl(selfHosted)) + .thenReturn("https://selfhosted.example.com/xmlrpc.php") + + source.await(selfHosted) + + verify(siteXmlRpcUrlRecoverer) + .persistXmlRpcUrl(eq(TEST_SITE_LOCAL_ID), eq("https://selfhosted.example.com/xmlrpc.php")) + } + @Test fun `given probe fails while offline, then transient error`() = test { stubHasStoredCredentials(true) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt new file mode 100644 index 000000000000..d895c67265ab --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.ui.accounts.login + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +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.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.utils.AppLogWrapper + +private const val ENDPOINT = "https://selfhosted.example.com/xmlrpc.php" +private const val SITE_LOCAL_ID = 5 + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SiteXmlRpcUrlRecovererTest : BaseUnitTest() { + @Mock lateinit var selfHostedEndpointFinder: SelfHostedEndpointFinder + @Mock lateinit var siteXMLRPCClient: SiteXMLRPCClient + @Mock lateinit var siteSqlUtils: SiteSqlUtils + @Mock lateinit var appLogWrapper: AppLogWrapper + + private lateinit var site: SiteModel + private lateinit var recoverer: SiteXmlRpcUrlRecoverer + + @Before + fun setUp() { + site = SiteModel().apply { + id = SITE_LOCAL_ID + url = "https://selfhosted.example.com" + apiRestUsernamePlain = "user" + apiRestPasswordPlain = "pass" + } + recoverer = SiteXmlRpcUrlRecoverer( + selfHostedEndpointFinder, + siteXMLRPCClient, + siteSqlUtils, + appLogWrapper, + testDispatcher(), + ) + } + + @Test + fun `given discovery and authenticated verify succeed, then returns the endpoint`() = test { + whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)).thenReturn(ENDPOINT) + whenever(siteXMLRPCClient.fetchSites(eq(ENDPOINT), any(), any())) + .thenReturn(SitesModel(listOf(SiteModel()))) + + assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isEqualTo(ENDPOINT) + } + + @Test + fun `given discovery throws, then returns null`() = test { + whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)) + .thenThrow(mock()) + + assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isNull() + } + + @Test + fun `given the authenticated verify errors, then returns null`() = test { + whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)).thenReturn(ENDPOINT) + whenever(siteXMLRPCClient.fetchSites(eq(ENDPOINT), any(), any())) + .thenReturn(SitesModel().apply { error = BaseNetworkError(GenericErrorType.UNKNOWN, "x") }) + + assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isNull() + } + + @Test + fun `given persist updates a row, then returns true`() = test { + whenever(siteSqlUtils.updateXmlRpcUrl(eq(SITE_LOCAL_ID), eq(ENDPOINT))).thenReturn(1) + + assertThat(recoverer.persistXmlRpcUrl(SITE_LOCAL_ID, ENDPOINT)).isTrue + verify(siteSqlUtils).updateXmlRpcUrl(eq(SITE_LOCAL_ID), eq(ENDPOINT)) + } + + @Test + fun `given persist matches no row, then returns false`() = test { + whenever(siteSqlUtils.updateXmlRpcUrl(eq(SITE_LOCAL_ID), eq(ENDPOINT))).thenReturn(0) + + assertThat(recoverer.persistXmlRpcUrl(SITE_LOCAL_ID, ENDPOINT)).isFalse + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index 58b4ddc425ad..090966a4b3e1 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -12,16 +12,12 @@ import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.eq -import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R -import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder -import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.repositories.SiteAuthState @@ -40,10 +36,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Mock lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper @Mock lateinit var siteStore: SiteStore @Mock lateinit var appLogWrapper: AppLogWrapper - @Mock lateinit var selfHostedEndpointFinder: SelfHostedEndpointFinder - @Mock lateinit var siteXMLRPCClient: SiteXMLRPCClient @Mock lateinit var siteProvisioningSource: SiteProvisioningSource - @Mock lateinit var dispatcher: Dispatcher private lateinit var siteTest: SiteModel private var card: MySiteCardAndItem? = null @@ -56,17 +49,11 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { applicationPasswordLoginHelper, siteStore, appLogWrapper, - selfHostedEndpointFinder, - siteXMLRPCClient, siteProvisioningSource, - dispatcher, - testDispatcher(), ).apply { initialize(testScope()) } siteTest = SiteModel().apply { id = TEST_SITE_ID url = TEST_URL - // A WP.com-REST site by default, so a provisioned site hides the card (no XML-RPC path). - setIsWPCom(true) } card = null slice.uiModel.observeForever { card = it } @@ -126,6 +113,9 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { @Test fun `given ready on a WPCom-REST site, then no card`() = test { stubReadiness(SiteReadiness.Ready) + whenever(siteStore.getSiteByLocalId(TEST_SITE_ID)).thenReturn( + SiteModel().apply { id = TEST_SITE_ID; url = TEST_URL; setIsWPCom(true) } + ) slice.buildCard(siteTest) @@ -134,16 +124,12 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { } @Test - fun `given ready on a self-hosted site missing XML-RPC, then show the XML-RPC disabled card`() = test { - siteTest = SiteModel().apply { - id = TEST_SITE_ID - url = TEST_URL - // Not WP.com-REST and no XML-RPC endpoint — the one case the card still surfaces. - } + fun `given ready on a self-hosted site whose XML-RPC stayed unrecovered, then show the disabled card`() = test { stubReadiness(SiteReadiness.Ready) - // Let rediscovery fail so the card stays put for the assertion. - whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(TEST_URL)) - .thenThrow(mock()) + // Not WP.com-REST and still no XML-RPC endpoint after the pipeline's recovery attempt. + whenever(siteStore.getSiteByLocalId(TEST_SITE_ID)).thenReturn( + SiteModel().apply { id = TEST_SITE_ID; url = TEST_URL } + ) slice.buildCard(siteTest) From 4861cb8238fcfb25ec7e99dcee2d1e67d6b24e75 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:26:36 -0600 Subject: [PATCH 06/32] Satisfy detekt and checkstyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Suppress("ReturnCount") on ensureAuth — its five returns are each a distinct auth outcome; a single-return rewrite would read worse. - Drop a stray blank line before a brace left from removing a test region. --- .../wordpress/android/repositories/SiteProvisioningSource.kt | 3 +++ .../wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 1e1dbab82863..a2a1f0ef662d 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -159,6 +159,9 @@ class SiteProvisioningSource @Inject constructor( * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack * tunnel. The mint persists the credentials, so later stages read them back. */ + // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed); + // collapsing to one return would thread a result through nested branches and read worse. + @Suppress("ReturnCount") private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteAuthState.Unprovisionable(hadCredentials = false) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt index 17ec4557b451..c5989af09870 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/GutenbergEditorPreloaderTest.kt @@ -481,5 +481,4 @@ class GutenbergEditorPreloaderTest : } // endregion - } From c46b58e75d772c4e84b9f2529c3eda9a9e05dba8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:41:28 -0600 Subject: [PATCH 07/32] Rename recover stages to recoverRestUrlIfNeeded / recoverXmlRpcIfNeeded The IfNeeded suffix makes the short-circuit (skip when the field is already present / not applicable) clear at the call site. --- .../android/repositories/SiteProvisioningSource.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index a2a1f0ef662d..e5b191b30ccd 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -143,10 +143,10 @@ class SiteProvisioningSource @Inject constructor( SiteAuthState.Provisioned -> coroutineScope { // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — // each reads the site fresh and writes only its own column — so run them in - // parallel. recoverRestUrl precedes detectCapabilities within its branch because + // parallel. recoverRestUrlIfNeeded precedes detectCapabilities within its branch because // the probe needs the recovered REST root. - val capabilities = async { recoverRestUrl(siteLocalId); detectCapabilities(siteLocalId) } - val xmlRpc = async { recoverXmlRpc(siteLocalId) } + val capabilities = async { recoverRestUrlIfNeeded(siteLocalId); detectCapabilities(siteLocalId) } + val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } xmlRpc.await() capabilities.await() } @@ -199,7 +199,7 @@ class SiteProvisioningSource @Inject constructor( * Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null). * Persists the one column; the capability probe re-reads it. */ - private suspend fun recoverRestUrl(siteLocalId: Int) { + private suspend fun recoverRestUrlIfNeeded(siteLocalId: Int) { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return if (!site.wpApiRestUrl.isNullOrEmpty()) return siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl -> @@ -212,7 +212,7 @@ class SiteProvisioningSource @Inject constructor( * sites that don't have one. Discovers + authenticates against it, and on * success persists the one column; the application-password card re-reads it. */ - private suspend fun recoverXmlRpc(siteLocalId: Int) { + private suspend fun recoverXmlRpcIfNeeded(siteLocalId: Int) { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return // WP.com / Atomic / Jetpack-WPCom-REST sites talk REST end-to-end and don't use XML-RPC. if (site.isUsingWpComRestApi || !site.xmlRpcUrl.isNullOrEmpty()) return From 86d8d7c180a209460022d0dbfc67ca99d54ae721 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:02:46 -0600 Subject: [PATCH 08/32] Don't gate capability detection behind a mint for WP.com Simple sites WP.com Simple sites are proxy-served and OAuth-authed; the application-password mint returns NotSupported for them, so the pipeline was returning Unprovisionable and never reaching capability detection (which works fine through the proxy) -- a regression vs. the old ungated probe. ensureAuth now short-circuits them to a new SiteAuthState.NotApplicable (treated like Provisioned), and recoverRestUrlIfNeeded skips them too. --- .../repositories/SiteProvisioningSource.kt | 14 ++++++++++++-- .../ApplicationPasswordViewModelSlice.kt | 3 ++- .../repositories/SiteProvisioningSourceTest.kt | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index e5b191b30ccd..d946304f9280 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -140,7 +140,7 @@ class SiteProvisioningSource @Inject constructor( private suspend fun runPipeline(siteLocalId: Int): SiteReadiness = when (val auth = ensureAuth(siteLocalId)) { - SiteAuthState.Provisioned -> coroutineScope { + SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — // each reads the site fresh and writes only its own column — so run them in // parallel. recoverRestUrlIfNeeded precedes detectCapabilities within its branch because @@ -165,6 +165,10 @@ class SiteProvisioningSource @Inject constructor( private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteAuthState.Unprovisionable(hadCredentials = false) + // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password + // applies (the mint returns NotSupported). Capability detection works through the proxy, so + // treat them as ready instead of blocking detection behind a mint that can never run. + if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { @@ -201,7 +205,9 @@ class SiteProvisioningSource @Inject constructor( */ private suspend fun recoverRestUrlIfNeeded(siteLocalId: Int) { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return - if (!site.wpApiRestUrl.isNullOrEmpty()) return + // WP.com Simple sites are proxy-served — no direct REST host to recover (their wpApiRestUrl + // is legitimately null), so don't burn a discovery call on them. + if (site.isWPComSimpleSite || !site.wpApiRestUrl.isNullOrEmpty()) return siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl -> siteApiRestUrlRecoverer.persistApiRootUrl(siteLocalId, apiRootUrl) } @@ -247,6 +253,10 @@ sealed interface SiteAuthState { /** Credentials are usable (validated, or freshly minted). */ data object Provisioned : SiteAuthState + /** No application password applies — a WP.com Simple site, which is proxy-served and + * OAuth-bearer-authed. Treated like [Provisioned]: capability detection runs via the proxy. */ + data object NotApplicable : SiteAuthState + /** Not usable yet, but not a terminal failure — a mint is implied / a transient * validation error occurred. The card stays hidden; the next run retries. */ data object Provisioning : SiteAuthState diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 38545070abac..69a1cc998b86 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -82,7 +82,8 @@ class ApplicationPasswordViewModelSlice @Inject constructor( uiModelMutable.postValue(null) appLogWrapper.d(AppLog.T.MAIN, "A_P: Provisioning in progress for ${site.url}") } - SiteAuthState.Provisioned -> Unit // unreachable: Provisioned never wraps in NeedsAuth + SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> + Unit // never wrap in NeedsAuth — they proceed to detection } // Any terminal provisioned state — the credentials are usable, so the only card left to // show is the self-hosted XML-RPC fallback. Capability outcome (Ready/Unreachable) is the diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 0c5fae5796de..aa44d02a5e42 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -157,6 +157,23 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { assertThat(result).isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) } + @Test + fun `given a WPCom Simple site, then it detects capabilities without minting`() = test { + val simple = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://simple.wordpress.com" + setIsWPCom(true) // isWPComSimpleSite = isWPCom && !isWPComAtomic + wpApiRestUrl = "https://simple.wordpress.com/wp-json" + } + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(simple) + stubCapabilityProbe(ok = true) + + assertThat(source.await(simple)).isEqualTo(SiteReadiness.Ready) + // No application password applies — it must not validate or mint, just probe via the proxy. + verify(applicationPasswordValidator, never()).validate(any()) + verify(siteStore, never()).createApplicationPassword(any()) + } + // endregion // region recovery + capability stages From 166309b26b4214fc2bd158c564e4a0abbe8ea27e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:17:46 -0600 Subject: [PATCH 09/32] Probe the direct host for Jetpack capability detection, not the proxy The route-support probe only sent Atomic sites to the direct host; Jetpack WPCom-REST sites fell through to the WP.com proxy. Since the proxy and the direct host advertise different route lists (the #22879 premise), Jetpack sites got the wrong answer. Broaden the predicate to isUsingWpComRestApi && !isWPComSimpleSite (Atomic + Jetpack); the proxy is only for minting the application password. --- .../repositories/EditorSettingsRepository.kt | 11 +++---- .../EditorSettingsRepositoryTest.kt | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index bce5a2d3dcf4..d57b7b8c44f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -108,11 +108,12 @@ class EditorSettingsRepository @Inject constructor( private suspend fun fetchRouteSupport( site: SiteModel ): Boolean = try { - // For Atomic sites the editor fetches `wp-block-editor/v1/settings` - // from the direct host — proxy and direct host can advertise - // different route lists, so detection has to probe the direct host - // too. See #22879. - if (site.isWPComAtomic) { + // Atomic and Jetpack-WPCom-REST sites have their own REST host that the editor talks to + // directly — the WP.com proxy and the direct host advertise different route lists, so + // detection has to probe the direct host too. The proxy is only for minting the application + // password. WP.com Simple sites have no direct host (the WP.com REST API *is* their API), + // and self-hosted sites are already direct via the configured client. See #22879. + if (site.isUsingWpComRestApi && !site.isWPComSimpleSite) { fetchRouteSupportViaDirectHostDiscovery(site) } else { fetchRouteSupportViaConfiguredClient(site) diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt index c4371b8675fb..a3bd6301355c 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/EditorSettingsRepositoryTest.kt @@ -229,6 +229,36 @@ class EditorSettingsRepositoryTest : BaseUnitTest() { verify(wpApiClientProvider, never()).getWpApiClient(atomicSite) } + @Test + fun `jetpack site probes the direct host, not the WP_com proxy`() = + runTest { + val jetpackSite = SiteModel().apply { + id = 7 + url = "https://jetpack.example.com" + // isUsingWpComRestApi via Jetpack, but not Atomic and not WP.com Simple → direct host. + setIsJetpackConnected(true) + setOrigin(SiteModel.ORIGIN_WPCOM_REST) + } + mockDiscoverySuccess( + siteUrl = jetpackSite.url, + hasEditorSettings = true, + hasEditorAssets = true + ) + whenever(themeRepository.fetchCurrentTheme(jetpackSite)) + .thenReturn(buildTheme(isBlockTheme = false)) + + val result = + repository.fetchEditorCapabilitiesForSite(jetpackSite) + + assertThat(result).isTrue() + verify(appPrefsWrapper) + .setSiteSupportsEditorSettings(jetpackSite, true) + verify(appPrefsWrapper) + .setSiteSupportsEditorAssets(jetpackSite, true) + // The proxy is only for minting — capability detection goes direct. + verify(wpApiClientProvider, never()).getWpApiClient(jetpackSite) + } + @Test fun `atomic site returns false when discovery fails`() = runTest { From bab2b773704035d53fd9ea48ccc38b01b316dd2d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:47:00 -0600 Subject: [PATCH 10/32] Carry minted credentials to the capability probe as a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a first provision of an Atomic site, the My Site screen showed a false "Unable to connect to your site" banner. `ensureAuth` minted an application password, but `detectCapabilities` re-read the `SiteModel` fresh and a concurrent whole-row site write (`insertOrUpdateSite` uses `UpdateAllExceptId`) had clobbered the just-encrypted credential columns before that read (#22905). With no credentials present, the authenticated direct-host probe was skipped, the unauthenticated discovery failed against the private host, and the probe reported the site unreachable. `ensureAuth` now returns the credentials it obtained in an internal `AuthResult`, and `detectCapabilities` overlays that immutable value onto its own coroutine-local `SiteModel` copy. The stages still read fresh and write only their own column — no `SiteModel` is shared or mutated across the parallel detect/`recoverXmlRpc` branches, so there's no race. Verified on-device against a private Atomic site: the authenticated direct-host probe runs and the banner is gone. --- .../repositories/SiteProvisioningSource.kt | 92 ++++++++++++++----- .../SiteProvisioningSourceTest.kt | 36 ++++++++ 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index d946304f9280..24a2cf187b21 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -138,45 +138,55 @@ class SiteProvisioningSource @Inject constructor( private fun shouldRun(siteLocalId: Int): Boolean = jobs[siteLocalId]?.isActive != true && siteLocalId !in ready - private suspend fun runPipeline(siteLocalId: Int): SiteReadiness = - when (val auth = ensureAuth(siteLocalId)) { + private suspend fun runPipeline(siteLocalId: Int): SiteReadiness { + val auth = ensureAuth(siteLocalId) + return when (auth.state) { SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { - // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — - // each reads the site fresh and writes only its own column — so run them in - // parallel. recoverRestUrlIfNeeded precedes detectCapabilities within its branch because - // the probe needs the recovered REST root. - val capabilities = async { recoverRestUrlIfNeeded(siteLocalId); detectCapabilities(siteLocalId) } + // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each + // reads the site fresh and writes only its own column — so run them in parallel. + // recoverRestUrlIfNeeded precedes detectCapabilities within its branch because the probe + // needs the recovered REST root. The mint's credentials reach the probe as an immutable + // value (auth.credentials), never via a shared mutated SiteModel — the fresh-read columns + // can't be trusted to carry them (transient, and clobberable by a concurrent write, #22905). + val capabilities = async { + recoverRestUrlIfNeeded(siteLocalId) + detectCapabilities(siteLocalId, auth.credentials) + } val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } xmlRpc.await() capabilities.await() } - else -> SiteReadiness.NeedsAuth(auth) + else -> SiteReadiness.NeedsAuth(auth.state) } + } /** - * Stage 1 — ensure the site has working application-password credentials. - * Validates stored creds with Basic auth against the direct host; on a - * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack - * tunnel. The mint persists the credentials, so later stages read them back. + * Stage 1 — ensure the site has working application-password credentials, and + * return them. Validates stored creds with Basic auth against the direct host; + * on a confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack + * tunnel. The credentials are returned in the [AuthResult] so [detectCapabilities] + * can authenticate with them without trusting a fresh re-read — the plain columns + * are transient and a concurrent whole-row site write can clobber the encrypted + * ones mid-run (#22905). */ // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed); // collapsing to one return would thread a result through nested branches and read worse. @Suppress("ReturnCount") - private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { + private suspend fun ensureAuth(siteLocalId: Int): AuthResult { val site = siteStore.getSiteByLocalId(siteLocalId) - ?: return SiteAuthState.Unprovisionable(hadCredentials = false) + ?: return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = false)) // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password // applies (the mint returns NotSupported). Capability detection works through the proxy, so // treat them as ready instead of blocking detection behind a mint that can never run. - if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable + if (site.isWPComSimpleSite) return AuthResult(SiteAuthState.NotApplicable) val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { ApplicationPasswordValidator.Outcome.Valid -> - return SiteAuthState.Provisioned + return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials()) ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}") - return SiteAuthState.Provisioning + return AuthResult(SiteAuthState.Provisioning) } ApplicationPasswordValidator.Outcome.Invalid -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") @@ -185,23 +195,24 @@ class SiteProvisioningSource @Inject constructor( } } } + // createApplicationPassword mutates this local `site` with the freshly minted plain credentials. val createResult = siteStore.createApplicationPassword(site) if (!createResult.isError && createResult.credentials != null) { wpApiClientProvider.clearSelfHostedClient(site.id) appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}") - return SiteAuthState.Provisioned + return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials()) } appLogWrapper.d( AppLog.T.MAIN, "A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})" ) - return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials) + return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) } /** * Stage 2a — recover the REST API root for Atomic sites minted through the * Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null). - * Persists the one column; the capability probe re-reads it. + * Persists the one column; the capability probe (sequenced after it) re-reads it. */ private suspend fun recoverRestUrlIfNeeded(siteLocalId: Int) { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return @@ -229,12 +240,25 @@ class SiteProvisioningSource @Inject constructor( /** * Stage 3 — probe the REST API for editor-capability support and persist it. - * Reached only once auth is [SiteAuthState.Provisioned], so credentials are - * guaranteed present: a failure here is a real transport problem, not a - * pending mint. + * Reached only once auth is [SiteAuthState.Provisioned] / [SiteAuthState.NotApplicable]. + * [credentials] (from [ensureAuth]) are overlaid onto this run-local copy so the + * authenticated direct-host probe can run even when the fresh read doesn't reflect + * the mint; a failure here is then a real transport problem, not a pending mint. */ - private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness { + private suspend fun detectCapabilities( + siteLocalId: Int, + credentials: ProvisionedCredentials?, + ): SiteReadiness { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable + // Overlay the credentials ensureAuth obtained. The fresh read can't be trusted to carry them: + // the plain columns are transient (never persisted), and a concurrent whole-row site write + // during My Site load can clobber the encrypted ones before this read (#22905). This copy is + // local to this coroutine — not shared with the parallel XML-RPC stage — so the overlay is + // race-free. + credentials?.let { + if (site.apiRestUsernamePlain.isNullOrEmpty()) site.apiRestUsernamePlain = it.username + if (site.apiRestPasswordPlain.isNullOrEmpty()) site.apiRestPasswordPlain = it.password + } val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) val hasCache = editorSettingsRepository.hasCachedCapabilities(site) return when { @@ -245,6 +269,26 @@ class SiteProvisioningSource @Inject constructor( } } +/** + * Snapshot of the application-password credentials [SiteProvisioningSource.ensureAuth] obtained, + * handed forward to the capability probe as an immutable value rather than via a shared, mutated + * [SiteModel] — so the parallel stages never race on it. + */ +private data class ProvisionedCredentials(val username: String, val password: String) + +private fun SiteModel.provisionedCredentials(): ProvisionedCredentials? { + val user = apiRestUsernamePlain + val pass = apiRestPasswordPlain + return if (!user.isNullOrEmpty() && !pass.isNullOrEmpty()) ProvisionedCredentials(user, pass) else null +} + +/** + * [SiteProvisioningSource.ensureAuth]'s outcome: the public [SiteAuthState] plus, when provisioned, + * the credentials to hand to the capability probe. Internal so the credentials never leak into the + * UI-facing [SiteReadiness] / [SiteAuthState]. + */ +private data class AuthResult(val state: SiteAuthState, val credentials: ProvisionedCredentials? = null) + /** * Whether a site's application password is usable. Owned by [SiteProvisioningSource]; * rendered by the application-password card. diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index aa44d02a5e42..d9e140b48a5c 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -7,6 +7,7 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -91,6 +92,14 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached) } + private fun provisionableSiteCopy() = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://test.example.com" + // Non-null REST root + XML-RPC url so both recovery branches short-circuit. + wpApiRestUrl = "https://test.example.com/wp-json" + xmlRpcUrl = "https://test.example.com/xmlrpc.php" + } + // region auth stage @Test @@ -174,6 +183,33 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(siteStore, never()).createApplicationPassword(any()) } + @Test + fun `carries minted credentials to the probe even when the re-read lacks them`() = test { + // ensureAuth reads one copy and the mint sets credentials on it; detectCapabilities re-reads a + // SEPARATE copy that — simulating a concurrent whole-row clobber (#22905) — carries none. The + // probe must still receive a credentialed site, because the pipeline hands the mint's creds + // forward as an immutable value rather than relying on the re-read or a shared, race-prone model. + val authCopy = provisionableSiteCopy() + val credentiallessReread = provisionableSiteCopy() + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(authCopy, credentiallessReread) + stubHasStoredCredentials(false) + whenever(siteStore.createApplicationPassword(any())).thenAnswer { invocation -> + // The real createApplicationPassword sets the plain credentials on the passed site. + (invocation.arguments[0] as SiteModel).apply { + apiRestUsernamePlain = "user" + apiRestPasswordPlain = "pass" + } + OnApplicationPasswordCreated(authCopy, ApplicationPasswordCredentials("user", "pass", uuid = "u")) + } + stubCapabilityProbe(ok = true) + + source.await(site) + + verify(editorSettingsRepository).fetchEditorCapabilitiesForSite( + argThat { apiRestUsernamePlain == "user" && apiRestPasswordPlain == "pass" } + ) + } + // endregion // region recovery + capability stages From 9b38f59d041a173513a9d11bc3809545ed923747 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:26:21 -0600 Subject: [PATCH 11/32] Contain unexpected throws in SiteXmlRpcUrlRecoverer discoverAndVerifyXmlRpcUrl caught only DiscoveryException, so a RuntimeException from the discovery/verify path would escape the async, cancel the whole provisioning coroutine, and reach appScope (no SupervisorJob/handler). Catch CancellationException + generic Exception like the sibling SiteApiRestUrlRecoverer, so any failure degrades to the XML-RPC-disabled card and retries next run. --- .../accounts/login/SiteXmlRpcUrlRecoverer.kt | 18 ++++++++++++++++-- .../login/SiteXmlRpcUrlRecovererTest.kt | 13 ++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt index b44dbefcf4e7..6278d15fc424 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt @@ -12,6 +12,7 @@ import org.wordpress.android.util.AppLog import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * Heals [SiteModel.xmlRpcUrl] for true self-hosted sites whose XML-RPC endpoint was never @@ -19,7 +20,8 @@ import javax.inject.Singleton * follows the same shape so callers never hold a mutated [SiteModel]: * * - [discoverAndVerifyXmlRpcUrl] discovers the endpoint and confirms it works with an authenticated - * call, returning the verified URL (or `null` if discovery/verification fails). + * call using the site's application-password credentials, returning the verified URL (or `null` + * if discovery/verification fails). * - [persistXmlRpcUrl] writes only that one column to the DB row for `localId`. */ @Singleton @@ -30,7 +32,7 @@ class SiteXmlRpcUrlRecoverer @Inject constructor( private val appLogWrapper: AppLogWrapper, @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, ) { - @Suppress("SwallowedException") + @Suppress("SwallowedException", "TooGenericExceptionCaught") suspend fun discoverAndVerifyXmlRpcUrl(site: SiteModel): String? = withContext(bgDispatcher) { try { val endpoint = selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url) @@ -45,9 +47,21 @@ class SiteXmlRpcUrlRecoverer @Inject constructor( } else { endpoint } + } catch (e: CancellationException) { + throw e } catch (e: SelfHostedEndpointFinder.DiscoveryException) { + // Expected when the site has no reachable XML-RPC endpoint — surfaces as the + // XML-RPC-disabled card (xmlRpcUrl stays empty) and retries on the next run. appLogWrapper.w(AppLog.T.API, "XML-RPC discovery failed for ${site.url}") null + } catch (e: Exception) { + // Best-effort recovery must never let an unexpected throw escape and cancel the + // provisioning pipeline (mirrors SiteApiRestUrlRecoverer). Same null -> disabled-card surface. + appLogWrapper.e( + AppLog.T.API, + "XML-RPC discovery threw for ${site.url}: ${e::class.simpleName}: ${e.message}" + ) + null } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt index d895c67265ab..3ce345c0fc41 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecovererTest.kt @@ -56,7 +56,8 @@ class SiteXmlRpcUrlRecovererTest : BaseUnitTest() { @Test fun `given discovery and authenticated verify succeed, then returns the endpoint`() = test { whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)).thenReturn(ENDPOINT) - whenever(siteXMLRPCClient.fetchSites(eq(ENDPOINT), any(), any())) + // The site's stored credentials are forwarded to the authenticated verify call. + whenever(siteXMLRPCClient.fetchSites(eq(ENDPOINT), eq("user"), eq("pass"))) .thenReturn(SitesModel(listOf(SiteModel()))) assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isEqualTo(ENDPOINT) @@ -70,6 +71,16 @@ class SiteXmlRpcUrlRecovererTest : BaseUnitTest() { assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isNull() } + @Test + fun `given discovery throws an unexpected exception, then returns null`() = test { + // A non-DiscoveryException (e.g. a RuntimeException from the network/parse path) must be + // contained, not propagated — otherwise it cancels the whole provisioning pipeline. + whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)) + .thenThrow(RuntimeException("unexpected")) + + assertThat(recoverer.discoverAndVerifyXmlRpcUrl(site)).isNull() + } + @Test fun `given the authenticated verify errors, then returns null`() = test { whenever(selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)).thenReturn(ENDPOINT) From 3f20dc4fdd6bd48940a2184d7fc3d212be608e6e Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:26:30 -0600 Subject: [PATCH 12/32] Drop credential forwarding now that app-password columns are single-writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #22947 made the app-password columns single-writer — excluded from the generic full-row update, written only by updateApplicationPasswordCredentials — so a fresh getSiteByLocalId after a mint reliably carries the credentials and a concurrent site write can't clobber them (#22905). The pipeline no longer needs to thread them as a value: remove ProvisionedCredentials, AuthResult.credentials, and the detectCapabilities overlay. ensureAuth returns a bare SiteAuthState; detectCapabilities and recoverXmlRpcIfNeeded read the credentials off the fresh read. --- .../repositories/SiteProvisioningSource.kt | 86 ++++++------------- .../SiteProvisioningSourceTest.kt | 36 -------- 2 files changed, 26 insertions(+), 96 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 24a2cf187b21..78704b09428b 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -140,53 +140,49 @@ class SiteProvisioningSource @Inject constructor( private suspend fun runPipeline(siteLocalId: Int): SiteReadiness { val auth = ensureAuth(siteLocalId) - return when (auth.state) { + return when (auth) { SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each // reads the site fresh and writes only its own column — so run them in parallel. // recoverRestUrlIfNeeded precedes detectCapabilities within its branch because the probe - // needs the recovered REST root. The mint's credentials reach the probe as an immutable - // value (auth.credentials), never via a shared mutated SiteModel — the fresh-read columns - // can't be trusted to carry them (transient, and clobberable by a concurrent write, #22905). + // needs the recovered REST root. Both branches read the credentials fresh from the store: + // the mint persists them via a single writer that the generic full-row update can no + // longer clobber (#22947), so the re-read is now trustworthy (#22905). val capabilities = async { recoverRestUrlIfNeeded(siteLocalId) - detectCapabilities(siteLocalId, auth.credentials) + detectCapabilities(siteLocalId) } val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } xmlRpc.await() capabilities.await() } - else -> SiteReadiness.NeedsAuth(auth.state) + else -> SiteReadiness.NeedsAuth(auth) } } /** - * Stage 1 — ensure the site has working application-password credentials, and - * return them. Validates stored creds with Basic auth against the direct host; - * on a confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack - * tunnel. The credentials are returned in the [AuthResult] so [detectCapabilities] - * can authenticate with them without trusting a fresh re-read — the plain columns - * are transient and a concurrent whole-row site write can clobber the encrypted - * ones mid-run (#22905). + * Stage 1 — ensure the site has working application-password credentials. Validates stored creds + * with Basic auth against the direct host; on a confirmed rejection wipes them and mints fresh + * ones via the FluxC Jetpack tunnel. The mint persists the credentials (single-writer, #22947), so + * the downstream stages read them back from a fresh [SiteModel] rather than having them threaded. */ // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed); // collapsing to one return would thread a result through nested branches and read worse. @Suppress("ReturnCount") - private suspend fun ensureAuth(siteLocalId: Int): AuthResult { + private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { val site = siteStore.getSiteByLocalId(siteLocalId) - ?: return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = false)) + ?: return SiteAuthState.Unprovisionable(hadCredentials = false) // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password // applies (the mint returns NotSupported). Capability detection works through the proxy, so // treat them as ready instead of blocking detection behind a mint that can never run. - if (site.isWPComSimpleSite) return AuthResult(SiteAuthState.NotApplicable) + if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { - ApplicationPasswordValidator.Outcome.Valid -> - return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials()) + ApplicationPasswordValidator.Outcome.Valid -> return SiteAuthState.Provisioned ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}") - return AuthResult(SiteAuthState.Provisioning) + return SiteAuthState.Provisioning } ApplicationPasswordValidator.Outcome.Invalid -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") @@ -195,18 +191,19 @@ class SiteProvisioningSource @Inject constructor( } } } - // createApplicationPassword mutates this local `site` with the freshly minted plain credentials. + // createApplicationPassword mints and persists the credentials; downstream stages read them + // back from a fresh SiteModel. val createResult = siteStore.createApplicationPassword(site) if (!createResult.isError && createResult.credentials != null) { wpApiClientProvider.clearSelfHostedClient(site.id) appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}") - return AuthResult(SiteAuthState.Provisioned, site.provisionedCredentials()) + return SiteAuthState.Provisioned } appLogWrapper.d( AppLog.T.MAIN, "A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})" ) - return AuthResult(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) + return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials) } /** @@ -226,8 +223,10 @@ class SiteProvisioningSource @Inject constructor( /** * Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted - * sites that don't have one. Discovers + authenticates against it, and on - * success persists the one column; the application-password card re-reads it. + * sites that don't have one. Discovers + authenticates against it with the + * site's application-password credentials (which work for XML-RPC just as for + * REST), and on success persists the one column; the application-password card + * re-reads it. */ private suspend fun recoverXmlRpcIfNeeded(siteLocalId: Int) { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return @@ -241,24 +240,11 @@ class SiteProvisioningSource @Inject constructor( /** * Stage 3 — probe the REST API for editor-capability support and persist it. * Reached only once auth is [SiteAuthState.Provisioned] / [SiteAuthState.NotApplicable]. - * [credentials] (from [ensureAuth]) are overlaid onto this run-local copy so the - * authenticated direct-host probe can run even when the fresh read doesn't reflect - * the mint; a failure here is then a real transport problem, not a pending mint. + * Reads the site fresh: the mint has already persisted the credentials (single-writer, #22947), + * so a probe failure here is a real transport problem, not a pending mint. */ - private suspend fun detectCapabilities( - siteLocalId: Int, - credentials: ProvisionedCredentials?, - ): SiteReadiness { + private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness { val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable - // Overlay the credentials ensureAuth obtained. The fresh read can't be trusted to carry them: - // the plain columns are transient (never persisted), and a concurrent whole-row site write - // during My Site load can clobber the encrypted ones before this read (#22905). This copy is - // local to this coroutine — not shared with the parallel XML-RPC stage — so the overlay is - // race-free. - credentials?.let { - if (site.apiRestUsernamePlain.isNullOrEmpty()) site.apiRestUsernamePlain = it.username - if (site.apiRestPasswordPlain.isNullOrEmpty()) site.apiRestPasswordPlain = it.password - } val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) val hasCache = editorSettingsRepository.hasCachedCapabilities(site) return when { @@ -269,26 +255,6 @@ class SiteProvisioningSource @Inject constructor( } } -/** - * Snapshot of the application-password credentials [SiteProvisioningSource.ensureAuth] obtained, - * handed forward to the capability probe as an immutable value rather than via a shared, mutated - * [SiteModel] — so the parallel stages never race on it. - */ -private data class ProvisionedCredentials(val username: String, val password: String) - -private fun SiteModel.provisionedCredentials(): ProvisionedCredentials? { - val user = apiRestUsernamePlain - val pass = apiRestPasswordPlain - return if (!user.isNullOrEmpty() && !pass.isNullOrEmpty()) ProvisionedCredentials(user, pass) else null -} - -/** - * [SiteProvisioningSource.ensureAuth]'s outcome: the public [SiteAuthState] plus, when provisioned, - * the credentials to hand to the capability probe. Internal so the credentials never leak into the - * UI-facing [SiteReadiness] / [SiteAuthState]. - */ -private data class AuthResult(val state: SiteAuthState, val credentials: ProvisionedCredentials? = null) - /** * Whether a site's application password is usable. Owned by [SiteProvisioningSource]; * rendered by the application-password card. diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index d9e140b48a5c..aa44d02a5e42 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -7,7 +7,6 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any -import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -92,14 +91,6 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { if (!ok) whenever(editorSettingsRepository.hasCachedCapabilities(any())).thenReturn(cached) } - private fun provisionableSiteCopy() = SiteModel().apply { - id = TEST_SITE_LOCAL_ID - url = "https://test.example.com" - // Non-null REST root + XML-RPC url so both recovery branches short-circuit. - wpApiRestUrl = "https://test.example.com/wp-json" - xmlRpcUrl = "https://test.example.com/xmlrpc.php" - } - // region auth stage @Test @@ -183,33 +174,6 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(siteStore, never()).createApplicationPassword(any()) } - @Test - fun `carries minted credentials to the probe even when the re-read lacks them`() = test { - // ensureAuth reads one copy and the mint sets credentials on it; detectCapabilities re-reads a - // SEPARATE copy that — simulating a concurrent whole-row clobber (#22905) — carries none. The - // probe must still receive a credentialed site, because the pipeline hands the mint's creds - // forward as an immutable value rather than relying on the re-read or a shared, race-prone model. - val authCopy = provisionableSiteCopy() - val credentiallessReread = provisionableSiteCopy() - whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(authCopy, credentiallessReread) - stubHasStoredCredentials(false) - whenever(siteStore.createApplicationPassword(any())).thenAnswer { invocation -> - // The real createApplicationPassword sets the plain credentials on the passed site. - (invocation.arguments[0] as SiteModel).apply { - apiRestUsernamePlain = "user" - apiRestPasswordPlain = "pass" - } - OnApplicationPasswordCreated(authCopy, ApplicationPasswordCredentials("user", "pass", uuid = "u")) - } - stubCapabilityProbe(ok = true) - - source.await(site) - - verify(editorSettingsRepository).fetchEditorCapabilitiesForSite( - argThat { apiRestUsernamePlain == "user" && apiRestPasswordPlain == "pass" } - ) - } - // endregion // region recovery + capability stages From 830043e6f4abc2edb0167e1773906e853103bafc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:01:18 -0600 Subject: [PATCH 13/32] Contain unexpected throws in the SiteProvisioningSource pipeline SiteApiRestUrlRecoverer and SiteXmlRpcUrlRecoverer already contain their own throws, but launchPipeline runs the whole pipeline inside appScope.launch with no guard -- so any other escaping throw (a SQLiteException from a stage's WellSql write, a failure in detectCapabilities) still reaches appScope. appScope is a plain Job (no SupervisorJob/handler), so it cancels the scope, takes down every other app-scoped coroutine, and leaves the readiness flow stuck on Probing. Wrap the launch body like the recoverers: rethrow CancellationException so invalidate/clear stay clean, contain anything else as Unreachable so the flow settles and the next run retries. Adds a regression test that fails without the guard. --- .../repositories/SiteProvisioningSource.kt | 17 ++++++++++++++++- .../repositories/SiteProvisioningSourceTest.kt | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 78704b09428b..658ac426b667 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * The single source of truth for getting a site ready to use: it provisions @@ -126,7 +127,21 @@ class SiteProvisioningSource @Inject constructor( jobs[siteLocalId]?.cancel() val flow = flowFor(siteLocalId) jobs[siteLocalId] = appScope.launch { - val readiness = runPipeline(siteLocalId) + // runPipeline runs on the app-lifetime appScope, a plain (non-supervisor) Job: an escaping + // throw would cancel it and every other app-scoped coroutine. Contain any unexpected failure + // (e.g. a SQLiteException from a stage's DB write) as Unreachable so the flow still settles + // and the next run retries; let cancellation propagate normally. + val readiness = try { + runPipeline(siteLocalId) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + appLogWrapper.e( + AppLog.T.MAIN, + "Provisioning pipeline failed for $siteLocalId: ${e::class.simpleName}: ${e.message}" + ) + SiteReadiness.Unreachable + } flow.value = readiness if (readiness is SiteReadiness.Ready) ready.add(siteLocalId) } diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index aa44d02a5e42..62b4aaa168fa 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -285,4 +285,20 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { } // endregion + + // region failure containment + + @Test + fun `given a stage throws, when awaited, then it settles off Probing`() = test { + // An unhandled throw inside the pipeline (here getSiteByLocalId failing like a SQLiteException) + // must not escape launchPipeline's appScope.launch: if it does, flow.value is never assigned, so + // every consumer is wedged on Probing and the shared appScope is cancelled. The run has to turn + // an unexpected failure into a terminal readiness instead. + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)) + .thenThrow(RuntimeException("DB read failed")) + + assertThat(source.await(site)).isNotEqualTo(SiteReadiness.Probing) + } + + // endregion } From ba1394aa6f939e098092a757b6364bdddd5e37d1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:36:32 -0600 Subject: [PATCH 14/32] Heal revoked app passwords in the pipeline; reauth only on failure On a persistent 401, WPMainActivity and MediaBrowserActivity navigated straight to interactive re-auth, even for WP.com-connected sites whose application password can be re-minted headlessly -- and once a site latched Ready nothing re-validated its credentials, so a server-side revocation went unnoticed until pull-to-refresh. Route invalid-auth through the provisioning pipeline. SiteProvisioningSource listens to the raw wordpress-rs 401 (WpAppNotifierHandler) and re-runs ensureAuth: a WP.com-connected site re-mints silently; one that can't be settles Unprovisionable. Only that terminal failure escalates to interactive re-auth -- via a new app-scoped ApplicationPasswordReauthNotifier the two activities now observe instead of the raw 401. So a successful re-mint no longer flashes the re-auth screen, and bearer-only WP.com Simple sites no longer mis-trigger an application-password prompt. Churn guards: skip sites already Unprovisionable and WP.com Simple sites; the no-op-while-active invalidate keeps the heal's own validate-401 from looping. A 401-triggered run is flagged so only it, never a routine onResume run, can escalate to re-auth. Also documents why invalidate no-ops during an in-flight run. --- .../repositories/SiteProvisioningSource.kt | 71 +++++++++++++- .../ApplicationPasswordReauthNotifier.kt | 45 +++++++++ .../android/ui/main/WPMainActivity.java | 12 +-- .../ui/media/MediaBrowserActivity.java | 12 +-- .../SiteProvisioningSourceTest.kt | 95 +++++++++++++++++++ .../ApplicationPasswordReauthNotifierTest.kt | 33 +++++++ 6 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt create mode 100644 WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifierTest.kt diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 658ac426b667..afc592526e31 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -8,11 +8,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.APPLICATION_SCOPE import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.ui.accounts.login.ApplicationPasswordReauthNotifier import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator @@ -69,8 +71,10 @@ class SiteProvisioningSource @Inject constructor( private val editorSettingsRepository: EditorSettingsRepository, private val networkUtilsWrapper: NetworkUtilsWrapper, private val appLogWrapper: AppLogWrapper, + private val wpAppNotifierHandler: WpAppNotifierHandler, + private val applicationPasswordReauthNotifier: ApplicationPasswordReauthNotifier, @Named(APPLICATION_SCOPE) private val appScope: CoroutineScope, -) { +) : WpAppNotifierHandler.NotifierListener { private val states = ConcurrentHashMap>() private val jobs = ConcurrentHashMap() @@ -79,6 +83,19 @@ class SiteProvisioningSource @Inject constructor( // access. Reset by invalidate / clear. private val ready = ConcurrentHashMap.newKeySet() + // Sites whose current run was triggered by a 401 (onRequestedWithInvalidAuthentication). If such a + // run settles Unprovisionable(hadCredentials), the headless heal failed, so we escalate to the + // interactive re-auth UI. A routine run (onResume) never sets this, so it never pops re-auth. + private val reauthOnFailure = ConcurrentHashMap.newKeySet() + + init { + // Re-provision when wordpress-rs reports a request was rejected for invalid auth (the app + // password was revoked / rotated server-side). Without this, a site latched Ready keeps its + // silently-broken credentials until a manual pull-to-refresh; instead force ensureAuth to + // re-validate, which wipes the dead credential and re-mints (or surfaces the re-auth card). + wpAppNotifierHandler.addListener(this) + } + /** * The shared readiness state for [site]. The first call starts the pipeline; * later calls return the same flow without re-running once it reached @@ -103,11 +120,17 @@ class SiteProvisioningSource @Inject constructor( } /** - * Forces a re-run for [site], bypassing the once-per-site gate. A no-op while - * a run is already in flight — that run already reflects current state. + * Forces a re-run for [site] (pull-to-refresh, banner retry), bypassing the once-per-site gate. + * + * Deliberately a **no-op while a run is already in flight**: cancelling mid-pipeline could + * interrupt an in-progress application-password mint after the server created it but before we + * persisted it, orphaning the credential and tripping a 409 on the next mint. So an explicit + * refresh that lands during an active run is coalesced into that run (the user gets its live + * result) rather than pre-empting it; a refresh once the run is idle starts a genuinely fresh one. */ @Synchronized fun invalidate(site: SiteModel) { + // No-op while running — see KDoc: don't pre-empt an in-flight mint. if (jobs[site.id]?.isActive == true) return ready.remove(site.id) launchPipeline(site.id) @@ -120,6 +143,47 @@ class SiteProvisioningSource @Inject constructor( jobs.clear() states.clear() ready.clear() + reauthOnFailure.clear() + } + + /** + * [WpAppNotifierHandler.NotifierListener] — wordpress-rs rejected a request for [siteUrl] with + * invalid authentication (a revoked application password, or an expired WP.com bearer token). + * Re-provision the matching app-password sites so ensureAuth re-validates and heals them: for a + * WP.com-connected site the headless re-mint succeeds and recovery is silent; for one that can't + * be re-minted the run settles Unprovisionable and [maybeRequestReauth] escalates to interactive + * re-auth. WP.com Simple sites are bearer-only (no application password), so they're skipped here. + * + * The notifier carries only the URL, so resolve to local id(s). Already-Unprovisionable sites are + * skipped — their re-auth is pending the user, and re-running would just re-fail the mint on every + * 401. [invalidate] also no-ops while a run is active, so the validate call in the triggered re-run + * (which can itself 401) can't spin this into a loop. + */ + override fun onRequestedWithInvalidAuthentication(siteUrl: String) { + siteStore.sites + .filter { it.url == siteUrl && !it.isWPComSimpleSite } + .forEach { site -> + val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth + if (auth is SiteAuthState.Unprovisionable) return@forEach + // Mark this as 401-triggered so a failed heal escalates to interactive re-auth. + reauthOnFailure.add(site.id) + invalidate(site) + } + } + + /** + * After a 401-triggered run settles, escalate to interactive re-auth only if the heal couldn't + * recover a previously-working credential. Consuming the flag bounds this to one prompt per heal; + * a routine (non-401) run never set the flag, so it never prompts. + */ + private fun maybeRequestReauth(siteLocalId: Int, readiness: SiteReadiness) { + if (!reauthOnFailure.remove(siteLocalId)) return + val auth = (readiness as? SiteReadiness.NeedsAuth)?.auth + if (auth is SiteAuthState.Unprovisionable && auth.hadCredentials) { + siteStore.getSiteByLocalId(siteLocalId)?.let { + applicationPasswordReauthNotifier.notifyReauthRequired(it.url) + } + } } @Synchronized @@ -144,6 +208,7 @@ class SiteProvisioningSource @Inject constructor( } flow.value = readiness if (readiness is SiteReadiness.Ready) ready.add(siteLocalId) + maybeRequestReauth(siteLocalId, readiness) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt new file mode 100644 index 000000000000..3aad4c1739d6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt @@ -0,0 +1,45 @@ +package org.wordpress.android.ui.accounts.login + +import java.lang.ref.WeakReference +import javax.inject.Inject +import javax.inject.Singleton + +/** + * App-scoped relay that asks the UI to start interactive application-password re-authentication for a + * site. SiteProvisioningSource posts here only after a headless heal (validate + re-mint) has failed + * for a site that previously had credentials — i.e. the credential is revoked and can't be recovered + * silently. WPMainActivity / MediaBrowserActivity listen and navigate to the re-auth screen. + * + * The raw wordpress-rs 401 signal (WpAppNotifierHandler) now drives the provisioning pipeline's heal + * instead of the UI directly, so a successful re-mint no longer flashes the re-auth screen. This + * mirrors that handler's weak-listener shape so the activity add/remove lifecycle is unchanged. + */ +@Singleton +class ApplicationPasswordReauthNotifier @Inject constructor() { + private val listeners = mutableMapOf>() + + /** Asks any listening UI to navigate to interactive re-auth for [siteUrl]. */ + @Synchronized + fun notifyReauthRequired(siteUrl: String) { + cleanupDeadReferences() + listeners.values.forEach { it.get()?.onReauthRequired(siteUrl) } + } + + @Synchronized + fun addListener(listener: Listener) { + listeners[listener.toString()] = WeakReference(listener) + } + + @Synchronized + fun removeListener(listener: Listener) { + listeners.remove(listener.toString()) + } + + private fun cleanupDeadReferences() { + listeners.entries.removeAll { it.value.get() == null } + } + + interface Listener { + fun onReauthRequired(siteUrl: String) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 77eae30b9768..81d3e0254397 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -43,7 +43,6 @@ import org.wordpress.android.fluxc.generated.SiteActionBuilder; import org.wordpress.android.fluxc.model.PostModel; import org.wordpress.android.fluxc.model.SiteModel; -import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler; import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie; import org.wordpress.android.fluxc.store.AccountStore; import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; @@ -60,6 +59,7 @@ import org.wordpress.android.fluxc.store.SiteStore.OnSiteRemoved; import org.wordpress.android.inappupdate.IInAppUpdateManager; import org.wordpress.android.inappupdate.InAppUpdateListener; +import org.wordpress.android.ui.accounts.login.ApplicationPasswordReauthNotifier; import org.wordpress.android.ui.accounts.login.LoginAnalyticsListener; import org.wordpress.android.networking.ConnectionChangeReceiver; import org.wordpress.android.push.GCMMessageHandler; @@ -187,7 +187,7 @@ public class WPMainActivity extends BaseAppCompatActivity implements BloggingPromptsReminderSchedulerListener, BloggingPromptsOnboardingListener, UpdateSelectedSiteListener, - WpAppNotifierHandler.NotifierListener { + ApplicationPasswordReauthNotifier.Listener { public static final String ARG_CONTINUE_JETPACK_CONNECT = "ARG_CONTINUE_JETPACK_CONNECT"; public static final String ARG_CREATE_SITE = "ARG_CREATE_SITE"; public static final String ARG_IS_MAGIC_LINK_LOGIN = "ARG_IS_MAGIC_LINK_LOGIN"; @@ -276,7 +276,7 @@ public class WPMainActivity extends BaseAppCompatActivity implements @Inject PerAppLocaleManager mPerAppLocaleManager; - @Inject WpAppNotifierHandler mWpAppNotifierHandler; + @Inject ApplicationPasswordReauthNotifier mReauthNotifier; /* * fragments implement this if their contents can be scrolled, called when user @@ -1061,7 +1061,7 @@ protected void onResume() { setUpMainView(); - mWpAppNotifierHandler.addListener(this); + mReauthNotifier.addListener(this); // Load selected site initSelectedSite(); @@ -1712,7 +1712,7 @@ public void onSetPromptReminderClick(final int siteId) { protected void onPause() { super.onPause(); - mWpAppNotifierHandler.removeListener(this); + mReauthNotifier.removeListener(this); } private void enableDeepLinkingComponentsIfNeeded() { @@ -1770,7 +1770,7 @@ private void showOpenPageMessageIfNeeded() { } } - @Override public void onRequestedWithInvalidAuthentication(@NonNull String siteUrl) { + @Override public void onReauthRequired(@NonNull String siteUrl) { showApplicationPasswordOffReauthenticateDialog(siteUrl); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java index 51335175611e..79c007e6b11d 100755 --- a/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/media/MediaBrowserActivity.java @@ -53,8 +53,8 @@ import org.wordpress.android.fluxc.model.MediaModel; import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; import org.wordpress.android.fluxc.model.SiteModel; -import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler; import org.wordpress.android.fluxc.store.MediaStore; +import org.wordpress.android.ui.accounts.login.ApplicationPasswordReauthNotifier; import org.wordpress.android.fluxc.store.MediaStore.CancelMediaPayload; import org.wordpress.android.fluxc.store.MediaStore.OnMediaChanged; import org.wordpress.android.fluxc.store.MediaStore.OnMediaListFetched; @@ -109,7 +109,7 @@ */ public class MediaBrowserActivity extends BaseAppCompatActivity implements MediaGridListener, OnQueryTextListener, OnActionExpandListener, - WPMediaUtils.LaunchCameraCallback, WpAppNotifierHandler.NotifierListener { + WPMediaUtils.LaunchCameraCallback, ApplicationPasswordReauthNotifier.Listener { public static final String ARG_BROWSER_TYPE = "media_browser_type"; public static final String ARG_FILTER = "filter"; public static final String ARG_LAUNCH_PHOTO_PICKER = "launch_photo_picker"; @@ -129,7 +129,7 @@ public class MediaBrowserActivity extends BaseAppCompatActivity implements Media @Inject SelectedSiteRepository mSelectedSiteRepository; @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject ActivityNavigator mActivityNavigator; - @Inject WpAppNotifierHandler mWpAppNotifierHandler; + @Inject ApplicationPasswordReauthNotifier mReauthNotifier; private SiteModel mSite; @@ -299,7 +299,7 @@ private void showQuota(boolean show) { } } - @Override public void onRequestedWithInvalidAuthentication(@NonNull String siteUrl) { + @Override public void onReauthRequired(@NonNull String siteUrl) { showApplicationPasswordReauthenticateDialog(siteUrl); } @@ -425,7 +425,7 @@ private void setFilter(@NonNull MediaFilter filter) { public void onStart() { super.onStart(); - mWpAppNotifierHandler.addListener(this); + mReauthNotifier.addListener(this); if (Build.VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { registerReceiver( @@ -459,7 +459,7 @@ protected void onResume() { @Override public void onStop() { - mWpAppNotifierHandler.removeListener(this); + mReauthNotifier.removeListener(this); EventBus.getDefault().unregister(this); unregisterReceiver(mReceiver); mDispatcher.unregister(this); diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 62b4aaa168fa..84695ca15abb 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -17,11 +17,13 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCredentials +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.fluxc.store.SiteStore.OnApplicationPasswordCreated import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.ui.accounts.login.ApplicationPasswordReauthNotifier import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator @@ -40,6 +42,8 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Mock lateinit var editorSettingsRepository: EditorSettingsRepository @Mock lateinit var networkUtilsWrapper: NetworkUtilsWrapper @Mock lateinit var appLogWrapper: AppLogWrapper + @Mock lateinit var wpAppNotifierHandler: WpAppNotifierHandler + @Mock lateinit var applicationPasswordReauthNotifier: ApplicationPasswordReauthNotifier private lateinit var site: SiteModel private lateinit var source: SiteProvisioningSource @@ -65,6 +69,8 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { editorSettingsRepository, networkUtilsWrapper, appLogWrapper, + wpAppNotifierHandler, + applicationPasswordReauthNotifier, testScope(), ) } @@ -286,6 +292,95 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { // endregion + // region invalid-auth re-provisioning + + @Test + fun `registers as an invalid-auth listener on construction`() { + verify(wpAppNotifierHandler).addListener(source) + } + + @Test + fun `given a ready site, when auth is reported invalid, then it re-runs`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + whenever(siteStore.sites).thenReturn(listOf(site)) + + source.await(site) + source.onRequestedWithInvalidAuthentication(site.url) + source.await(site) + + verify(applicationPasswordValidator, times(2)).validate(any()) + } + + @Test + fun `given a 401-triggered heal succeeds, then no reauth is requested`() = test { + whenever(siteStore.sites).thenReturn(listOf(site)) + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) // creds revoked -> wiped + stubMintSuccess() // re-mint heals silently (WP.com-connected) + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site.url) + source.await(site) + + verify(siteStore).createApplicationPassword(any()) + verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) + } + + @Test + fun `given a 401-triggered heal fails, then reauth is requested`() = test { + whenever(siteStore.sites).thenReturn(listOf(site)) + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + + source.onRequestedWithInvalidAuthentication(site.url) + source.await(site) + + verify(applicationPasswordReauthNotifier).notifyReauthRequired(site.url) + } + + @Test + fun `given a 401 on a WPCom Simple site, then it is ignored`() = test { + val simple = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://simple.wordpress.com" + setIsWPCom(true) // isWPComSimpleSite = isWPCom && !isWPComAtomic -> bearer-only + } + whenever(siteStore.sites).thenReturn(listOf(simple)) + + source.onRequestedWithInvalidAuthentication(simple.url) + + verify(applicationPasswordValidator, never()).validate(any()) + verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) + } + + @Test + fun `given an already-unprovisionable site, when 401 arrives, then it does not re-run`() = test { + whenever(siteStore.sites).thenReturn(listOf(site)) + stubHasStoredCredentials(false) + stubMintFailure() + + source.await(site) // settles NeedsAuth(Unprovisionable) + source.onRequestedWithInvalidAuthentication(site.url) + + verify(siteStore, times(1)).createApplicationPassword(any()) + } + + @Test + fun `given a routine run settles unprovisionable, then no reauth is requested`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + + source.await(site) // not 401-triggered, so the failure must not pop re-auth + + verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) + } + + // endregion + // region failure containment @Test diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifierTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifierTest.kt new file mode 100644 index 000000000000..b7fd05dcbcfe --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifierTest.kt @@ -0,0 +1,33 @@ +package org.wordpress.android.ui.accounts.login + +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +private const val SITE_URL = "https://selfhosted.example.com" + +class ApplicationPasswordReauthNotifierTest { + private val notifier = ApplicationPasswordReauthNotifier() + + @Test + fun `notifies a registered listener`() { + val listener = mock() + notifier.addListener(listener) + + notifier.notifyReauthRequired(SITE_URL) + + verify(listener).onReauthRequired(SITE_URL) + } + + @Test + fun `does not notify a removed listener`() { + val listener = mock() + notifier.addListener(listener) + notifier.removeListener(listener) + + notifier.notifyReauthRequired(SITE_URL) + + verify(listener, never()).onReauthRequired(SITE_URL) + } +} From 13fa43239f7dfe3e32e327df8a9ad7c5cde375a8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:27:12 -0600 Subject: [PATCH 15/32] Use getSiteByLocalId in the preloader; drop dead isAwaitingApplicationPassword Two review cleanups, no behavior change: - GutenbergEditorPreloader re-read the provisioned site with siteStore.sites.firstOrNull { it.id == siteId } -- a full-table read plus a per-row credential decrypt to fetch one row by id. Use getSiteByLocalId(siteId), the single-row lookup the rest of SiteProvisioningSource already uses. - EditorSettingsRepository.isAwaitingApplicationPassword is dead: its only caller (the connectivity banner's pending-auth suppression) was removed when detection moved behind the pipeline. Delete it. (SiteStore.persistXmlRpcUrl is also orphaned by this stack but lives in fluxc code this PR doesn't touch -- left for a fluxc follow-up.) --- .../android/repositories/EditorSettingsRepository.kt | 11 ----------- .../android/ui/posts/GutenbergEditorPreloader.kt | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt index d57b7b8c44f9..b46b8c2f22fd 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt @@ -34,17 +34,6 @@ class EditorSettingsRepository @Inject constructor( fun hasCachedCapabilities(site: SiteModel): Boolean = appPrefsWrapper.hasSiteEditorCapabilities(site) - /** - * True when capability detection can't run yet because an Atomic site's - * direct-host probe needs an application password that hasn't been - * provisioned. The password is minted asynchronously on the My Site - * screen (see ApplicationPasswordViewModelSlice), so a first-login fetch - * can fail purely for lack of credentials — callers should treat this as - * pending, not a connection failure. - */ - fun isAwaitingApplicationPassword(site: SiteModel): Boolean = - site.isWPComAtomic && !site.hasApplicationPasswordCredentials() - /** * Returns whether the site is known to support the * `wp-block-editor/v1/settings` endpoint, based on diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt index e90c5e510f0d..4d757b6d00cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergEditorPreloader.kt @@ -101,8 +101,7 @@ class GutenbergEditorPreloader @Inject constructor( // run; re-read the provisioned site so the config points at the // recovered REST root. siteProvisioningSource.await(site) - val provisionedSite = siteStore.sites - .firstOrNull { it.id == siteId } ?: site + val provisionedSite = siteStore.getSiteByLocalId(siteId) ?: site // Preloading produces EditorDependencies, which the editor // consumes alongside its own per-launch EditorConfiguration. // Cookies and network-logging are per-launch concerns the From 7447c5ca36e384e013edc1d1a8a70695007224be Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:13:37 -0600 Subject: [PATCH 16/32] Harden SiteProvisioningSource and key the 401 heal to the exact site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launchPipeline now contains the post-runPipeline tail. `maybeRequestReauth` reads the DB (`getSiteByLocalId`), and an escaping throw there would cancel the non-supervisor `appScope` and wedge provisioning for every site — wrap it so only `CancellationException` propagates. - `detectCapabilities` returns a private `PipelineResult(readiness, latch)`; the per-site dedup gate latches only on a live probe, so a `Ready` served from stale cache re-probes on the next run instead of sticking for the process lifetime. - A 401 that arrives while a run is in flight is deferred (`healForInvalidAuth`) instead of letting `invalidate` no-op and an unrelated run consume the re-auth flag — which could drop a revoked credential's interactive re-auth escalation. - `WpAppNotifierHandler.NotifierListener` hands listeners the `SiteModel`, not just the URL — URL is not unique (the constraint is `SITE_ID+URL`), so the heal now targets the exact row that 401'd. Drops the per-401 full site-table load+decrypt. --- .../repositories/SiteProvisioningSource.kt | 119 +++++++++++++----- .../SiteProvisioningSourceTest.kt | 65 ++++++++-- .../WpAppNotifierHandler.kt | 7 +- .../WpAppNotifierHandlerTest.kt | 16 +-- 4 files changed, 156 insertions(+), 51 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index afc592526e31..499156362b6b 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -147,28 +147,58 @@ class SiteProvisioningSource @Inject constructor( } /** - * [WpAppNotifierHandler.NotifierListener] — wordpress-rs rejected a request for [siteUrl] with - * invalid authentication (a revoked application password, or an expired WP.com bearer token). - * Re-provision the matching app-password sites so ensureAuth re-validates and heals them: for a - * WP.com-connected site the headless re-mint succeeds and recovery is silent; for one that can't - * be re-minted the run settles Unprovisionable and [maybeRequestReauth] escalates to interactive - * re-auth. WP.com Simple sites are bearer-only (no application password), so they're skipped here. + * [WpAppNotifierHandler.NotifierListener] — wordpress-rs rejected a request for [site] with invalid + * authentication (a revoked application password, or an expired WP.com bearer token). Re-provision it + * so ensureAuth re-validates and heals: for a WP.com-connected site the headless re-mint succeeds and + * recovery is silent; for one that can't be re-minted the run settles Unprovisionable and + * [maybeRequestReauth] escalates to interactive re-auth. WP.com Simple sites are bearer-only (no + * application password), so they're skipped here. * - * The notifier carries only the URL, so resolve to local id(s). Already-Unprovisionable sites are - * skipped — their re-auth is pending the user, and re-running would just re-fail the mint on every - * 401. [invalidate] also no-ops while a run is active, so the validate call in the triggered re-run - * (which can itself 401) can't spin this into a loop. + * The notifier hands us the exact [SiteModel] whose client raised the 401, so we heal that one row by + * id — no resolving back by URL (which isn't unique: the DB constraint is on SITE_ID+URL, so two rows + * can share a URL). Already-Unprovisionable sites are skipped — their re-auth is pending the user, and + * re-running would just re-fail the mint on every 401. When a run is already in flight, + * [healForInvalidAuth] defers the heal until it finishes rather than pre-empting a possibly mid-mint + * stage, and skips it if that run already settled Unprovisionable — so a validate that itself 401s + * can't spin this into a loop. */ - override fun onRequestedWithInvalidAuthentication(siteUrl: String) { - siteStore.sites - .filter { it.url == siteUrl && !it.isWPComSimpleSite } - .forEach { site -> - val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth - if (auth is SiteAuthState.Unprovisionable) return@forEach - // Mark this as 401-triggered so a failed heal escalates to interactive re-auth. - reauthOnFailure.add(site.id) - invalidate(site) + override fun onRequestedWithInvalidAuthentication(site: SiteModel) { + if (site.isWPComSimpleSite) return + val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth + if (auth is SiteAuthState.Unprovisionable) return + healForInvalidAuth(site) + } + + /** + * Drive a heal for a 401. If the pipeline is idle, run it now and arm [reauthOnFailure] so a failed + * heal escalates to interactive re-auth. If a run is already in flight we must not pre-empt a + * possibly mid-mint stage — but the 401 must not be swallowed either: that run may have validated the + * credential *before* it was revoked and will settle Ready, consuming nothing and healing nothing. So + * defer a fresh heal until the active run finishes, unless it settled [SiteAuthState.Unprovisionable] + * (in which case it already escalated on its own). The flag is armed only when the heal actually + * launches, so the in-flight run's tail can't consume it for an outcome it never serviced. + */ + @Synchronized + private fun healForInvalidAuth(site: SiteModel) { + val siteLocalId = site.id + val active = jobs[siteLocalId] + if (active?.isActive != true) { + reauthOnFailure.add(siteLocalId) + ready.remove(siteLocalId) + launchPipeline(siteLocalId) + return + } + active.invokeOnCompletion { cause -> + if (cause != null) return@invokeOnCompletion // cancelled / relaunched — a fresh run is coming + synchronized(this@SiteProvisioningSource) { + if (jobs[siteLocalId]?.isActive == true) return@synchronized // a newer run is already underway + val settledAuth = (states[siteLocalId]?.value as? SiteReadiness.NeedsAuth)?.auth + if (settledAuth is SiteAuthState.Unprovisionable) return@synchronized // already escalated + reauthOnFailure.add(siteLocalId) + ready.remove(siteLocalId) + launchPipeline(siteLocalId) } + } } /** @@ -195,7 +225,7 @@ class SiteProvisioningSource @Inject constructor( // throw would cancel it and every other app-scoped coroutine. Contain any unexpected failure // (e.g. a SQLiteException from a stage's DB write) as Unreachable so the flow still settles // and the next run retries; let cancellation propagate normally. - val readiness = try { + val result = try { runPipeline(siteLocalId) } catch (e: CancellationException) { throw e @@ -204,11 +234,25 @@ class SiteProvisioningSource @Inject constructor( AppLog.T.MAIN, "Provisioning pipeline failed for $siteLocalId: ${e::class.simpleName}: ${e.message}" ) - SiteReadiness.Unreachable + PipelineResult(SiteReadiness.Unreachable, latch = false) + } + flow.value = result.readiness + // Latch the dedup gate only on a freshly live-probed Ready; a Ready served from stale cache + // (latch = false) is left to re-probe on the next run instead of sticking for the process. + if (result.latch) ready.add(siteLocalId) + // maybeRequestReauth reads the DB and drives the reauth notifier, and runs after the flow has + // already settled. Contain its throws too: on this non-supervisor appScope an escaping throw + // here would cancel the scope and wedge provisioning for every other site. + try { + maybeRequestReauth(siteLocalId, result.readiness) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + appLogWrapper.e( + AppLog.T.MAIN, + "Reauth escalation failed for $siteLocalId: ${e::class.simpleName}: ${e.message}" + ) } - flow.value = readiness - if (readiness is SiteReadiness.Ready) ready.add(siteLocalId) - maybeRequestReauth(siteLocalId, readiness) } } @@ -218,7 +262,7 @@ class SiteProvisioningSource @Inject constructor( private fun shouldRun(siteLocalId: Int): Boolean = jobs[siteLocalId]?.isActive != true && siteLocalId !in ready - private suspend fun runPipeline(siteLocalId: Int): SiteReadiness { + private suspend fun runPipeline(siteLocalId: Int): PipelineResult { val auth = ensureAuth(siteLocalId) return when (auth) { SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { @@ -236,7 +280,7 @@ class SiteProvisioningSource @Inject constructor( xmlRpc.await() capabilities.await() } - else -> SiteReadiness.NeedsAuth(auth) + else -> PipelineResult(SiteReadiness.NeedsAuth(auth), latch = false) } } @@ -323,16 +367,29 @@ class SiteProvisioningSource @Inject constructor( * Reads the site fresh: the mint has already persisted the credentials (single-writer, #22947), * so a probe failure here is a real transport problem, not a pending mint. */ - private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness { - val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable + private suspend fun detectCapabilities(siteLocalId: Int): PipelineResult { + val site = siteStore.getSiteByLocalId(siteLocalId) + ?: return PipelineResult(SiteReadiness.Unreachable, latch = false) val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site) val hasCache = editorSettingsRepository.hasCachedCapabilities(site) return when { - ok || hasCache -> SiteReadiness.Ready - !networkUtilsWrapper.isNetworkAvailable() -> SiteReadiness.TransientError - else -> SiteReadiness.Unreachable + // Live probe succeeded: latch so we stop re-probing — capabilities rarely change. + ok -> PipelineResult(SiteReadiness.Ready, latch = true) + // Probe failed but stale cache keeps the site usable and the banner hidden; do NOT latch, + // so the next run re-probes and can refresh the cache / surface a genuine failure (#22944). + hasCache -> PipelineResult(SiteReadiness.Ready, latch = false) + !networkUtilsWrapper.isNetworkAvailable() -> + PipelineResult(SiteReadiness.TransientError, latch = false) + else -> PipelineResult(SiteReadiness.Unreachable, latch = false) } } + + /** + * A settled pipeline result plus whether it should latch the per-site dedup gate ([ready]). Only a + * freshly live-probed [SiteReadiness.Ready] latches; a Ready served from stale cache does not, so it + * re-probes on the next run. Internal to the pipeline — consumers only ever see [readiness]. + */ + private data class PipelineResult(val readiness: SiteReadiness, val latch: Boolean) } /** diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 84695ca15abb..d27ce5c8088f 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -264,6 +264,20 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(applicationPasswordValidator, times(1)).validate(any()) } + @Test + fun `given a prior cache-only ready run, when awaited again, then it re-probes`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + // Ready served from stale cache (live probe failing) must NOT latch the dedup gate, so the + // next access re-probes and can refresh / surface a genuine failure (#22944 c3). + stubCapabilityProbe(ok = false, cached = true) + + source.await(site) + source.await(site) + + verify(applicationPasswordValidator, times(2)).validate(any()) + } + @Test fun `given a prior ready run, when invalidated, then it re-runs`() = test { stubHasStoredCredentials(true) @@ -304,10 +318,9 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.Valid) stubCapabilityProbe(ok = true) - whenever(siteStore.sites).thenReturn(listOf(site)) source.await(site) - source.onRequestedWithInvalidAuthentication(site.url) + source.onRequestedWithInvalidAuthentication(site) source.await(site) verify(applicationPasswordValidator, times(2)).validate(any()) @@ -315,13 +328,12 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `given a 401-triggered heal succeeds, then no reauth is requested`() = test { - whenever(siteStore.sites).thenReturn(listOf(site)) stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.Invalid) // creds revoked -> wiped stubMintSuccess() // re-mint heals silently (WP.com-connected) stubCapabilityProbe(ok = true) - source.onRequestedWithInvalidAuthentication(site.url) + source.onRequestedWithInvalidAuthentication(site) source.await(site) verify(siteStore).createApplicationPassword(any()) @@ -330,17 +342,52 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `given a 401-triggered heal fails, then reauth is requested`() = test { - whenever(siteStore.sites).thenReturn(listOf(site)) stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.Invalid) stubMintFailure() - source.onRequestedWithInvalidAuthentication(site.url) + source.onRequestedWithInvalidAuthentication(site) source.await(site) verify(applicationPasswordReauthNotifier).notifyReauthRequired(site.url) } + @Test + fun `given the reauth notifier throws, then the pipeline still settles off probing`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + // The post-settle reauth escalation runs on the non-supervisor appScope; a throw here must be + // contained, not allowed to cancel the scope and wedge provisioning for every site (#22944 c1). + whenever(applicationPasswordReauthNotifier.notifyReauthRequired(any())) + .thenThrow(RuntimeException("boom")) + + source.onRequestedWithInvalidAuthentication(site) + val result = source.await(site) + + assertThat(result) + .isEqualTo(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + } + + @Test + fun `given a 401 during an active run, the deferred heal still escalates a dead credential`() = test { + stubHasStoredCredentials(true) + // The active run validates the not-yet-revoked credential (Valid -> Ready); the deferred re-heal + // then sees it revoked (Invalid) and can't re-mint, so it must escalate rather than be swallowed + // by the run that was already in flight when the 401 arrived (#22944 c2). + whenever(applicationPasswordValidator.validate(any())) + .thenReturn(ApplicationPasswordValidator.Outcome.Valid) + .thenReturn(ApplicationPasswordValidator.Outcome.Invalid) + stubCapabilityProbe(ok = true) + stubMintFailure() + + source.stateFor(site) // launch the run; it stays active (not yet run) + source.onRequestedWithInvalidAuthentication(site) // 401 while the run is active -> defer the heal + testScheduler.advanceUntilIdle() // run the active run, then the deferred heal + + verify(applicationPasswordReauthNotifier).notifyReauthRequired(site.url) + } + @Test fun `given a 401 on a WPCom Simple site, then it is ignored`() = test { val simple = SiteModel().apply { @@ -348,9 +395,8 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { url = "https://simple.wordpress.com" setIsWPCom(true) // isWPComSimpleSite = isWPCom && !isWPComAtomic -> bearer-only } - whenever(siteStore.sites).thenReturn(listOf(simple)) - source.onRequestedWithInvalidAuthentication(simple.url) + source.onRequestedWithInvalidAuthentication(simple) verify(applicationPasswordValidator, never()).validate(any()) verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) @@ -358,12 +404,11 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `given an already-unprovisionable site, when 401 arrives, then it does not re-run`() = test { - whenever(siteStore.sites).thenReturn(listOf(site)) stubHasStoredCredentials(false) stubMintFailure() source.await(site) // settles NeedsAuth(Unprovisionable) - source.onRequestedWithInvalidAuthentication(site.url) + source.onRequestedWithInvalidAuthentication(site) verify(siteStore, times(1)).createApplicationPassword(any()) } diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandler.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandler.kt index 58a8871eeb99..6ce9c3fb3eed 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandler.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandler.kt @@ -18,7 +18,10 @@ class WpAppNotifierHandler @Inject constructor() { cleanupDeadReferences() listeners.forEach { val listener = it.value.get() - listener?.onRequestedWithInvalidAuthentication(site.url) + // Hand listeners the whole SiteModel, not just its URL: URL is not a unique key + // (UNIQUE is on SITE_ID+URL), so a listener resolving back by URL can't tell which row + // actually failed. The id pins it to the exact site whose client raised the 401. + listener?.onRequestedWithInvalidAuthentication(site) } } @@ -37,6 +40,6 @@ class WpAppNotifierHandler @Inject constructor() { } interface NotifierListener { - fun onRequestedWithInvalidAuthentication(siteUrl: String) + fun onRequestedWithInvalidAuthentication(site: SiteModel) } } diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandlerTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandlerTest.kt index f480e591064f..338323602894 100644 --- a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandlerTest.kt +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WpAppNotifierHandlerTest.kt @@ -38,8 +38,8 @@ class WpAppNotifierHandlerTest { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(testSite) // Then - verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSiteUrl) - verify(mockListener2, times(1)).onRequestedWithInvalidAuthentication(testSiteUrl) + verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSite) + verify(mockListener2, times(1)).onRequestedWithInvalidAuthentication(testSite) } @Test @@ -53,7 +53,7 @@ class WpAppNotifierHandlerTest { } @Test - fun `notifyRequestedWithInvalidAuthentication passes correct site URL to listeners`() { + fun `notifyRequestedWithInvalidAuthentication passes the site to listeners`() { // Given val customSiteUrl = "https://custom-site.example.org" val customSite = SiteModel().apply { @@ -66,7 +66,7 @@ class WpAppNotifierHandlerTest { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(customSite) // Then - verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(customSiteUrl) + verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(customSite) } @Test @@ -80,8 +80,8 @@ class WpAppNotifierHandlerTest { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(testSite) // Then - only remaining listener should be called - verify(mockListener1, never()).onRequestedWithInvalidAuthentication(testSiteUrl) - verify(mockListener2, times(1)).onRequestedWithInvalidAuthentication(testSiteUrl) + verify(mockListener1, never()).onRequestedWithInvalidAuthentication(testSite) + verify(mockListener2, times(1)).onRequestedWithInvalidAuthentication(testSite) } @Test @@ -104,7 +104,7 @@ class WpAppNotifierHandlerTest { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(testSite) // Then - original listener should still be called - verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSiteUrl) + verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSite) } @Test @@ -117,6 +117,6 @@ class WpAppNotifierHandlerTest { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(testSite) // Then - should only be called once (overwrites previous entry) - verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSiteUrl) + verify(mockListener1, times(1)).onRequestedWithInvalidAuthentication(testSite) } } From 913c720dad73045708ce5750bef3dc777c7fa58c Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:13:48 -0600 Subject: [PATCH 17/32] Give the Jetpack install step its own client so its 401 stays local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install step talked to the site through the shared, globally-wired `getWpApiClient`, so a 401 there fired the app-wide `WpAppNotifierHandler` — which `SiteProvisioningSource` now also observes, racing the in-flight connection (a concurrent validate / wipe / re-mint can clear the `apiRest*` columns mid-install, and `requireRestCredentials` then throws). `initWpApiClient` now builds a dedicated `WpApiClient` with a connection-local notifier (mirroring `initJetpackConnectionClient`), and `JetpackInstaller` threads the callback through. `JetpackRestConnectionViewModel` drops its `WpAppNotifierHandler.NotifierListener` and handles the install 401 via a local `onInstallAuthFailed` callback. `SiteProvisioningSource` is now the single app-wide invalid-auth authority. --- .../JetpackConnectionHelper.kt | 40 +++++++++++++-- .../jetpackrestconnection/JetpackInstaller.kt | 9 +++- .../JetpackRestConnectionViewModel.kt | 18 +++---- .../JetpackRestConnectionViewModelTest.kt | 51 ++++++++++--------- 4 files changed, 79 insertions(+), 39 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt index 5206fed305a1..8b9cf129af24 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt @@ -2,7 +2,6 @@ package org.wordpress.android.ui.jetpackrestconnection import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor -import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpNetworkAvailabilityProvider import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.util.AppLog @@ -14,17 +13,50 @@ import uniffi.wp_api.WpApiClientDelegate import uniffi.wp_api.WpApiMiddlewarePipeline import uniffi.wp_api.WpAppNotifier import uniffi.wp_api.WpAuthenticationProvider +import uniffi.wp_api.WpOrgSiteApiUrlResolver import javax.inject.Inject class JetpackConnectionHelper @Inject constructor( - private val wpApiClientProvider: WpApiClientProvider, private val appLogWrapper: AppLogWrapper, private val trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, private val networkAvailabilityProvider: WpNetworkAvailabilityProvider, ) { - fun initWpApiClient(site: SiteModel): WpApiClient { + /** + * Builds a **dedicated** [WpApiClient] for the Jetpack plugin-install step, talking to the site's + * own REST host with its application-password credentials. + * + * Unlike [org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider.getWpApiClient], a + * 401 from this client is handled **locally** via [onInvalidAuth] rather than broadcast on the + * app-wide `WpAppNotifierHandler`. An invalid-auth here is the connection flow's own concern (the + * view model resets and restarts it); broadcasting it would also wake the app-wide + * SiteProvisioningSource, whose concurrent validate / wipe / re-mint of these same credentials can + * race this in-flight connection — e.g. clearing the `apiRest*` columns that [requireRestCredentials] + * needs, mid-install. Keeping the install client off that bus leaves SiteProvisioningSource as the + * single global invalid-auth authority. This mirrors [initJetpackConnectionClient], whose + * [InvalidAuthNotifier] is likewise connection-local. + * + * [onInvalidAuth] fires at most once per client: install issues up to two requests (list, then + * create) and the flow should only restart once. + */ + fun initWpApiClient(site: SiteModel, onInvalidAuth: () -> Unit): WpApiClient { requireRestCredentials(site) - return wpApiClientProvider.getWpApiClient(site) + return WpApiClient( + apiUrlResolver = WpOrgSiteApiUrlResolver(ParsedUrl.parse(resolveRestApiUrl(site))), + authProvider = createRestAuthProvider(site), + requestExecutor = WpRequestExecutor( + interceptors = listOf(trackNetworkRequestsInterceptor), + networkAvailabilityProvider = networkAvailabilityProvider + ), + appNotifier = object : WpAppNotifier { + private var handled = false + override suspend fun requestedWithInvalidAuthentication(requestUrl: String) { + if (handled) return + handled = true + appLogWrapper.d(AppLog.T.API, "$TAG: requestedWithInvalidAuthentication (install client)") + onInvalidAuth() + } + }, + ) } fun initJetpackConnectionClient(site: SiteModel): JetpackConnectionClient { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackInstaller.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackInstaller.kt index f83612659a90..00e7cd19fbb0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackInstaller.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackInstaller.kt @@ -20,10 +20,15 @@ class JetpackInstaller @Inject constructor( private val jetpackConnectionHelper: JetpackConnectionHelper, private val appLogWrapper: AppLogWrapper, ) { + /** + * Installs/activates the Jetpack plugin on [site]. [onInvalidAuth] is invoked if the site rejects the + * application-password credentials (401) — handled locally by the caller rather than via the app-wide + * notifier, so it can't race a concurrent provisioning heal (see [JetpackConnectionHelper.initWpApiClient]). + */ @Suppress("TooGenericExceptionCaught") - suspend fun installJetpack(site: SiteModel): Result { + suspend fun installJetpack(site: SiteModel, onInvalidAuth: () -> Unit): Result { return try { - val apiClient = jetpackConnectionHelper.initWpApiClient(site) + val apiClient = jetpackConnectionHelper.initWpApiClient(site, onInvalidAuth) val info = getPluginInfo(apiClient) when (info?.status) { PluginStatus.ACTIVE, PluginStatus.NETWORK_ACTIVE -> { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModel.kt index 302d54e9e3a7..e60805e78243 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModel.kt @@ -16,7 +16,6 @@ import org.wordpress.android.analytics.AnalyticsTracker.JETPACK_REST_CONNECT_STA import org.wordpress.android.analytics.AnalyticsTracker.JETPACK_REST_CONNECT_STATE_STARTED import org.wordpress.android.analytics.AnalyticsTracker.JETPACK_REST_CONNECT_STATE_STEP_KEY import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.BG_THREAD @@ -41,8 +40,7 @@ class JetpackRestConnectionViewModel @Inject constructor( private val jetpackModuleHelper: JetpackStatsModuleHelper, private val appLogWrapper: AppLogWrapper, private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, - private val wpAppNotifierHandler: WpAppNotifierHandler, -) : ScopedViewModel(mainDispatcher), WpAppNotifierHandler.NotifierListener { +) : ScopedViewModel(mainDispatcher) { // Internal variables that can be overridden for testing internal var uiDelayMs: Long = UI_DELAY_MS internal var stepTimeoutMs: Long = STEP_TIMEOUT_MS @@ -80,7 +78,6 @@ class JetpackRestConnectionViewModel @Inject constructor( _uiEvent.value = null analyticsTrackerWrapper.track(AnalyticsTracker.Stat.JETPACK_REST_CONNECT_STARTED) - wpAppNotifierHandler.addListener(this) startStep(fromStep ?: ConnectionStep.LoginWpCom) } @@ -95,7 +92,6 @@ class JetpackRestConnectionViewModel @Inject constructor( _buttonType.value = ButtonType.Retry } - wpAppNotifierHandler.removeListener(this) _currentStep.value = null } @@ -362,7 +358,7 @@ class JetpackRestConnectionViewModel @Inject constructor( * Step 2: Installs Jetpack to the current site if not already installed */ private suspend fun installJetpack() { - val result = jetpackInstaller.installJetpack(site) + val result = jetpackInstaller.installJetpack(site, onInvalidAuth = ::onInstallAuthFailed) result.fold( onSuccess = { status -> @@ -475,12 +471,14 @@ class JetpackRestConnectionViewModel @Inject constructor( } /** - * Called when auth fails in the WpApiClient created in JetpackConnectionHelper.initWpApiClient, reset the - * access token and restart the connection flow so the user sees the login page + * Invoked when the site rejects the application password during the Jetpack install step. This is + * delivered **locally** by the install client's notifier (see [JetpackConnectionHelper.initWpApiClient]), + * not by the app-wide `WpAppNotifierHandler`, so the app-wide SiteProvisioningSource doesn't also react + * and race this connection (see #22944). Reset the access token and restart the flow so the user sees + * the login page. */ - override fun onRequestedWithInvalidAuthentication(siteUrl: String) { + private fun onInstallAuthFailed() { appLogWrapper.d(AppLog.T.API, "$TAG: Invalid authentication, restarting") - wpAppNotifierHandler.removeListener(this) accountStore.resetAccessToken() clearValues() startConnectionFlow() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModelTest.kt index 8bb91e1a9a30..b48a9406b9c1 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/jetpackrestconnection/JetpackRestConnectionViewModelTest.kt @@ -20,7 +20,6 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.BuildConfig import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.jetpackrestconnection.JetpackRestConnectionViewModel.ButtonType @@ -56,9 +55,6 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { @Mock lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper - @Mock - lateinit var wpAppNotifierHandler: WpAppNotifierHandler - @Mock lateinit var siteModel: SiteModel @@ -94,7 +90,6 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { jetpackModuleHelper = jetpackModuleHelper, appLogWrapper = appLogWrapper, analyticsTrackerWrapper = analyticsTrackerWrapper, - wpAppNotifierHandler = wpAppNotifierHandler, ) // Override delays for faster tests @@ -147,7 +142,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { @Test fun `onRetryClick retries from failed step`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.failure(Exception("Failed"))) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.failure(Exception("Failed"))) viewModel.onStartClick() advanceTimeBy(TEST_ADVANCE_TIME_MS) @@ -155,7 +150,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { assertThat(viewModel.stepStates.value[ConnectionStep.InstallJetpack]?.status) .isEqualTo(ConnectionStatus.Failed) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) viewModel.onRetryClick() advanceTimeBy(TEST_ADVANCE_TIME_MS) // Need to advance time for retry to complete @@ -213,7 +208,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { @Test fun `installJetpack step succeeds with active plugin`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) viewModel.onStartClick() advanceTimeBy(TEST_ADVANCE_TIME_MS) @@ -225,7 +220,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { @Test fun `installJetpack step fails with inactive plugin`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.INACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.INACTIVE)) viewModel.onStartClick() advanceTimeBy(TEST_ADVANCE_TIME_MS) @@ -237,7 +232,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { @Test fun `connectSite step succeeds and updates site ID`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) viewModel.onStartClick() @@ -253,7 +248,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { whenever(accountStore.hasAccessToken()) .thenReturn(true) // Initial check for LoginWpCom .thenReturn(false) // Check in ConnectUser step - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) viewModel.onStartClick() @@ -266,7 +261,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { fun `connectUser step succeeds with access token`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) whenever(jetpackConnector.connectUser(any(), any())).thenReturn(Result.success(TEST_USER_ID)) @@ -282,7 +277,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { fun `finalize step succeeds and completes step`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) whenever(jetpackConnector.connectUser(any(), any())).thenReturn(Result.success(TEST_USER_ID)) whenever(jetpackModuleHelper.activateStatsModule(any())).thenReturn(Result.success(Unit)) @@ -297,7 +292,7 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { fun `finalize step fails on exception`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) whenever(jetpackConnector.connectUser(any(), any())).thenReturn(Result.success(TEST_USER_ID)) whenever(jetpackModuleHelper.activateStatsModule(any())).thenReturn(Result.failure(Exception("Stats failed"))) @@ -313,18 +308,29 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { } @Test - fun `onRequestedWithInvalidAuthentication resets and restarts flow`() = runTest { - viewModel.onRequestedWithInvalidAuthentication("https://example.com") + fun `install auth failure resets token and restarts the flow`() = runTest { + whenever(accountStore.hasAccessToken()) + .thenReturn(true) // initial LoginWpCom completes so the install step runs + .thenReturn(false) // after the reset, the restarted flow waits at the login step + // The install client signals invalid auth locally via the onInvalidAuth callback (2nd arg), + // not the app-wide WpAppNotifierHandler. Simulate that callback firing on a 401 (see #22944). + whenever(jetpackInstaller.installJetpack(any(), any())).doSuspendableAnswer { + it.getArgument<() -> Unit>(1).invoke() + Result.failure(Exception("Invalid credentials")) + } + + viewModel.onStartClick() + advanceTimeBy(TEST_ADVANCE_TIME_MS) + advanceUntilIdle() - verify(wpAppNotifierHandler).removeListener(viewModel) verify(accountStore).resetAccessToken() - assertThat(viewModel.currentStep.value).isEqualTo(ConnectionStep.LoginWpCom) + assertThat(viewModel.uiEvent.value).isEqualTo(UiEvent.StartWPComLogin) } @Test fun `step timeout triggers timeout error`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).doSuspendableAnswer { + whenever(jetpackInstaller.installJetpack(any(), any())).doSuspendableAnswer { delay(TEST_STEP_TIMEOUT_MS + 10L) // Longer than TEST_STEP_TIMEOUT_MS to trigger timeout Result.success(PluginStatus.ACTIVE) } @@ -410,10 +416,10 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { } @Test - fun `successful flow completion sets Done button and removes listener`() = runTest { + fun `successful flow completion sets Done button`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) whenever(accountStore.accessToken).thenReturn(TEST_ACCESS_TOKEN) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.success(PluginStatus.ACTIVE)) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.success(PluginStatus.ACTIVE)) whenever(jetpackConnector.connectSite(any())).thenReturn(Result.success(TEST_SITE_ID)) whenever(jetpackConnector.connectUser(any(), any())).thenReturn(Result.success(TEST_USER_ID)) whenever(jetpackModuleHelper.activateStatsModule(any())).thenReturn(Result.success(Unit)) @@ -422,13 +428,12 @@ class JetpackRestConnectionViewModelTest : BaseUnitTest() { advanceTimeBy(TEST_ADVANCE_TIME_MS) assertThat(viewModel.buttonType.value).isEqualTo(ButtonType.Done) - verify(wpAppNotifierHandler).removeListener(viewModel) } @Test fun `failed flow completion sets Retry button`() = runTest { whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(jetpackInstaller.installJetpack(any())).thenReturn(Result.failure(Exception("Failed"))) + whenever(jetpackInstaller.installJetpack(any(), any())).thenReturn(Result.failure(Exception("Failed"))) viewModel.onStartClick() advanceTimeBy(TEST_ADVANCE_TIME_MS) From ce8edf521bad2452bca40f5170c0ebfcf92ab790 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 31 Aug 2026 13:23:03 -0400 Subject: [PATCH 18/32] Fix a 401 relaunch loop, a lost connectivity banner, and a swallowed re-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the new provisioning pipeline, found reviewing #22944. 1. The pipeline's own capability detection can raise the 401 that feeds back into healForInvalidAuth. getWpComApiClient installs the same invalid-auth notifier as the application-password client, so a rejected WP.com bearer token on an Atomic site 401s the theme fetch every run while the app password validates fine. Nothing broke the cycle: the run settles Ready or Unreachable, so neither the Unprovisionable guard nor the "already escalated" check fires, and the deferred heal relaunches forever. ensureAuth now reports whether it actually replaced the credentials, and PipelineResult carries whether auth was confirmed. A heal that confirmed a working password without replacing it cannot be the fix for a 401, so the site is recorded in healFutile and its 401s are dropped until an explicit retry. A heal that re-minted, or one that never reached the site, is not marked futile — so genuine revocation still heals. 2. The validator maps every ambiguous failure (DNS, timeout, refused, 5xx) to NetworkUnavailable so it never wipes credentials on a guess, and ensureAuth turned that into Provisioning. Since the banner renders only Unreachable and the card hides on Provisioning, a site that was simply down showed nothing at all — the exact case "Unable to connect to your site" exists for. ensureAuth now distinguishes device-offline from site-unreachable via SiteAuthState.SiteUnreachable, which maps to SiteReadiness.Unreachable. 3. A routine run never arms reauthOnFailure, so when a 401 arrived mid-run and that run settled Unprovisionable, the deferred handler returned "already escalated" when nothing had escalated. It now escalates instead of relaunching (a relaunch would only re-fail the mint), through a single escalateReauth funnel that is idempotent per Unprovisionable episode. Each fix has a test that fails when the fix is reverted; the loop test exhausts the test JVM without the guard. Also renames the transient-validation test, which was passing on a defaulted mock rather than a stated offline precondition. --- .../repositories/SiteProvisioningSource.kt | 165 ++++++++++++++---- .../ApplicationPasswordViewModelSlice.kt | 2 + .../SiteConnectivityBannerViewModelSlice.kt | 11 +- .../SiteProvisioningSourceTest.kt | 122 ++++++++++++- 4 files changed, 263 insertions(+), 37 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 0ddecbe7b957..54afc34c9bf1 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -95,6 +95,18 @@ class SiteProvisioningSource @Inject constructor( // licenses the application-password card to show the XML-RPC-disabled warning. private val xmlRpcUnavailable = ConcurrentHashMap.newKeySet() + // Sites whose last heal ran to completion without changing the credentials. Their 401s aren't an + // application-password problem (a WP.com bearer token rejected on the proxy raises the same + // notification), so re-running ensureAuth can only re-validate the same good password. Without this + // the pipeline's own detection calls feed their 401 back into healForInvalidAuth and relaunch + // forever. Cleared by invalidate / clear, so a user-initiated retry re-enables healing. + private val healFutile = ConcurrentHashMap.newKeySet() + + // Sites already escalated to interactive re-auth for their current Unprovisionable state. Both + // escalation paths funnel through escalateReauth, which uses this for idempotency; a run settling + // anything else clears it, so a later revocation escalates again. + private val escalated = ConcurrentHashMap.newKeySet() + init { // Re-provision when wordpress-rs reports a request was rejected for invalid auth (the app // password was revoked / rotated server-side). Without this, a site latched Ready keeps its @@ -140,6 +152,10 @@ class SiteProvisioningSource @Inject constructor( // No-op while running — see KDoc: don't pre-empt an in-flight mint. if (jobs[site.id]?.isActive == true) return ready.remove(site.id) + // An explicit retry is the user asking us to try everything again, so drop the futile-heal and + // already-escalated verdicts that would otherwise suppress a 401 heal / re-auth prompt. + healFutile.remove(site.id) + escalated.remove(site.id) launchPipeline(site.id) } @@ -152,6 +168,8 @@ class SiteProvisioningSource @Inject constructor( ready.clear() reauthOnFailure.clear() xmlRpcUnavailable.clear() + healFutile.clear() + escalated.clear() } /** @@ -181,6 +199,9 @@ class SiteProvisioningSource @Inject constructor( if (site.isWPComSimpleSite) return val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth if (auth is SiteAuthState.Unprovisionable) return + // A previous heal already proved re-provisioning doesn't fix this site's 401s — healing again + // would just re-validate the same working password and re-raise the same 401. + if (site.id in healFutile) return healForInvalidAuth(site) } @@ -189,9 +210,14 @@ class SiteProvisioningSource @Inject constructor( * heal escalates to interactive re-auth. If a run is already in flight we must not pre-empt a * possibly mid-mint stage — but the 401 must not be swallowed either: that run may have validated the * credential *before* it was revoked and will settle Ready, consuming nothing and healing nothing. So - * defer a fresh heal until the active run finishes, unless it settled [SiteAuthState.Unprovisionable] - * (in which case it already escalated on its own). The flag is armed only when the heal actually + * defer a fresh heal until the active run finishes. The flag is armed only when the heal actually * launches, so the in-flight run's tail can't consume it for an outcome it never serviced. + * + * Two cases end the deferral instead of relaunching. A run that settled + * [SiteAuthState.Unprovisionable] has already made a terminal mint attempt, so relaunching would + * only re-fail it — escalate to interactive re-auth instead (a *routine* run never armed + * [reauthOnFailure], so it never escalated on its own). And a site in [healFutile] has already had + * a heal change nothing, so its 401 is not an application-password problem. */ @Synchronized private fun healForInvalidAuth(site: SiteModel) { @@ -207,8 +233,16 @@ class SiteProvisioningSource @Inject constructor( if (cause != null) return@invokeOnCompletion // cancelled / relaunched — a fresh run is coming synchronized(this@SiteProvisioningSource) { if (jobs[siteLocalId]?.isActive == true) return@synchronized // a newer run is already underway + // Re-checked here, not just at registration time: the run we were waiting on may itself + // have been the heal that proved healing futile. + if (siteLocalId in healFutile) return@synchronized val settledAuth = (states[siteLocalId]?.value as? SiteReadiness.NeedsAuth)?.auth - if (settledAuth is SiteAuthState.Unprovisionable) return@synchronized // already escalated + if (settledAuth is SiteAuthState.Unprovisionable) { + // Terminal mint failure — a relaunch would just re-fail it. Escalate instead, which + // a routine run's tail skipped because it never armed reauthOnFailure. + escalateReauth(siteLocalId, settledAuth) + return@synchronized + } reauthOnFailure.add(siteLocalId) ready.remove(siteLocalId) launchPipeline(siteLocalId) @@ -217,17 +251,40 @@ class SiteProvisioningSource @Inject constructor( } /** - * After a 401-triggered run settles, escalate to interactive re-auth only if the heal couldn't - * recover a previously-working credential. Consuming the flag bounds this to one prompt per heal; - * a routine (non-401) run never set the flag, so it never prompts. + * Records what a settled run means for future 401s, and escalates to interactive re-auth when a + * heal couldn't recover a previously-working credential. + * + * [wasHeal] is the consumed [reauthOnFailure] flag: only a 401-triggered run may prompt, so a + * routine run that happens to settle [SiteAuthState.Unprovisionable] leaves the prompt to the + * application-password card. A heal that settled without changing the credentials is recorded in + * [healFutile] — the 401 came from something re-provisioning can't fix. */ - private fun maybeRequestReauth(siteLocalId: Int, readiness: SiteReadiness) { - if (!reauthOnFailure.remove(siteLocalId)) return - val auth = (readiness as? SiteReadiness.NeedsAuth)?.auth - if (auth is SiteAuthState.Unprovisionable && auth.hadCredentials) { - siteStore.getSiteByLocalId(siteLocalId)?.let { - applicationPasswordReauthNotifier.notifyReauthRequired(it.url) - } + private fun settleHealState(siteLocalId: Int, result: PipelineResult, wasHeal: Boolean) { + val auth = (result.readiness as? SiteReadiness.NeedsAuth)?.auth + if (auth is SiteAuthState.Unprovisionable) { + if (wasHeal) escalateReauth(siteLocalId, auth) + return + } + // Not stuck on auth any more, so a future revocation should be able to prompt again. + escalated.remove(siteLocalId) + // The heal confirmed the stored password works and replaced nothing, yet a 401 still prompted + // it — so re-provisioning cannot be the fix. Stop honouring this site's 401s until an explicit + // retry. A run that never got that far (site unreachable, offline) is inconclusive, not futile. + if (wasHeal && result.authConfirmed && !result.credentialsChanged) { + appLogWrapper.w( + AppLog.T.MAIN, + "A_P: Heal for $siteLocalId changed no credentials - suppressing further 401 heals" + ) + healFutile.add(siteLocalId) + } + } + + /** Prompts for interactive re-auth at most once per [SiteAuthState.Unprovisionable] episode. */ + private fun escalateReauth(siteLocalId: Int, auth: SiteAuthState.Unprovisionable) { + if (!auth.hadCredentials) return + if (!escalated.add(siteLocalId)) return + siteStore.getSiteByLocalId(siteLocalId)?.let { + applicationPasswordReauthNotifier.notifyReauthRequired(it.url) } } @@ -255,11 +312,12 @@ class SiteProvisioningSource @Inject constructor( // Latch the dedup gate only on a freshly live-probed Ready; a Ready served from stale cache // (latch = false) is left to re-probe on the next run instead of sticking for the process. if (result.latch) ready.add(siteLocalId) - // maybeRequestReauth reads the DB and drives the reauth notifier, and runs after the flow has + // settleHealState reads the DB and drives the reauth notifier, and runs after the flow has // already settled. Contain its throws too: on this non-supervisor appScope an escaping throw - // here would cancel the scope and wedge provisioning for every other site. + // here would cancel the scope and wedge provisioning for every other site. It must run + // before this job completes so the deferred heal handler sees the verdicts it records. try { - maybeRequestReauth(siteLocalId, result.readiness) + settleHealState(siteLocalId, result, wasHeal = reauthOnFailure.remove(siteLocalId)) } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { @@ -278,7 +336,7 @@ class SiteProvisioningSource @Inject constructor( jobs[siteLocalId]?.isActive != true && siteLocalId !in ready private suspend fun runPipeline(siteLocalId: Int): PipelineResult { - val auth = ensureAuth(siteLocalId) + val (auth, credentialsChanged) = ensureAuth(siteLocalId) return when (auth) { SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each @@ -293,9 +351,21 @@ class SiteProvisioningSource @Inject constructor( } val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } xmlRpc.await() - capabilities.await() + capabilities.await().copy( + credentialsChanged = credentialsChanged, + authConfirmed = true, + ) } - else -> PipelineResult(SiteReadiness.NeedsAuth(auth), latch = false) + // The site itself couldn't be reached, so nothing downstream can run. This is the + // connectivity banner's case, not the application-password card's — validation never got + // far enough to say anything about the credentials. + SiteAuthState.SiteUnreachable -> + PipelineResult(SiteReadiness.Unreachable, latch = false, credentialsChanged = false) + else -> PipelineResult( + SiteReadiness.NeedsAuth(auth), + latch = false, + credentialsChanged = credentialsChanged, + ) } } @@ -305,23 +375,34 @@ class SiteProvisioningSource @Inject constructor( * ones via the FluxC Jetpack tunnel. The mint persists the credentials (single-writer, #22947), so * the downstream stages read them back from a fresh [SiteModel] rather than having them threaded. */ - // Each return is a distinct auth outcome (missing site, valid, transient, minted, failed); - // collapsing to one return would thread a result through nested branches and read worse. + // Each return is a distinct auth outcome (missing site, valid, unreachable, transient, minted, + // failed); collapsing to one return would thread a result through nested branches and read worse. @Suppress("ReturnCount") - private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState { + private suspend fun ensureAuth(siteLocalId: Int): AuthOutcome { val site = siteStore.getSiteByLocalId(siteLocalId) - ?: return SiteAuthState.Unprovisionable(hadCredentials = false) + ?: return AuthOutcome(SiteAuthState.Unprovisionable(hadCredentials = false)) // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password // applies (the mint returns NotSupported). Capability detection works through the proxy, so // treat them as ready instead of blocking detection behind a mint that can never run. - if (site.isWPComSimpleSite) return SiteAuthState.NotApplicable + if (site.isWPComSimpleSite) return AuthOutcome(SiteAuthState.NotApplicable) val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { - ApplicationPasswordValidator.Outcome.Valid -> return SiteAuthState.Provisioned + // Credentials confirmed working, and untouched — nothing was re-minted. + ApplicationPasswordValidator.Outcome.Valid -> + return AuthOutcome(SiteAuthState.Provisioned) ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { - appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}") - return SiteAuthState.Provisioning + // The validator maps everything ambiguous — DNS, timeout, refused, 5xx — to this + // outcome so it never wipes credentials on a guess. That conflates two very + // different situations, and only the device-offline one should stay quiet: the + // global offline banner already covers it. If the device is online, the *site* is + // what we couldn't reach, which is exactly the connectivity banner's case (#22944). + if (networkUtilsWrapper.isNetworkAvailable()) { + appLogWrapper.d(AppLog.T.MAIN, "A_P: Site unreachable during validation: ${site.url}") + return AuthOutcome(SiteAuthState.SiteUnreachable) + } + appLogWrapper.d(AppLog.T.MAIN, "A_P: Device offline during validation for ${site.url}") + return AuthOutcome(SiteAuthState.Provisioning) } ApplicationPasswordValidator.Outcome.Invalid -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") @@ -336,13 +417,15 @@ class SiteProvisioningSource @Inject constructor( if (!createResult.isError && createResult.credentials != null) { wpApiClientProvider.clearSelfHostedClient(site.id) appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}") - return SiteAuthState.Provisioned + // A fresh mint replaced the credentials, so a 401 that prompted this run may genuinely be + // healed by it — this is the one path that marks the heal as having done something. + return AuthOutcome(SiteAuthState.Provisioned, credentialsChanged = true) } appLogWrapper.d( AppLog.T.MAIN, "A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})" ) - return SiteAuthState.Unprovisionable(hadCredentials = hadCredentials) + return AuthOutcome(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) } /** @@ -409,9 +492,24 @@ class SiteProvisioningSource @Inject constructor( /** * A settled pipeline result plus whether it should latch the per-site dedup gate ([ready]). Only a * freshly live-probed [SiteReadiness.Ready] latches; a Ready served from stale cache does not, so it - * re-probes on the next run. Internal to the pipeline — consumers only ever see [readiness]. + * re-probes on the next run. [credentialsChanged] is true only when this run actually re-minted the + * application password, which is how a heal proves it did something; [authConfirmed] is true when + * the run got far enough to establish the credentials are usable, which is what makes an unchanged + * heal conclusively futile rather than merely inconclusive. Internal to the pipeline — consumers + * only ever see [readiness]. */ - private data class PipelineResult(val readiness: SiteReadiness, val latch: Boolean) + private data class PipelineResult( + val readiness: SiteReadiness, + val latch: Boolean, + val credentialsChanged: Boolean = false, + val authConfirmed: Boolean = false, + ) + + /** What [ensureAuth] settled on, plus whether it replaced the stored credentials. */ + private data class AuthOutcome( + val state: SiteAuthState, + val credentialsChanged: Boolean = false, + ) } /** @@ -430,6 +528,11 @@ sealed interface SiteAuthState { * validation error occurred. The card stays hidden; the next run retries. */ data object Provisioning : SiteAuthState + /** The site couldn't be reached at all while the device was online, so validation says nothing + * about the credentials. Mapped to [SiteReadiness.Unreachable] rather than wrapped in + * [SiteReadiness.NeedsAuth]: it's the connectivity banner's case, not the card's. */ + data object SiteUnreachable : SiteAuthState + /** Terminal: the mint failed. [hadCredentials] distinguishes a re-authentication * (creds went bad) from a first-time authentication prompt. */ data class Unprovisionable(val hadCredentials: Boolean) : SiteAuthState diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index e57bb3599a99..31cc0c1793e6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -84,6 +84,8 @@ class ApplicationPasswordViewModelSlice @Inject constructor( } SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> Unit // never wrap in NeedsAuth — they proceed to detection + SiteAuthState.SiteUnreachable -> + Unit // never wrap in NeedsAuth — surfaces as Unreachable for the connectivity banner } // Any terminal provisioned state — the credentials are usable, so the only card left to // show is the self-hosted XML-RPC fallback. Capability outcome (Ready/Unreachable) is the diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt index fc17191982db..5da0eacb131b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt @@ -28,11 +28,12 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor( /** * Subscribes the banner to [site]'s readiness. The banner is a thin view over - * that state — it surfaces only when the site is provisioned but the capability - * probe failed ([SiteReadiness.Unreachable]). Every other state (probing, needs - * auth, offline, ready) leaves it hidden: when credentials are the problem the - * application-password card owns it, and the banner stays out of the way. - * [isUserInitiated] (pull-to-refresh, retry) forces a fresh run. + * that state — it surfaces only on [SiteReadiness.Unreachable], which the pipeline + * reports both when the capability probe failed and when the site couldn't be + * reached at the auth stage while the device was online. Every other state + * (probing, needs auth, offline, ready) leaves it hidden: when credentials are the + * problem the application-password card owns it, and the banner stays out of the + * way. [isUserInitiated] (pull-to-refresh, retry) forces a fresh run. */ fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) { collectJob?.cancel() diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 0601759dfe81..13186bfe47b1 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -144,9 +144,10 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { } @Test - fun `given a transient validation error, then provisioning and no mint or probe`() = test { + fun `given a transient validation error while offline, then provisioning and no mint or probe`() = test { stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.NetworkUnavailable) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) val result = source.await(site) @@ -465,6 +466,125 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) } + @Test + fun `given the site is unreachable while online, then unreachable rather than provisioning`() = test { + // The validator collapses DNS / timeout / refused / 5xx into NetworkUnavailable so it never + // wipes credentials on a guess. With the device online that means the *site* is down, which is + // the connectivity banner's case — reporting Provisioning here hides the banner entirely and + // loses the one state it was written for (#22944 c2). + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.NetworkUnavailable) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + val result = source.await(site) + + assertThat(result).isEqualTo(SiteReadiness.Unreachable) + verify(siteStore, never()).createApplicationPassword(any()) + verify(editorSettingsRepository, never()).fetchEditorCapabilitiesForSite(any()) + } + + @Test + fun `given the pipeline's own request 401s every run, then healing stops instead of looping`() = test { + // The WP.com bearer client used for capability detection raises the same invalid-auth + // notification as a revoked application password. With a rejected bearer token the app password + // validates fine every run, so an unguarded heal relaunches the pipeline forever (#22944 c1). + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + whenever(editorSettingsRepository.fetchEditorCapabilitiesForSite(any())).thenAnswer { + source.onRequestedWithInvalidAuthentication(site) + true + } + + source.await(site) + testScheduler.advanceUntilIdle() + + // One routine run plus exactly one heal; the heal proved re-provisioning changes nothing. + verify(applicationPasswordValidator, times(2)).validate(any()) + verify(siteStore, never()).createApplicationPassword(any()) + } + + @Test + fun `given a heal changed no credentials, then later 401s are ignored`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site) // heal #1: validates Valid, changes nothing + source.await(site) + source.onRequestedWithInvalidAuthentication(site) // must be dropped, not healed again + testScheduler.advanceUntilIdle() + + verify(applicationPasswordValidator, times(1)).validate(any()) + } + + @Test + fun `given an explicit retry after a futile heal, then 401s are honoured again`() = test { + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site) + source.await(site) + source.invalidate(site) // pull-to-refresh clears the futile verdict + testScheduler.advanceUntilIdle() + source.onRequestedWithInvalidAuthentication(site) + testScheduler.advanceUntilIdle() + + // Futile heal, then the invalidate run, then the re-enabled heal. + verify(applicationPasswordValidator, times(3)).validate(any()) + } + + @Test + fun `given a heal re-minted the credentials, then later 401s still heal`() = test { + // A heal that actually replaced the password did something, so it must not be recorded as + // futile — otherwise one genuine revocation permanently disables healing for the site. + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintSuccess() + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site) + source.await(site) + source.onRequestedWithInvalidAuthentication(site) + testScheduler.advanceUntilIdle() + + verify(siteStore, times(2)).createApplicationPassword(any()) + } + + @Test + fun `given an unreachable site during a heal, then the heal is not recorded as futile`() = test { + // The heal never got far enough to confirm the credentials, so it's inconclusive rather than + // proof that re-provisioning can't help. + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.NetworkUnavailable) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + source.onRequestedWithInvalidAuthentication(site) + source.await(site) + source.onRequestedWithInvalidAuthentication(site) + testScheduler.advanceUntilIdle() + + verify(applicationPasswordValidator, times(2)).validate(any()) + } + + @Test + fun `given a routine run settles unprovisionable with a 401 pending, then it escalates once`() = test { + // A routine run never arms the heal flag, so its own tail skips escalation. The deferred handler + // used to return "already escalated" on that outcome and swallow the 401 entirely, leaving no + // interactive re-auth and dropping every later 401 via the Unprovisionable guard (#22944 c3). + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + + source.stateFor(site) // routine run, still active + source.onRequestedWithInvalidAuthentication(site) // 401 mid-run -> deferred + testScheduler.advanceUntilIdle() + + verify(applicationPasswordReauthNotifier, times(1)).notifyReauthRequired(site.url) + // The run already made a terminal mint attempt; relaunching would only re-fail it. + verify(siteStore, times(1)).createApplicationPassword(any()) + } + // endregion // region failure containment From 4bde97c9c746c35071a2fbeaaad0a9f1ea819f83 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 31 Aug 2026 13:45:36 -0400 Subject: [PATCH 19/32] Model the auth stage and heal evidence as their own types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up cleanup on the provisioning pipeline; no behaviour change. SiteAuthState had five variants but only two — Provisioning and Unprovisionable — were ever wrapped in NeedsAuth and observed by a consumer. The other three were ensureAuth control-flow signals that happened to live in a public sealed interface, which forced the application-password card into two dead `when` arms. ensureAuth now returns a private AuthStage (Proceed / SiteUnreachable / Stop), so runPipeline is a three-way when with no else and SiteAuthState means exactly what the card renders. PipelineResult carried credentialsChanged and authConfirmed, two booleans encoding a three-state fact with one combination that was unrepresentable in practice but perfectly constructible. They collapse into a HealEvidence enum (Inconclusive / ConfirmedUnchanged / Replaced) derived entirely in ensureAuth, so the futile-heal test is a single equality and runPipeline no longer sets authConfirmed on the side. This also drops a dead credentialsChanged pass-through that was always false on the NeedsAuth branch. No test file needed changing: nothing referenced the three removed variants. The loop test only passes when healFutile is populated, so it exercises the new evidence wiring end to end — verified by emitting Replaced on the Valid path, which reproduces the 401 loop and hangs the suite. One unreachable semantic shift: a WP.com Simple site used to satisfy the futile condition and now reports Inconclusive, which is the honest encoding since no application password applies. Simple sites return early from onRequestedWithInvalidAuthentication, so they can never be a heal. --- .../repositories/SiteProvisioningSource.kt | 113 +++++++++--------- .../ApplicationPasswordViewModelSlice.kt | 4 - 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 54afc34c9bf1..82d4e1cb5f40 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -269,8 +269,8 @@ class SiteProvisioningSource @Inject constructor( escalated.remove(siteLocalId) // The heal confirmed the stored password works and replaced nothing, yet a 401 still prompted // it — so re-provisioning cannot be the fix. Stop honouring this site's 401s until an explicit - // retry. A run that never got that far (site unreachable, offline) is inconclusive, not futile. - if (wasHeal && result.authConfirmed && !result.credentialsChanged) { + // retry. Inconclusive runs (site unreachable, offline, no password applies) prove nothing. + if (wasHeal && result.evidence == HealEvidence.ConfirmedUnchanged) { appLogWrapper.w( AppLog.T.MAIN, "A_P: Heal for $siteLocalId changed no credentials - suppressing further 401 heals" @@ -336,9 +336,8 @@ class SiteProvisioningSource @Inject constructor( jobs[siteLocalId]?.isActive != true && siteLocalId !in ready private suspend fun runPipeline(siteLocalId: Int): PipelineResult { - val (auth, credentialsChanged) = ensureAuth(siteLocalId) - return when (auth) { - SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> coroutineScope { + return when (val stage = ensureAuth(siteLocalId)) { + is AuthStage.Proceed -> coroutineScope { // Post-auth, the REST-capability chain and the XML-RPC recovery are independent — each // reads the site fresh and writes only its own column — so run them in parallel. // recoverRestUrlIfNeeded precedes detectCapabilities within its branch because the probe @@ -351,21 +350,13 @@ class SiteProvisioningSource @Inject constructor( } val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } xmlRpc.await() - capabilities.await().copy( - credentialsChanged = credentialsChanged, - authConfirmed = true, - ) + capabilities.await().copy(evidence = stage.evidence) } // The site itself couldn't be reached, so nothing downstream can run. This is the // connectivity banner's case, not the application-password card's — validation never got // far enough to say anything about the credentials. - SiteAuthState.SiteUnreachable -> - PipelineResult(SiteReadiness.Unreachable, latch = false, credentialsChanged = false) - else -> PipelineResult( - SiteReadiness.NeedsAuth(auth), - latch = false, - credentialsChanged = credentialsChanged, - ) + AuthStage.SiteUnreachable -> PipelineResult(SiteReadiness.Unreachable, latch = false) + is AuthStage.Stop -> PipelineResult(SiteReadiness.NeedsAuth(stage.auth), latch = false) } } @@ -378,19 +369,19 @@ class SiteProvisioningSource @Inject constructor( // Each return is a distinct auth outcome (missing site, valid, unreachable, transient, minted, // failed); collapsing to one return would thread a result through nested branches and read worse. @Suppress("ReturnCount") - private suspend fun ensureAuth(siteLocalId: Int): AuthOutcome { + private suspend fun ensureAuth(siteLocalId: Int): AuthStage { val site = siteStore.getSiteByLocalId(siteLocalId) - ?: return AuthOutcome(SiteAuthState.Unprovisionable(hadCredentials = false)) + ?: return AuthStage.Stop(SiteAuthState.Unprovisionable(hadCredentials = false)) // WP.com Simple sites are fully proxied and OAuth-bearer-authed — no application password // applies (the mint returns NotSupported). Capability detection works through the proxy, so - // treat them as ready instead of blocking detection behind a mint that can never run. - if (site.isWPComSimpleSite) return AuthOutcome(SiteAuthState.NotApplicable) + // treat them as ready instead of blocking detection behind a mint that can never run. No + // password is involved, so there is nothing for a heal to confirm. + if (site.isWPComSimpleSite) return AuthStage.Proceed(HealEvidence.Inconclusive) val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site) if (hadCredentials) { when (applicationPasswordValidator.validate(site)) { - // Credentials confirmed working, and untouched — nothing was re-minted. ApplicationPasswordValidator.Outcome.Valid -> - return AuthOutcome(SiteAuthState.Provisioned) + return AuthStage.Proceed(HealEvidence.ConfirmedUnchanged) ApplicationPasswordValidator.Outcome.NetworkUnavailable -> { // The validator maps everything ambiguous — DNS, timeout, refused, 5xx — to this // outcome so it never wipes credentials on a guess. That conflates two very @@ -399,10 +390,10 @@ class SiteProvisioningSource @Inject constructor( // what we couldn't reach, which is exactly the connectivity banner's case (#22944). if (networkUtilsWrapper.isNetworkAvailable()) { appLogWrapper.d(AppLog.T.MAIN, "A_P: Site unreachable during validation: ${site.url}") - return AuthOutcome(SiteAuthState.SiteUnreachable) + return AuthStage.SiteUnreachable } appLogWrapper.d(AppLog.T.MAIN, "A_P: Device offline during validation for ${site.url}") - return AuthOutcome(SiteAuthState.Provisioning) + return AuthStage.Stop(SiteAuthState.Provisioning) } ApplicationPasswordValidator.Outcome.Invalid -> { appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing") @@ -419,13 +410,13 @@ class SiteProvisioningSource @Inject constructor( appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${site.url}") // A fresh mint replaced the credentials, so a 401 that prompted this run may genuinely be // healed by it — this is the one path that marks the heal as having done something. - return AuthOutcome(SiteAuthState.Provisioned, credentialsChanged = true) + return AuthStage.Proceed(HealEvidence.Replaced) } appLogWrapper.d( AppLog.T.MAIN, "A_P: Headless mint failed for ${site.url} (notSupported=${createResult.error?.notSupported})" ) - return AuthOutcome(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) + return AuthStage.Stop(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) } /** @@ -468,7 +459,7 @@ class SiteProvisioningSource @Inject constructor( /** * Stage 3 — probe the REST API for editor-capability support and persist it. - * Reached only once auth is [SiteAuthState.Provisioned] / [SiteAuthState.NotApplicable]. + * Reached only once [ensureAuth] returned [AuthStage.Proceed]. * Reads the site fresh: the mint has already persisted the credentials (single-writer, #22947), * so a probe failure here is a real transport problem, not a pending mint. */ @@ -492,47 +483,61 @@ class SiteProvisioningSource @Inject constructor( /** * A settled pipeline result plus whether it should latch the per-site dedup gate ([ready]). Only a * freshly live-probed [SiteReadiness.Ready] latches; a Ready served from stale cache does not, so it - * re-probes on the next run. [credentialsChanged] is true only when this run actually re-minted the - * application password, which is how a heal proves it did something; [authConfirmed] is true when - * the run got far enough to establish the credentials are usable, which is what makes an unchanged - * heal conclusively futile rather than merely inconclusive. Internal to the pipeline — consumers - * only ever see [readiness]. + * re-probes on the next run. [evidence] is what the run established about the credentials, which is + * how [settleHealState] judges whether a heal accomplished anything. Internal to the pipeline — + * consumers only ever see [readiness]. */ private data class PipelineResult( val readiness: SiteReadiness, val latch: Boolean, - val credentialsChanged: Boolean = false, - val authConfirmed: Boolean = false, + val evidence: HealEvidence = HealEvidence.Inconclusive, ) - /** What [ensureAuth] settled on, plus whether it replaced the stored credentials. */ - private data class AuthOutcome( - val state: SiteAuthState, - val credentialsChanged: Boolean = false, - ) + /** + * What [ensureAuth] settled on. Only [Stop] escapes the pipeline, as a + * [SiteReadiness.NeedsAuth] the application-password card renders. + */ + private sealed interface AuthStage { + /** The credentials are usable — run the rest of the pipeline. */ + data class Proceed(val evidence: HealEvidence) : AuthStage + + /** The site couldn't be reached while the device was online, so validation says nothing about + * the credentials. Surfaces as [SiteReadiness.Unreachable] for the connectivity banner. */ + data object SiteUnreachable : AuthStage + + /** Stopped at the auth stage; [auth] is what the card should show. */ + data class Stop(val auth: SiteAuthState) : AuthStage + } + + /** + * What a run established about the site's application password — the basis for deciding whether a + * 401-triggered heal did anything, and so whether repeating it could ever help. + */ + private enum class HealEvidence { + /** Nothing was established: the site was unreachable, the device was offline, the mint failed, + * or no application password applies to the site at all. */ + Inconclusive, + + /** The stored credentials were confirmed working and left untouched. A heal that ends here + * cannot be the fix for the 401 that prompted it. */ + ConfirmedUnchanged, + + /** The credentials were replaced by a fresh mint, so the heal may genuinely have healed. */ + Replaced, + } } /** - * Whether a site's application password is usable. Owned by [SiteProvisioningSource]; - * rendered by the application-password card. + * Why a site's application password isn't usable yet. Owned by [SiteProvisioningSource] and rendered + * by the application-password card — these are the only two auth outcomes a consumer ever observes. + * Everything else the auth stage can settle on (usable credentials, an unreachable site) stays inside + * the pipeline as `AuthStage`, so a `when` over this type has no unreachable branches. */ sealed interface SiteAuthState { - /** Credentials are usable (validated, or freshly minted). */ - data object Provisioned : SiteAuthState - - /** No application password applies — a WP.com Simple site, which is proxy-served and - * OAuth-bearer-authed. Treated like [Provisioned]: capability detection runs via the proxy. */ - data object NotApplicable : SiteAuthState - /** Not usable yet, but not a terminal failure — a mint is implied / a transient * validation error occurred. The card stays hidden; the next run retries. */ data object Provisioning : SiteAuthState - /** The site couldn't be reached at all while the device was online, so validation says nothing - * about the credentials. Mapped to [SiteReadiness.Unreachable] rather than wrapped in - * [SiteReadiness.NeedsAuth]: it's the connectivity banner's case, not the card's. */ - data object SiteUnreachable : SiteAuthState - /** Terminal: the mint failed. [hadCredentials] distinguishes a re-authentication * (creds went bad) from a first-time authentication prompt. */ data class Unprovisionable(val hadCredentials: Boolean) : SiteAuthState @@ -549,7 +554,7 @@ sealed interface SiteReadiness { data object Probing : SiteReadiness /** Stopped at the auth stage — credentials aren't usable. Carries the - * [SiteAuthState] so the card can pick re-auth vs. first-auth. */ + * [SiteAuthState] so the card can pick re-auth vs. first-auth vs. hidden. */ data class NeedsAuth(val auth: SiteAuthState) : SiteReadiness /** Provisioned and editor capabilities are known (detected or cached). */ diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 31cc0c1793e6..6121bf8f5bd4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -82,10 +82,6 @@ class ApplicationPasswordViewModelSlice @Inject constructor( uiModelMutable.postValue(null) appLogWrapper.d(AppLog.T.MAIN, "A_P: Provisioning in progress for ${site.url}") } - SiteAuthState.Provisioned, SiteAuthState.NotApplicable -> - Unit // never wrap in NeedsAuth — they proceed to detection - SiteAuthState.SiteUnreachable -> - Unit // never wrap in NeedsAuth — surfaces as Unreachable for the connectivity banner } // Any terminal provisioned state — the credentials are usable, so the only card left to // show is the self-hosted XML-RPC fallback. Capability outcome (Ready/Unreachable) is the From 9fc83581e9b845970e238bf9506441d9b7d0511f Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 31 Aug 2026 14:00:37 -0400 Subject: [PATCH 20/32] Extract the 401 heal predicate to satisfy detekt's ReturnCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The healFutile guard added a third early return to onRequestedWithInvalidAuthentication, one past detekt's limit of 2. The three skip conditions collapse cleanly into a conjunction, so extract them as canHeal() rather than suppressing the rule — the per-condition rationale reads better as KDoc on the predicate than as inline comments between guards. Also fixes a stale [maybeRequestReauth] KDoc link left by the rename to settleHealState. No behaviour change: canHeal reads the states map before the Simple-site check instead of after, which is a side-effect-free lookup. --- .../repositories/SiteProvisioningSource.kt | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 82d4e1cb5f40..a35f9fa02c77 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -184,25 +184,33 @@ class SiteProvisioningSource @Inject constructor( * authentication (a revoked application password, or an expired WP.com bearer token). Re-provision it * so ensureAuth re-validates and heals: for a WP.com-connected site the headless re-mint succeeds and * recovery is silent; for one that can't be re-minted the run settles Unprovisionable and - * [maybeRequestReauth] escalates to interactive re-auth. WP.com Simple sites are bearer-only (no - * application password), so they're skipped here. + * [settleHealState] escalates to interactive re-auth. * * The notifier hands us the exact [SiteModel] whose client raised the 401, so we heal that one row by * id — no resolving back by URL (which isn't unique: the DB constraint is on SITE_ID+URL, so two rows - * can share a URL). Already-Unprovisionable sites are skipped — their re-auth is pending the user, and - * re-running would just re-fail the mint on every 401. When a run is already in flight, - * [healForInvalidAuth] defers the heal until it finishes rather than pre-empting a possibly mid-mint - * stage, and skips it if that run already settled Unprovisionable — so a validate that itself 401s - * can't spin this into a loop. + * can share a URL). When a run is already in flight, [healForInvalidAuth] defers the heal until it + * finishes rather than pre-empting a possibly mid-mint stage — so a validate that itself 401s can't + * spin this into a loop. */ override fun onRequestedWithInvalidAuthentication(site: SiteModel) { - if (site.isWPComSimpleSite) return + if (canHeal(site)) healForInvalidAuth(site) + } + + /** + * Whether re-provisioning could plausibly fix a 401 for [site]: + * + * - WP.com Simple sites are bearer-only — no application password applies, so there is nothing to + * heal. + * - An already-[SiteAuthState.Unprovisionable] site has its re-auth pending the user; re-running + * would just re-fail the mint on every 401. + * - A site in [healFutile] already had a heal confirm its stored password and replace nothing, so + * its 401s come from something re-provisioning can't fix. + */ + private fun canHeal(site: SiteModel): Boolean { val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth - if (auth is SiteAuthState.Unprovisionable) return - // A previous heal already proved re-provisioning doesn't fix this site's 401s — healing again - // would just re-validate the same working password and re-raise the same 401. - if (site.id in healFutile) return - healForInvalidAuth(site) + return !site.isWPComSimpleSite && + auth !is SiteAuthState.Unprovisionable && + site.id !in healFutile } /** From 8d14e32ce1e54a4fbe589a98600661cfbd828177 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 31 Aug 2026 15:11:09 -0400 Subject: [PATCH 21/32] Move the release note to the active section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note was filed under 26.9, which shipped on 1 July. Trunk's top section is still labelled 27.0 even though versionName is 27.2 and both 27.0 and 27.1 have released — that top section is the accumulating bucket everything lands in, so append there rather than adding a new 27.2 heading. Left under 26.9 the note would never have reached a release. --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index d846e97bec0d..8e805abba4ca 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -14,10 +14,10 @@ * [**] Images and videos shared to the app from the photo picker now upload instead of being silently dropped. * [**] Media shared from apps that generate it on the fly, such as an "Enhanced" photo from Google Photos, now keeps its correct file type instead of always uploading as a JPEG, and sharing several photos at once no longer drops some of them. [https://github.com/wordpress-mobile/WordPress-Android/issues/23047] * [**] Self-hosted sites added with an application password now have their Jetpack status detected, so opening Stats in the Jetpack app no longer asks you to install a plugin your site already has. +* [*] Reworked editor capability detection to be more reliable and prevent a false "Unable to connect to your site" banner on private Atomic sites. 26.9 ----- -* [*] Reworked editor capability detection to be more reliable and prevent a false "Unable to connect to your site" banner on private Atomic sites. 26.8 ----- From 6989221adfe6fcbd57c5b8afc062d4664fc8078d Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Mon, 31 Aug 2026 16:03:53 -0400 Subject: [PATCH 22/32] Tell the user their site is private instead of "not supported" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in to a private WordPress.com site reported "The provided site does not support Application Password authentication." The site supports them fine — the Privacy gate sits in front of WordPress and answers the anonymous discovery request with 403 private_site, so discovery never reaches the REST API. Two things were wrong. The reason was thrown away. The ViewModel signalled failure by sending an empty discoveryURL, and the fragment read that empty string as "unsupported" and overwrote the specific message it had just been given. Every discovery failure — DNS, timeout, TLS, 403, malformed URL — reported the same wrong cause. Fixed structurally rather than by patching the message: discoveryURL now only ever carries a URL to navigate to, and failure travels through errorMessage alone, so no future failure path can clobber it either. setError() went with its only caller. The reason wasn't named. FailureFetchAndParseApiRoot carries a WpError with the error code, message and status, so DiscoveryResult.Failed now carries a FailureReason and the UI can explain a private site rather than guess. Verified against a live private Atomic site rather than assumed: the library surfaces the code as WpErrorCode.CustomException("private_site") with status 403. Non-private failures now surface the library's own message, which is still better than the previous blanket claim. Leaves application_password_not_supported_error unreferenced. It is tagged a8c-src-lib="module:login", so removing it means every values-*/strings.xml plus whatever the string sync does — worth its own change. --- .../login/ApplicationPasswordLoginHelper.kt | 63 +++++++++++++++++-- .../LoginSiteApplicationPasswordFragment.kt | 7 +-- .../LoginSiteApplicationPasswordViewModel.kt | 30 ++++++--- WordPress/src/main/res/values/strings.xml | 1 + ...ginSiteApplicationPasswordViewModelTest.kt | 39 ++++++++++-- 5 files changed, 117 insertions(+), 23 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt index 80b6208058c4..5385d61efdff 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt @@ -25,6 +25,8 @@ import org.wordpress.android.util.crashlogging.sendReportWithTag import rs.wordpress.api.kotlin.ApiDiscoveryResult import rs.wordpress.api.kotlin.WpLoginClient import uniffi.wp_api.DiscoveredAuthenticationMechanism +import uniffi.wp_api.FetchAndParseApiRootFailure +import uniffi.wp_api.WpErrorCode import uniffi.wp_api.applicationPasswordsUrl import java.net.URI import javax.inject.Inject @@ -36,6 +38,11 @@ private const val REASON_TAG = "reason" private const val SOURCE_TAG = "source" private const val ERROR_TAG = "error" +// WordPress.com returns this error code from the REST root of a site whose Privacy setting is +// Private (or Coming Soon). The gate sits in front of WordPress, so discovery never reaches the +// API — the site's Application Password support is irrelevant to the failure. +private const val PRIVATE_SITE_ERROR_CODE = "private_site" + class ApplicationPasswordLoginHelper @Inject constructor( @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val dispatcherWrapper: DispatcherWrapper, @@ -53,13 +60,35 @@ class ApplicationPasswordLoginHelper @Inject constructor( sealed class DiscoveryResult { data class Authorized(val authorizationUrl: String) : DiscoveryResult() - data class Failed(val userFacingMessage: String) : DiscoveryResult() + + /** + * Discovery couldn't reach or read the site's REST API. [userFacingMessage] is the library's + * description of what went wrong; [reason] narrows it when we can recognise the cause, so the + * UI can explain it rather than guess. + */ + data class Failed( + val userFacingMessage: String, + val reason: FailureReason = FailureReason.Unknown, + ) : DiscoveryResult() /** * The site is hosted on WordPress.com: API discovery reported OAuth2 as the authentication * mechanism, so it can't use Application Passwords and should log in via WordPress.com. */ object WpComSite : DiscoveryResult() + + /** A recognised cause for a [Failed] discovery. */ + enum class FailureReason { + /** Nothing more specific than the library's message. */ + Unknown, + + /** + * The site's Privacy setting blocks anonymous requests, so discovery got a 403 instead of + * the REST root. Nothing is wrong with the site's Application Password support — it just + * has to be publicly reachable for the login flow to read its API. + */ + PrivateSite, + } } @Suppress("TooGenericExceptionCaught") @@ -104,15 +133,39 @@ class ApplicationPasswordLoginHelper @Inject constructor( is ApiDiscoveryResult.FailureParseSiteUrl -> handleAuthenticationDiscoveryError( siteUrl, - urlDiscoveryResult.userFacingErrorMessage(siteUrl).orEmpty() + urlDiscoveryResult.userFacingErrorMessage(siteUrl).orEmpty(), + urlDiscoveryResult.failureReason(), ) } } - private fun handleAuthenticationDiscoveryError(siteUrl: String, message: String): DiscoveryResult { - appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - $message") + /** + * Recognise causes worth naming to the user. A [FetchAndParseApiRootFailure.WpError] means we + * reached the site and it answered with a REST error envelope, so its `code` is a reliable + * signal — WordPress.com sends `private_site` from a site whose Privacy setting hides it. + */ + private fun ApiDiscoveryResult.failureReason(): DiscoveryResult.FailureReason { + val wpError = (this as? ApiDiscoveryResult.FailureFetchAndParseApiRoot) + ?.fetchAndParseApiRootFailure as? FetchAndParseApiRootFailure.WpError + ?: return DiscoveryResult.FailureReason.Unknown + // `private_site` has no dedicated WpErrorCode, so the library surfaces it as a CustomException + // carrying the raw code string. + val rawCode = (wpError.errorCode as? WpErrorCode.CustomException)?.v1 + return if (rawCode == PRIVATE_SITE_ERROR_CODE) { + DiscoveryResult.FailureReason.PrivateSite + } else { + DiscoveryResult.FailureReason.Unknown + } + } + + private fun handleAuthenticationDiscoveryError( + siteUrl: String, + message: String, + reason: DiscoveryResult.FailureReason = DiscoveryResult.FailureReason.Unknown, + ): DiscoveryResult { + appLogWrapper.e(AppLog.T.API, "A_P: Error during API discovery for $siteUrl - $message ($reason)") AnalyticsTracker.track(Stat.BACKGROUND_REST_AUTODISCOVERY_FAILED) - return DiscoveryResult.Failed(message) + return DiscoveryResult.Failed(message, reason) } sealed class StoreCredentialsResult { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordFragment.kt index 85e65f552729..bc3631489100 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordFragment.kt @@ -95,12 +95,9 @@ class LoginSiteApplicationPasswordFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + // Only ever a URL to navigate to; a failed discovery surfaces through errorMessage. viewModel.discoveryURL.collect { url -> - if (url.isEmpty()) { - viewModel.setError(getString(R.string.application_password_not_supported_error)) - } else { - activityNavigator.openApplicationPasswordLogin(requireActivity(), url) - } + activityNavigator.openApplicationPasswordLogin(requireActivity(), url) } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt index e334e7886bae..038b8137c984 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt @@ -9,12 +9,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import org.wordpress.android.R import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.viewmodel.ResourceProvider import javax.inject.Inject @HiltViewModel class LoginSiteApplicationPasswordViewModel @Inject constructor( private val applicationPasswordLoginHelper: ApplicationPasswordLoginHelper, + private val resourceProvider: ResourceProvider, ) : ViewModel() { private val _discoveryURL = Channel(Channel.BUFFERED) val discoveryURL = _discoveryURL.receiveAsFlow() @@ -39,25 +42,36 @@ class LoginSiteApplicationPasswordViewModel @Inject constructor( _discoveryURL.send(result.authorizationUrl) is ApplicationPasswordLoginHelper.DiscoveryResult.WpComSite -> _wpComDetected.send(siteUrl) - is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { - _errorMessage.value = result.userFacingMessage - _discoveryURL.send("") - } + // Report why discovery failed. Previously this also sent an empty discoveryURL, which + // the fragment read as "unsupported" and used to overwrite this message with a generic + // one — so the real reason never reached the user. Failure is signalled by + // [errorMessage] alone; discoveryURL only ever carries a URL to navigate to. + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> + _errorMessage.value = messageFor(result) } _loadingStateFlow.value = false } } + /** + * Prefer an explanation the user can act on. The library's message for a private site is + * "failed to read its API configuration", which describes the symptom rather than the cause and + * reads as though the site is broken. + */ + private fun messageFor(result: ApplicationPasswordLoginHelper.DiscoveryResult.Failed): String = + when (result.reason) { + ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.PrivateSite -> + resourceProvider.getString(R.string.application_password_private_site_error) + ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.Unknown -> + result.userFacingMessage + } + fun cancelDiscovery() { discoveryJob?.cancel() discoveryJob = null _loadingStateFlow.value = false } - fun setError(message: String) { - _errorMessage.value = message - } - fun clearError() { _errorMessage.value = null } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 94b1599b6c03..2c44de5883fd 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4589,6 +4589,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Authorization cancelled Authenticate using Application Password The provided site does not support Application Password authentication. + This site is private, so we can\'t read its settings to sign you in. Invalid Application Password Your application password no longer exists. Please sign in again to create a new application password. Application Password Required diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt index 07b1080def1e..d91b163c257b 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt @@ -14,7 +14,9 @@ import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.viewmodel.ResourceProvider import kotlin.test.assertEquals import kotlin.test.assertNull @@ -24,6 +26,9 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { @Mock private lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper + @Mock + private lateinit var resourceProvider: ResourceProvider + private lateinit var viewModel: LoginSiteApplicationPasswordViewModel // Test dispatcher for coroutines @@ -32,7 +37,7 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { @Before fun setUp() { MockitoAnnotations.openMocks(this) - viewModel = LoginSiteApplicationPasswordViewModel(applicationPasswordLoginHelper) + viewModel = LoginSiteApplicationPasswordViewModel(applicationPasswordLoginHelper, resourceProvider) } @Test @@ -93,10 +98,10 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { } @Test - fun `Given discovery fails, when running discovery, then the generic error is shown`() = test { + fun `Given discovery fails, when running discovery, then the library message is shown as-is`() = test { // Given val siteUrl = "https://example.com" - val errorMessage = "not supported" + val errorMessage = "Found a site but failed to read its API configuration." whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed(errorMessage)) @@ -110,12 +115,36 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { viewModel.runApiDiscovery(siteUrl) advanceUntilIdle() - // Then + // Then — the specific reason survives, and no URL is emitted for the UI to misread as a + // failure signal (that empty emission is what used to overwrite this message). assertEquals(errorMessage, viewModel.errorMessage.value) - assertEquals("", collectedUrl) + assertNull(collectedUrl) assertEquals(false, wpComDetected) wpComJob.cancel() discoveryJob.cancel() } + + @Test + fun `Given a private site, when running discovery, then the private-site explanation is shown`() = test { + // The library reports a private site as a generic "couldn't read the API configuration", + // which reads as though the site is broken. Name the actual cause instead. + val siteUrl = "https://example.com" + val privateSiteMessage = "This site is private, so we can't read its settings to sign you in." + whenever(resourceProvider.getString(R.string.application_password_private_site_error)) + .thenReturn(privateSiteMessage) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Failed( + userFacingMessage = "Found a site but failed to read its API configuration.", + reason = ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.PrivateSite, + ) + ) + + viewModel.runApiDiscovery(siteUrl) + advanceUntilIdle() + + assertEquals(privateSiteMessage, viewModel.errorMessage.value) + assertEquals(false, viewModel.loadingStateFlow.value) + } } From bb96371c1a99a6f5173c2c68a52dc81e6ff7301f Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 11:47:41 -0400 Subject: [PATCH 23/32] Bound the 401 heal by budget, not by evidence type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings, four of them in the heal containment added earlier on this branch. The heal could disable itself on success. validate() talks through a notifier-wired client, so a revoked credential's 401 fires during the very heal that is fixing it. That scheduled a redundant second run, which re-validated the fresh password, recorded "confirmed unchanged", and permanently suppressed healing for the site. The deferred heal now stands down when the run it waited on already re-minted. Keying suppression on evidence type also missed the opposite case: a host that mints happily but never accepts the result produced Replaced every time, which never suppressed anything and re-minted on every 401 — accumulating application passwords on the user's account. healFutile is replaced by a spend counter: confirming an unchanged password exhausts the budget outright, a re-mint costs one, and reaching Ready without needing a heal restores it. invalidate() cleared that suppression below its in-flight guard, so it never ran for the sites that needed it — MySiteViewModel.refresh builds the application-password card first, and buildCard starts a run synchronously on Main.immediate. The clears move above the guard; only the relaunch has to respect an in-flight mint. escalateReauth read the DB and started an Activity while holding the class monitor, which the main thread takes on every stateFor / invalidate. The deferred handler now decides under the lock and acts outside it. Removing the fragment's blanket error overwrite took with it the only producer of application_password_not_supported_error — a site that advertises no application-passwords endpoint then showed raw untranslated exception text. That case gets its own FailureReason and the string back. The Jetpack install client lost buildUrl's empty-string guard when it moved off getWpApiClient, so an empty wpApiRestUrl reached ParsedUrl.parse instead of falling back. Tests use a validator stub that fires the notifier the way the real one does; plain thenReturn stubs hid the feedback loop that caused the first bug. --- .../repositories/SiteProvisioningSource.kt | 137 ++++++++++++------ .../login/ApplicationPasswordLoginHelper.kt | 22 ++- .../LoginSiteApplicationPasswordViewModel.kt | 2 + .../JetpackConnectionHelper.kt | 5 +- .../SiteProvisioningSourceTest.kt | 100 +++++++++++++ ...ginSiteApplicationPasswordViewModelTest.kt | 23 +++ 6 files changed, 237 insertions(+), 52 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index a35f9fa02c77..52da583df1f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -27,6 +27,10 @@ import javax.inject.Named import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException +// How many consecutive heals a site may attempt before we stop honouring its 401s. Two allows one +// retry after a re-mint that didn't take, without letting a self-feeding 401 run unbounded. +private const val MAX_CONSECUTIVE_HEALS = 2 + /** * The single source of truth for getting a site ready to use: it provisions * application-password credentials, recovers the REST API root, recovers the @@ -95,12 +99,19 @@ class SiteProvisioningSource @Inject constructor( // licenses the application-password card to show the XML-RPC-disabled warning. private val xmlRpcUnavailable = ConcurrentHashMap.newKeySet() - // Sites whose last heal ran to completion without changing the credentials. Their 401s aren't an - // application-password problem (a WP.com bearer token rejected on the proxy raises the same - // notification), so re-running ensureAuth can only re-validate the same good password. Without this - // the pipeline's own detection calls feed their 401 back into healForInvalidAuth and relaunch - // forever. Cleared by invalidate / clear, so a user-initiated retry re-enables healing. - private val healFutile = ConcurrentHashMap.newKeySet() + // Heal budget spent per site. The pipeline's own requests can 401 (validation and the capability + // probe both go through notifier-wired clients), so an unbounded heal feeds itself. A heal that + // confirmed the stored password and replaced nothing can never be the fix — the 401 came from + // something re-provisioning can't touch — so it spends the whole budget at once; one that + // re-minted might have worked, so it only counts against it, which bounds a host that mints + // happily but never accepts the result. Reset when a run reaches Ready without needing a heal, + // and by invalidate / clear. + private val healBudgetSpent = ConcurrentHashMap() + + // What the most recent run established about each site's credentials. The deferred heal reads it + // to avoid re-running after a run that already re-minted — validation's own 401 fires the notifier + // during the very heal that is fixing it, which would otherwise schedule a redundant second run. + private val lastEvidence = ConcurrentHashMap() // Sites already escalated to interactive re-auth for their current Unprovisionable state. Both // escalation paths funnel through escalateReauth, which uses this for idempotency; a run settling @@ -149,13 +160,16 @@ class SiteProvisioningSource @Inject constructor( */ @Synchronized fun invalidate(site: SiteModel) { + // Clear the suppression verdicts first, above the in-flight guard. Only the *relaunch* must not + // pre-empt a mid-mint run; dropping these sets pre-empts nothing. It has to happen here because + // MySiteViewModel.refresh builds the application-password card before it reaches this call, and + // that card's buildCard starts a run synchronously on Main.immediate — so by the time an + // explicit retry lands, a run is already active for exactly the sites that need clearing. + healBudgetSpent.remove(site.id) + escalated.remove(site.id) // No-op while running — see KDoc: don't pre-empt an in-flight mint. if (jobs[site.id]?.isActive == true) return ready.remove(site.id) - // An explicit retry is the user asking us to try everything again, so drop the futile-heal and - // already-escalated verdicts that would otherwise suppress a 401 heal / re-auth prompt. - healFutile.remove(site.id) - escalated.remove(site.id) launchPipeline(site.id) } @@ -168,7 +182,8 @@ class SiteProvisioningSource @Inject constructor( ready.clear() reauthOnFailure.clear() xmlRpcUnavailable.clear() - healFutile.clear() + healBudgetSpent.clear() + lastEvidence.clear() escalated.clear() } @@ -203,14 +218,20 @@ class SiteProvisioningSource @Inject constructor( * heal. * - An already-[SiteAuthState.Unprovisionable] site has its re-auth pending the user; re-running * would just re-fail the mint on every 401. - * - A site in [healFutile] already had a heal confirm its stored password and replace nothing, so - * its 401s come from something re-provisioning can't fix. + * - A site that has spent its heal budget ([healBudgetSpent]) has already had attempts that + * changed nothing, so its 401s come from something re-provisioning can't fix. */ - private fun canHeal(site: SiteModel): Boolean { - val auth = (states[site.id]?.value as? SiteReadiness.NeedsAuth)?.auth - return !site.isWPComSimpleSite && - auth !is SiteAuthState.Unprovisionable && - site.id !in healFutile + private fun canHeal(site: SiteModel): Boolean = + !site.isWPComSimpleSite && hasHealBudget(site.id) + + /** + * Whether [siteLocalId] may still be healed: its re-auth isn't already pending the user, and it + * hasn't spent its heal budget on attempts that changed nothing. + */ + private fun hasHealBudget(siteLocalId: Int): Boolean { + val auth = (states[siteLocalId]?.value as? SiteReadiness.NeedsAuth)?.auth + return auth !is SiteAuthState.Unprovisionable && + (healBudgetSpent[siteLocalId] ?: 0) < MAX_CONSECUTIVE_HEALS } /** @@ -224,8 +245,8 @@ class SiteProvisioningSource @Inject constructor( * Two cases end the deferral instead of relaunching. A run that settled * [SiteAuthState.Unprovisionable] has already made a terminal mint attempt, so relaunching would * only re-fail it — escalate to interactive re-auth instead (a *routine* run never armed - * [reauthOnFailure], so it never escalated on its own). And a site in [healFutile] has already had - * a heal change nothing, so its 401 is not an application-password problem. + * [reauthOnFailure], so it never escalated on its own). And a site that has spent its heal budget + * has already had attempts change nothing, so its 401 is not an application-password problem. */ @Synchronized private fun healForInvalidAuth(site: SiteModel) { @@ -239,22 +260,36 @@ class SiteProvisioningSource @Inject constructor( } active.invokeOnCompletion { cause -> if (cause != null) return@invokeOnCompletion // cancelled / relaunched — a fresh run is coming - synchronized(this@SiteProvisioningSource) { - if (jobs[siteLocalId]?.isActive == true) return@synchronized // a newer run is already underway - // Re-checked here, not just at registration time: the run we were waiting on may itself - // have been the heal that proved healing futile. - if (siteLocalId in healFutile) return@synchronized - val settledAuth = (states[siteLocalId]?.value as? SiteReadiness.NeedsAuth)?.auth - if (settledAuth is SiteAuthState.Unprovisionable) { - // Terminal mint failure — a relaunch would just re-fail it. Escalate instead, which - // a routine run's tail skipped because it never armed reauthOnFailure. - escalateReauth(siteLocalId, settledAuth) - return@synchronized - } - reauthOnFailure.add(siteLocalId) - ready.remove(siteLocalId) - launchPipeline(siteLocalId) - } + // Decide under the lock, act outside it: escalateReauth reads the DB and starts an + // Activity, and the main thread takes this same monitor on every stateFor / invalidate. + val escalation = synchronized(this@SiteProvisioningSource) { resumeDeferredHeal(siteLocalId) } + escalation?.let { escalateReauth(siteLocalId, it) } + } + } + + /** + * The deferred half of [healForInvalidAuth], run once the in-flight pipeline finishes. Returns the + * [SiteAuthState.Unprovisionable] to escalate for, or `null` when there's nothing to do — the + * caller performs the escalation outside the lock. + */ + private fun resumeDeferredHeal(siteLocalId: Int): SiteAuthState.Unprovisionable? = when { + // Superseded: a newer run is underway, or the run we waited on itself re-minted. Validation's + // own 401 goes through a notifier-wired client, so the heal that fixes a revoked password also + // schedules this deferral — re-running would re-validate the fresh credential for nothing. + jobs[siteLocalId]?.isActive == true || + lastEvidence[siteLocalId] == HealEvidence.Replaced -> null + + // Budget re-checked here, not just at registration time: that run may have spent it. A + // terminal mint failure escalates instead of relaunching — a routine run's tail skipped that + // because it never armed reauthOnFailure. + !hasHealBudget(siteLocalId) -> + (states[siteLocalId]?.value as? SiteReadiness.NeedsAuth)?.auth as? SiteAuthState.Unprovisionable + + else -> { + reauthOnFailure.add(siteLocalId) + ready.remove(siteLocalId) + launchPipeline(siteLocalId) + null } } @@ -265,9 +300,10 @@ class SiteProvisioningSource @Inject constructor( * [wasHeal] is the consumed [reauthOnFailure] flag: only a 401-triggered run may prompt, so a * routine run that happens to settle [SiteAuthState.Unprovisionable] leaves the prompt to the * application-password card. A heal that settled without changing the credentials is recorded in - * [healFutile] — the 401 came from something re-provisioning can't fix. + * [healBudgetSpent] — the 401 came from something re-provisioning can't fix. */ private fun settleHealState(siteLocalId: Int, result: PipelineResult, wasHeal: Boolean) { + lastEvidence[siteLocalId] = result.evidence val auth = (result.readiness as? SiteReadiness.NeedsAuth)?.auth if (auth is SiteAuthState.Unprovisionable) { if (wasHeal) escalateReauth(siteLocalId, auth) @@ -275,15 +311,24 @@ class SiteProvisioningSource @Inject constructor( } // Not stuck on auth any more, so a future revocation should be able to prompt again. escalated.remove(siteLocalId) - // The heal confirmed the stored password works and replaced nothing, yet a 401 still prompted - // it — so re-provisioning cannot be the fix. Stop honouring this site's 401s until an explicit - // retry. Inconclusive runs (site unreachable, offline, no password applies) prove nothing. - if (wasHeal && result.evidence == HealEvidence.ConfirmedUnchanged) { - appLogWrapper.w( - AppLog.T.MAIN, - "A_P: Heal for $siteLocalId changed no credentials - suppressing further 401 heals" - ) - healFutile.add(siteLocalId) + when { + wasHeal -> { + // A heal that only re-confirmed the stored password can never be the fix, so it + // exhausts the budget outright; one that re-minted might have worked, so it costs one. + val cost = if (result.evidence == HealEvidence.ConfirmedUnchanged) { + MAX_CONSECUTIVE_HEALS + } else { + 1 + } + val spent = healBudgetSpent.merge(siteLocalId, cost, Int::plus) + appLogWrapper.w( + AppLog.T.MAIN, + "A_P: Heal for $siteLocalId spent $cost (${spent}/$MAX_CONSECUTIVE_HEALS)" + ) + } + // A run that reached Ready without needing a heal means whatever was failing has cleared, + // so a later revocation gets a full budget again. + result.readiness == SiteReadiness.Ready -> healBudgetSpent.remove(siteLocalId) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt index 5385d61efdff..8eb250457352 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordLoginHelper.kt @@ -88,6 +88,12 @@ class ApplicationPasswordLoginHelper @Inject constructor( * has to be publicly reachable for the login flow to read its API. */ PrivateSite, + + /** + * Discovery succeeded but the site advertises no application-passwords endpoint, so it + * genuinely can't be logged into this way. + */ + NotSupported, } } @@ -112,6 +118,15 @@ class ApplicationPasswordLoginHelper @Inject constructor( } else { val authorizationUrl = discoverSuccessWrapper.getApplicationPasswordsAuthenticationUrl(urlDiscoveryResult) + if (authorizationUrl == null) { + // Discovery worked; the site just doesn't offer application passwords. This + // is the one case the old blanket "not supported" message was right about. + return@withContext handleAuthenticationDiscoveryError( + siteUrl, + "No application-passwords authentication URL advertised", + DiscoveryResult.FailureReason.NotSupported, + ) + } val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(urlDiscoveryResult) if (apiRootUrl.isNotEmpty()) { // Store the ApiRootUrl for use it after the login @@ -489,12 +504,9 @@ class ApplicationPasswordLoginHelper @Inject constructor( WPUrlUtils.isWordPressCom(authentication.endpoints.authorizationUrl) } + /** `null` when the site advertises no application-passwords endpoint. */ fun getApplicationPasswordsAuthenticationUrl( successObject: ApiDiscoveryResult.Success - ): String = requireNotNull( - applicationPasswordsUrl(successObject.success.authentication)?.url() - ) { - "Application passwords authentication URL is required" - } + ): String? = applicationPasswordsUrl(successObject.success.authentication)?.url() } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt index 038b8137c984..985bb7b2f8ed 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModel.kt @@ -62,6 +62,8 @@ class LoginSiteApplicationPasswordViewModel @Inject constructor( when (result.reason) { ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.PrivateSite -> resourceProvider.getString(R.string.application_password_private_site_error) + ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.NotSupported -> + resourceProvider.getString(R.string.application_password_not_supported_error) ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.Unknown -> result.userFacingMessage } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt index 8b9cf129af24..48f8932034ee 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt @@ -93,8 +93,11 @@ class JetpackConnectionHelper @Inject constructor( } } + // takeIf: wpApiRestUrl can be an empty string as well as null (the column is cleared, not + // dropped), and ParsedUrl.parse("") throws. Mirrors WpApiClientProvider.buildUrl, which the + // install client used before it got its own notifier-free client. private fun resolveRestApiUrl(site: SiteModel) = - site.wpApiRestUrl ?: "${site.url}/wp-json" + site.wpApiRestUrl?.takeIf { it.isNotEmpty() } ?: "${site.url}/wp-json" private inner class InvalidAuthNotifier : WpAppNotifier { override suspend fun requestedWithInvalidAuthentication(requestUrl: String) { diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 13186bfe47b1..23e040f7cf69 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -7,7 +7,9 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.atMost import org.mockito.kotlin.eq +import org.mockito.kotlin.mockingDetails import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -82,6 +84,23 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { private suspend fun stubValidate(outcome: ApplicationPasswordValidator.Outcome) = whenever(applicationPasswordValidator.validate(any())).thenReturn(outcome) + /** + * The real validator talks through `getApplicationPasswordClient`, whose notifier reports a 401 + * back to the source — so a revoked credential fires onRequestedWithInvalidAuthentication during + * the very heal that is fixing it. Plain `thenReturn` stubs hide that feedback entirely. + */ + private suspend fun stubValidateFiringNotifierOn401(vararg outcomes: ApplicationPasswordValidator.Outcome) { + var call = 0 + whenever(applicationPasswordValidator.validate(any())).thenAnswer { + val outcome = outcomes[minOf(call, outcomes.lastIndex)] + call++ + if (outcome == ApplicationPasswordValidator.Outcome.Invalid) { + source.onRequestedWithInvalidAuthentication(site) + } + outcome + } + } + private suspend fun stubMintSuccess() = whenever(siteStore.createApplicationPassword(any())).thenReturn( OnApplicationPasswordCreated(site, ApplicationPasswordCredentials("user", "pass", uuid = "u")) @@ -503,6 +522,87 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(siteStore, never()).createApplicationPassword(any()) } + @Test + fun `given validation's own 401 during a successful heal, then no redundant re-heal and healing stays enabled`() = + test { + // The heal's validate sees the revoked credential (Invalid) and its notifier fires mid-run; + // the mint then succeeds. The deferred heal must notice the run already re-minted and stand + // down, or it re-validates the fresh password, records "changed nothing", and permanently + // disables healing for the site (#22944 review c2). + val selfHosted = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://test.example.com" + wpApiRestUrl = "https://test.example.com/wp-json" + xmlRpcUrl = "https://test.example.com/xmlrpc.php" + } + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(selfHosted) + stubHasStoredCredentials(true) + stubValidateFiringNotifierOn401(ApplicationPasswordValidator.Outcome.Invalid) + stubMintSuccess() + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site) + source.await(site) + testScheduler.advanceUntilIdle() + + // Exactly one heal: the deferred one stood down because the run had already re-minted. + verify(siteStore, times(1)).createApplicationPassword(any()) + verify(applicationPasswordReauthNotifier, never()).notifyReauthRequired(any()) + } + + @Test + fun `given a mint that never sticks, then heals stop after the budget is spent`() = test { + // A host that mints happily but never accepts the result would otherwise re-mint on every + // 401 forever, accumulating application passwords on the user's account (#22944 review c1). + stubHasStoredCredentials(true) + stubValidateFiringNotifierOn401(ApplicationPasswordValidator.Outcome.Invalid) + stubMintSuccess() + stubCapabilityProbe(ok = true) + + source.onRequestedWithInvalidAuthentication(site) + source.await(site) + repeat(5) { + source.onRequestedWithInvalidAuthentication(site) + testScheduler.advanceUntilIdle() + } + + // Bounded by MAX_CONSECUTIVE_HEALS rather than growing with the number of 401s. + verify(siteStore, atMost(2)).createApplicationPassword(any()) + } + + @Test + fun `given an explicit retry while a run is active, then the heal budget is still cleared`() = test { + // MySiteViewModel.refresh builds the application-password card first, which starts a run + // synchronously on Main.immediate — so invalidate lands mid-run for exactly the sites that + // need clearing. The clears must sit above the in-flight guard (#22944 review c4). + // + // The site must settle Unreachable, not Ready: Ready latches (so stateFor wouldn't start a + // run at all) and also resets the budget on its own, either of which hides the bug. + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = false, cached = false) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + source.onRequestedWithInvalidAuthentication(site) // heal #1: ConfirmedUnchanged -> budget spent + source.await(site) + assertThat(source.await(site)).isEqualTo(SiteReadiness.Unreachable) // never latches + source.stateFor(site) // a run is now in flight, as after buildCard + source.invalidate(site) // pull-to-refresh lands mid-run + testScheduler.advanceUntilIdle() + + val validationsBeforeRetry = mockingDetails(applicationPasswordValidator).invocations + .count { it.method.name == "validate" } + + source.onRequestedWithInvalidAuthentication(site) // must be honoured again + testScheduler.advanceUntilIdle() + + val validationsAfterRetry = mockingDetails(applicationPasswordValidator).invocations + .count { it.method.name == "validate" } + assertThat(validationsAfterRetry) + .describedAs("the 401 after an explicit retry should have started a fresh heal") + .isGreaterThan(validationsBeforeRetry) + } + @Test fun `given a heal changed no credentials, then later 401s are ignored`() = test { stubHasStoredCredentials(true) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt index d91b163c257b..92af0c7290eb 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/accounts/login/applicationpassword/LoginSiteApplicationPasswordViewModelTest.kt @@ -147,4 +147,27 @@ class LoginSiteApplicationPasswordViewModelTest : BaseUnitTest() { assertEquals(privateSiteMessage, viewModel.errorMessage.value) assertEquals(false, viewModel.loadingStateFlow.value) } + + @Test + fun `Given a site without application passwords, then the not-supported message is shown`() = test { + // Discovery succeeded but advertised no application-passwords endpoint. Removing the + // fragment's blanket overwrite took this message's only producer with it, leaving the raw + // untranslated exception text on screen (#22944 review c3). + val siteUrl = "https://example.com" + val notSupportedMessage = "The provided site does not support Application Password authentication." + whenever(resourceProvider.getString(R.string.application_password_not_supported_error)) + .thenReturn(notSupportedMessage) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(siteUrl)) + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Failed( + userFacingMessage = "No application-passwords authentication URL advertised", + reason = ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.NotSupported, + ) + ) + + viewModel.runApiDiscovery(siteUrl) + advanceUntilIdle() + + assertEquals(notSupportedMessage, viewModel.errorMessage.value) + } } From bc62e4ddb53f1fca72dc0b285d74737d740469bc Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 11:47:50 -0400 Subject: [PATCH 24/32] Cover the application-password card click handlers Three tests asserting each card emits the right SiteNavigationAction when tapped: the create and re-authentication cards open auto-authentication, the XML-RPC disabled card opens the bottom sheet. The slice's rendering was covered already; its click wiring was not. --- .../ApplicationPasswordViewModelSliceTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index ade0ffd01648..e245bf658e98 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -25,6 +25,7 @@ import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper import org.wordpress.android.ui.mysite.MySiteCardAndItem +import org.wordpress.android.ui.mysite.SiteNavigationAction private const val TEST_URL = "https://www.test.com" private const val TEST_SITE_ID = 1 @@ -40,6 +41,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { private lateinit var siteTest: SiteModel private var card: MySiteCardAndItem? = null + private var navigation: SiteNavigationAction? = null private lateinit var slice: ApplicationPasswordViewModelSlice @Before @@ -56,7 +58,9 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { url = TEST_URL } card = null + navigation = null slice.uiModel.observeForever { card = it } + slice.onNavigation.observeForever { navigation = it.peekContent() } } private fun stubReadiness(readiness: SiteReadiness): MutableStateFlow { @@ -152,4 +156,42 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { assertNull(card) } + + @Test + fun `given the create card is showing, when tapped, then auto-authentication is launched`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) + stubAuthorized() + slice.buildCard(siteTest) + + (card as MySiteCardAndItem.Card.QuickLinksItem).quickLinkItems.first().onClick.click() + + assertThat(navigation) + .isEqualTo(SiteNavigationAction.OpenApplicationPasswordAutoAuthentication(siteTest, TEST_AUTH_URL)) + } + + @Test + fun `given the reauthentication card is showing, when tapped, then auto-authentication is launched`() = test { + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + stubAuthorized() + slice.buildCard(siteTest) + + (card as MySiteCardAndItem.Item.SingleActionCard).onActionClick() + + assertThat(navigation) + .isEqualTo(SiteNavigationAction.OpenApplicationPasswordAutoAuthentication(siteTest, TEST_AUTH_URL)) + } + + @Test + fun `given the XML-RPC disabled card is showing, when tapped, then the bottom sheet opens`() = test { + stubReadiness(SiteReadiness.Ready) + whenever(siteStore.getSiteByLocalId(TEST_SITE_ID)).thenReturn( + SiteModel().apply { id = TEST_SITE_ID; url = TEST_URL } + ) + whenever(siteProvisioningSource.isXmlRpcUnavailable(TEST_SITE_ID)).thenReturn(true) + slice.buildCard(siteTest) + + (card as MySiteCardAndItem.Item.SingleActionCard).onActionClick() + + assertThat(navigation).isEqualTo(SiteNavigationAction.OpenXmlRpcDisabledBottomSheet) + } } From c08e876d20b79455e4cb4d2d2bff8ea02f3babda Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 12:12:03 -0400 Subject: [PATCH 25/32] Retire the bearer-token framing after trunk stopped it notifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trunk c45e08ea162 made the WP.com bearer client a no-op notifier, on the same reasoning the heal containment was written for: application-password reauthentication can't fix a bearer-token 401. That removes one of the two self-feeding 401 sources, so the comments no longer cite it. The containment still applies: getApplicationPasswordClient — the client validation uses — and the self-hosted capability probe both still report to the handler, which is what makes a heal schedule a redundant re-run of itself. --- .../android/repositories/SiteProvisioningSource.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 52da583df1f9..85c65b7f84ca 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -99,8 +99,9 @@ class SiteProvisioningSource @Inject constructor( // licenses the application-password card to show the XML-RPC-disabled warning. private val xmlRpcUnavailable = ConcurrentHashMap.newKeySet() - // Heal budget spent per site. The pipeline's own requests can 401 (validation and the capability - // probe both go through notifier-wired clients), so an unbounded heal feeds itself. A heal that + // Heal budget spent per site. The pipeline's own requests can 401 — validation always goes through + // a notifier-wired client, as does the capability probe on self-hosted sites — so an unbounded + // heal feeds itself. A heal that // confirmed the stored password and replaced nothing can never be the fix — the 401 came from // something re-provisioning can't touch — so it spends the whole budget at once; one that // re-minted might have worked, so it only counts against it, which bounds a host that mints @@ -196,7 +197,7 @@ class SiteProvisioningSource @Inject constructor( /** * [WpAppNotifierHandler.NotifierListener] — wordpress-rs rejected a request for [site] with invalid - * authentication (a revoked application password, or an expired WP.com bearer token). Re-provision it + * authentication — a revoked application password; bearer clients no longer report here. Re-provision it * so ensureAuth re-validates and heals: for a WP.com-connected site the headless re-mint succeeds and * recovery is silent; for one that can't be re-minted the run settles Unprovisionable and * [settleHealState] escalates to interactive re-auth. From 991a1de171400b96c3d3ac557f7fd055b48f0bf5 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 13:13:51 -0400 Subject: [PATCH 26/32] Tell the user their site is private instead of showing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoke a private site's application passwords and pull to refresh, and the app said nothing at all. The pipeline was right — validate, wipe, mint, settle Unprovisionable — but building the re-authentication card needs an authorization URL, discovery can't get one through the Privacy gate, and the Failed branch posted null. The user is left with broken credentials and no explanation. The two discovery-failure branches (create card and re-auth banner) were identical, so they share one handler. It names the one cause this branch can recognise; everything else still hides pending #22884. Its own string rather than the login screen's: there the user is signing in, here they are already signed in and the site can't be reconnected. --- .../ApplicationPasswordViewModelSlice.kt | 47 +++++++++++++------ WordPress/src/main/res/values/strings.xml | 1 + .../ApplicationPasswordViewModelSliceTest.kt | 31 ++++++++++++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index 6121bf8f5bd4..c119731e5beb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -129,14 +129,7 @@ class ApplicationPasswordViewModelSlice @Inject constructor( "A_P: Hiding reauthentication card for ${site.url} - WordPress.com site" ) } - is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { - // TODO follow-up: surface result.userFacingMessage in the card (issue #22884). - uiModelMutable.postValue(null) - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: Hiding reauthentication card for ${site.url} - bad discovery: ${result.userFacingMessage}" - ) - } + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> handleFailedDiscovery(site, result) } } @@ -149,15 +142,39 @@ class ApplicationPasswordViewModelSlice @Inject constructor( uiModelMutable.postValue(null) appLogWrapper.d(AppLog.T.MAIN, "A_P: Hiding card for ${site.url} - WordPress.com site") } - is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> { - // TODO follow-up: surface result.userFacingMessage in the card (issue #22884). - uiModelMutable.postValue(null) - appLogWrapper.d( - AppLog.T.MAIN, - "A_P: Hiding card for ${site.url} - bad discovery: ${result.userFacingMessage}" + is ApplicationPasswordLoginHelper.DiscoveryResult.Failed -> handleFailedDiscovery(site, result) + } + } + + /** + * Discovery couldn't produce an authorization URL, so neither the create nor the re-authenticate + * card can be built. A private site is the one cause we can name: its Privacy gate answers the + * anonymous discovery request with a 403, and without saying so the user sees nothing at all + * while their credentials stay broken. Every other cause still hides the card pending #22884. + */ + private fun handleFailedDiscovery( + site: SiteModel, + result: ApplicationPasswordLoginHelper.DiscoveryResult.Failed, + ) { + if (result.reason == ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.PrivateSite) { + uiModelMutable.postValue( + MySiteCardAndItem.Item.SingleActionCard( + textResource = R.string.application_password_private_site_card, + imageResource = R.drawable.ic_notice_white_24dp, + onActionClick = { }, + centerImageVertically = true, + showLearnMore = false, ) - } + ) + appLogWrapper.d(AppLog.T.MAIN, "A_P: Showing private-site card for ${site.url}") + return } + // TODO follow-up: surface result.userFacingMessage in the card (issue #22884). + uiModelMutable.postValue(null) + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Hiding card for ${site.url} - bad discovery: ${result.userFacingMessage}" + ) } private fun showApplicationPasswordCreateCard(site: SiteModel, alternativeUrl: String) { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index cd8fc9fcf5b0..e80047d4d379 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4591,6 +4591,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Authenticate using Application Password The provided site does not support Application Password authentication. This site is private, so we can\'t read its settings to sign you in. + This site is private, so we can\'t reconnect to it. Invalid Application Password Your application password no longer exists. Please sign in again to create a new application password. Application Password Required diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index e245bf658e98..0463cbd140de 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -157,6 +157,37 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { assertNull(card) } + @Test + fun `given a private site with revoked credentials, then the private-site card explains it`() = test { + // Discovery can't produce an authorization URL through the Privacy gate, so the re-auth card + // can't be built. Hiding it leaves the user with broken credentials and no explanation at all. + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) + .thenReturn( + ApplicationPasswordLoginHelper.DiscoveryResult.Failed( + userFacingMessage = "Found a site but failed to read its API configuration.", + reason = ApplicationPasswordLoginHelper.DiscoveryResult.FailureReason.PrivateSite, + ) + ) + + slice.buildCard(siteTest) + + val privateCard = card as MySiteCardAndItem.Item.SingleActionCard + assertThat(privateCard.textResource).isEqualTo(R.string.application_password_private_site_card) + } + + @Test + fun `given discovery fails for any other reason, then the card stays hidden`() = test { + // Only a private site is named; everything else still hides pending #22884. + stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) + whenever(applicationPasswordLoginHelper.getAuthorizationUrlComplete(eq(TEST_URL))) + .thenReturn(ApplicationPasswordLoginHelper.DiscoveryResult.Failed("connection reset")) + + slice.buildCard(siteTest) + + assertNull(card) + } + @Test fun `given the create card is showing, when tapped, then auto-authentication is launched`() = test { stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) From 9193828532691a0e23b97b29d0727f39893e8b36 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 13:22:32 -0400 Subject: [PATCH 27/32] Centre the private-site card and drop its invisible icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message sat off-centre and wrapped onto two lines because the layout constrains the text to start after the icon, and the card was passing ic_notice_white_24dp — a white icon on a white card. Nothing was drawn, but the space was still reserved. SingleActionCard gains a centerText flag: the image is hidden and the text spans the whole card. Reclaiming that space also fits the message on one line. The other four call sites take the default and are unaffected. Both branches of each layout decision are written out because view holders are recycled — a centred card must not leave the next one centred. bind() split into bindText / bindImage to stay under detekt's LongMethod limit. --- .../android/ui/mysite/MySiteCardAndItem.kt | 8 ++- .../ApplicationPasswordViewModelSlice.kt | 3 +- .../SingleActionCardViewHolder.kt | 67 +++++++++++-------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItem.kt index 93ef3128cabf..5df28c804cf4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItem.kt @@ -372,12 +372,18 @@ sealed class MySiteCardAndItem(open val type: Type) { ) : MySiteCardAndItem(type) { data class InfoItem(val title: UiString) : Item(INFO_ITEM) + /** + * [centerText] presents the card as a plain notice: the image is hidden and the text is + * centred across the whole card rather than sitting in the column beside the icon. Use it + * when the card only explains something and has nothing to tap. + */ data class SingleActionCard( @StringRes val textResource: Int, @DrawableRes val imageResource: Int, val onActionClick: () -> Unit, val centerImageVertically: Boolean = false, - val showLearnMore: Boolean = true + val showLearnMore: Boolean = true, + val centerText: Boolean = false ) : Item(SINGLE_ACTION_CARD) data class CategoryHeaderItem(val title: UiString) : Item(CATEGORY_HEADER_ITEM) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt index c119731e5beb..2634ab5a8223 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt @@ -160,10 +160,11 @@ class ApplicationPasswordViewModelSlice @Inject constructor( uiModelMutable.postValue( MySiteCardAndItem.Item.SingleActionCard( textResource = R.string.application_password_private_site_card, + // Hidden by centerText — the card is a notice with nothing to tap. imageResource = R.drawable.ic_notice_white_24dp, onActionClick = { }, - centerImageVertically = true, showLearnMore = false, + centerText = true, ) ) appLogWrapper.d(AppLog.T.MAIN, "A_P: Showing private-site card for ${site.url}") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/singleactioncard/SingleActionCardViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/singleactioncard/SingleActionCardViewHolder.kt index 2bb9d1f45250..77a27a2c6718 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/singleactioncard/SingleActionCardViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/singleactioncard/SingleActionCardViewHolder.kt @@ -1,9 +1,11 @@ package org.wordpress.android.ui.mysite.items.singleactioncard +import android.view.Gravity import android.view.View import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat +import org.wordpress.android.R import org.wordpress.android.databinding.MySiteSingleActionCardItemBinding import org.wordpress.android.ui.mysite.MySiteCardAndItem.Item.SingleActionCard import org.wordpress.android.ui.mysite.MySiteCardAndItemViewHolder @@ -16,40 +18,48 @@ class SingleActionCardViewHolder( ) { fun bind(singleActionCard: SingleActionCard) = with(binding) { val context = root.context - singleActionCardText.text = - context.getString(singleActionCard.textResource) + singleActionCardText.text = context.getString(singleActionCard.textResource) singleActionCardImage.setImageDrawable( ContextCompat.getDrawable(context, singleActionCard.imageResource) ) - singleActionCardCover.setOnClickListener { - singleActionCard.onActionClick() - } - learnMore.visibility = if (singleActionCard.showLearnMore) { - View.VISIBLE + singleActionCardCover.setOnClickListener { singleActionCard.onActionClick() } + learnMore.visibility = if (singleActionCard.showLearnMore) View.VISIBLE else View.GONE + bindText(singleActionCard) + bindImage(singleActionCard) + } + + /** + * Every branch here is written both ways round: view holders are recycled, so a card bound with + * one presentation must not leave the next card wearing it. + */ + private fun MySiteSingleActionCardItemBinding.bindText(singleActionCard: SingleActionCard) { + val marginExtraLarge = root.context.resources.getDimensionPixelSize(R.dimen.margin_extra_large) + val params = singleActionCardText.layoutParams as ConstraintLayout.LayoutParams + if (singleActionCard.showLearnMore) { + params.bottomToBottom = ConstraintLayout.LayoutParams.UNSET + params.bottomMargin = 0 } else { - View.GONE + params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + params.bottomMargin = marginExtraLarge } - val textParams = singleActionCardText.layoutParams - as ConstraintLayout.LayoutParams - if (!singleActionCard.showLearnMore) { - textParams.bottomToBottom = - ConstraintLayout.LayoutParams.PARENT_ID - textParams.bottomMargin = context.resources - .getDimensionPixelSize( - org.wordpress.android.R.dimen.margin_extra_large - ) + // Centred cards span the whole card rather than the column beside the icon, which + // bindImage hides. + if (singleActionCard.centerText) { + singleActionCardText.gravity = Gravity.CENTER_HORIZONTAL + params.startToEnd = ConstraintLayout.LayoutParams.UNSET + params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID } else { - textParams.bottomToBottom = - ConstraintLayout.LayoutParams.UNSET - textParams.bottomMargin = 0 + singleActionCardText.gravity = Gravity.START + params.startToEnd = singleActionCardImage.id + params.startToStart = ConstraintLayout.LayoutParams.UNSET } - singleActionCardText.layoutParams = textParams - val marginExtraLarge = context.resources - .getDimensionPixelSize( - org.wordpress.android.R.dimen.margin_extra_large - ) - val params = singleActionCardImage.layoutParams - as ConstraintLayout.LayoutParams + singleActionCardText.layoutParams = params + } + + private fun MySiteSingleActionCardItemBinding.bindImage(singleActionCard: SingleActionCard) { + singleActionCardImage.visibility = if (singleActionCard.centerText) View.GONE else View.VISIBLE + val marginExtraLarge = root.context.resources.getDimensionPixelSize(R.dimen.margin_extra_large) + val params = singleActionCardImage.layoutParams as ConstraintLayout.LayoutParams if (singleActionCard.centerImageVertically) { params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID @@ -57,8 +67,7 @@ class SingleActionCardViewHolder( params.bottomMargin = 0 } else { params.topToTop = ConstraintLayout.LayoutParams.UNSET - params.bottomToBottom = - ConstraintLayout.LayoutParams.UNSET + params.bottomToBottom = ConstraintLayout.LayoutParams.UNSET params.topMargin = marginExtraLarge params.bottomMargin = marginExtraLarge } From 971230a1dc9a88014b4b634c23eb8832ad171dd0 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 14:28:26 -0400 Subject: [PATCH 28/32] Drop card-click tests that cover unchanged behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three tests asserting each card emits the right SiteNavigationAction covered wiring this PR never touches: neither OpenApplicationPasswordAutoAuthentication nor OpenXmlRpcDisabledBottomSheet appears as a changed line in the slice's diff, and they replaced nothing — the pre-PR test file had no click assertions at all. Good tests, wrong PR. This one is already flagged oversized, so they can land separately against the code they actually cover. Audited the rest and kept it: every other added test either belongs to a new class, replaces one removed in the same rewrite, or covers behaviour this PR changed. --- .../ApplicationPasswordViewModelSliceTest.kt | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index 0463cbd140de..6547e50641a8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -25,7 +25,6 @@ import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper import org.wordpress.android.ui.mysite.MySiteCardAndItem -import org.wordpress.android.ui.mysite.SiteNavigationAction private const val TEST_URL = "https://www.test.com" private const val TEST_SITE_ID = 1 @@ -41,7 +40,6 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { private lateinit var siteTest: SiteModel private var card: MySiteCardAndItem? = null - private var navigation: SiteNavigationAction? = null private lateinit var slice: ApplicationPasswordViewModelSlice @Before @@ -58,9 +56,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { url = TEST_URL } card = null - navigation = null slice.uiModel.observeForever { card = it } - slice.onNavigation.observeForever { navigation = it.peekContent() } } private fun stubReadiness(readiness: SiteReadiness): MutableStateFlow { @@ -188,41 +184,4 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { assertNull(card) } - @Test - fun `given the create card is showing, when tapped, then auto-authentication is launched`() = test { - stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = false))) - stubAuthorized() - slice.buildCard(siteTest) - - (card as MySiteCardAndItem.Card.QuickLinksItem).quickLinkItems.first().onClick.click() - - assertThat(navigation) - .isEqualTo(SiteNavigationAction.OpenApplicationPasswordAutoAuthentication(siteTest, TEST_AUTH_URL)) - } - - @Test - fun `given the reauthentication card is showing, when tapped, then auto-authentication is launched`() = test { - stubReadiness(SiteReadiness.NeedsAuth(SiteAuthState.Unprovisionable(hadCredentials = true))) - stubAuthorized() - slice.buildCard(siteTest) - - (card as MySiteCardAndItem.Item.SingleActionCard).onActionClick() - - assertThat(navigation) - .isEqualTo(SiteNavigationAction.OpenApplicationPasswordAutoAuthentication(siteTest, TEST_AUTH_URL)) - } - - @Test - fun `given the XML-RPC disabled card is showing, when tapped, then the bottom sheet opens`() = test { - stubReadiness(SiteReadiness.Ready) - whenever(siteStore.getSiteByLocalId(TEST_SITE_ID)).thenReturn( - SiteModel().apply { id = TEST_SITE_ID; url = TEST_URL } - ) - whenever(siteProvisioningSource.isXmlRpcUnavailable(TEST_SITE_ID)).thenReturn(true) - slice.buildCard(siteTest) - - (card as MySiteCardAndItem.Item.SingleActionCard).onActionClick() - - assertThat(navigation).isEqualTo(SiteNavigationAction.OpenXmlRpcDisabledBottomSheet) - } } From 5a684128745d90d97448e6602f7f0cfa263b9e4e Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 15:04:40 -0400 Subject: [PATCH 29/32] Run XML-RPC discovery on IO, and stop losing backgrounded re-auth prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings. SiteXmlRpcUrlRecoverer ran verifyOrDiscoverXMLRPCEndpoint — a blocking chain of HTTP calls that can take tens of seconds against a dead host — on BG_THREAD, which is Dispatchers.Default. That is the same CPU-sized pool APPLICATION_SCOPE runs every site's pipeline on, so a few slow self-hosted sites could park most of it. The code this replaced used IO_THREAD; restore that. escalateReauth marked the once-per-episode flag before knowing whether anyone took the prompt. Activities register their listener in onResume, so a heal that settles while the app is backgrounded notified an empty map and lost the prompt for good: later 401s are blocked by the Unprovisionable state, and routine runs settle with wasHeal = false. notifyReauthRequired now reports whether a live listener took it, and an undelivered prompt is held and retried by the next run — in practice the onResume run, once an activity has registered. The pipeline's catch-all mapped any throw to Unreachable without the offline check that ensureAuth and detectCapabilities both make, stacking the site banner on the global no-connection one. It now makes the same distinction. The first attempt at the second fix only stopped burning the flag, which left nothing to retry — the test caught it. --- .../repositories/SiteProvisioningSource.kt | 50 ++++++++++++++++--- .../ApplicationPasswordReauthNotifier.kt | 13 +++-- .../accounts/login/SiteXmlRpcUrlRecoverer.kt | 12 +++-- .../SiteProvisioningSourceTest.kt | 49 ++++++++++++++++++ 4 files changed, 110 insertions(+), 14 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 85c65b7f84ca..274909947af6 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -119,6 +119,11 @@ class SiteProvisioningSource @Inject constructor( // anything else clears it, so a later revocation escalates again. private val escalated = ConcurrentHashMap.newKeySet() + // Sites that earned an escalation while nothing was listening — the heal settled with the app + // backgrounded. Retried by the next run that settles Unprovisionable, which in practice is the + // onResume run, by which time an activity has registered its listener. + private val escalationPending = ConcurrentHashMap.newKeySet() + init { // Re-provision when wordpress-rs reports a request was rejected for invalid auth (the app // password was revoked / rotated server-side). Without this, a site latched Ready keeps its @@ -186,6 +191,7 @@ class SiteProvisioningSource @Inject constructor( healBudgetSpent.clear() lastEvidence.clear() escalated.clear() + escalationPending.clear() } /** @@ -307,11 +313,14 @@ class SiteProvisioningSource @Inject constructor( lastEvidence[siteLocalId] = result.evidence val auth = (result.readiness as? SiteReadiness.NeedsAuth)?.auth if (auth is SiteAuthState.Unprovisionable) { - if (wasHeal) escalateReauth(siteLocalId, auth) + // A routine run normally leaves the prompt to the card, but it must still deliver an + // escalation that an earlier heal earned and couldn't hand to anyone. + if (wasHeal || siteLocalId in escalationPending) escalateReauth(siteLocalId, auth) return } // Not stuck on auth any more, so a future revocation should be able to prompt again. escalated.remove(siteLocalId) + escalationPending.remove(siteLocalId) when { wasHeal -> { // A heal that only re-confirmed the stored password can never be the fix, so it @@ -333,12 +342,28 @@ class SiteProvisioningSource @Inject constructor( } } - /** Prompts for interactive re-auth at most once per [SiteAuthState.Unprovisionable] episode. */ + /** + * Prompts for interactive re-auth at most once per [SiteAuthState.Unprovisionable] episode. + * + * The episode is only recorded once a live listener has taken the prompt. Activities register + * theirs in onResume and drop it in onPause, so a heal that settles while the app is backgrounded + * would otherwise burn the one prompt on an empty listener map — and nothing would ask again, + * because further 401s are blocked by the Unprovisionable state. An undelivered prompt is + * held in [escalationPending] and retried by the next run instead. + */ private fun escalateReauth(siteLocalId: Int, auth: SiteAuthState.Unprovisionable) { - if (!auth.hadCredentials) return - if (!escalated.add(siteLocalId)) return - siteStore.getSiteByLocalId(siteLocalId)?.let { - applicationPasswordReauthNotifier.notifyReauthRequired(it.url) + val alreadyHandled = !auth.hadCredentials || siteLocalId in escalated + val site = if (alreadyHandled) null else siteStore.getSiteByLocalId(siteLocalId) + if (site == null) return + if (applicationPasswordReauthNotifier.notifyReauthRequired(site.url)) { + escalated.add(siteLocalId) + escalationPending.remove(siteLocalId) + } else { + escalationPending.add(siteLocalId) + appLogWrapper.d( + AppLog.T.MAIN, + "A_P: Nobody listening for re-auth on ${site.url} - will retry on the next run" + ) } } @@ -360,7 +385,10 @@ class SiteProvisioningSource @Inject constructor( AppLog.T.MAIN, "Provisioning pipeline failed for $siteLocalId: ${e::class.simpleName}: ${e.message}" ) - PipelineResult(SiteReadiness.Unreachable, latch = false) + // Same offline distinction detectCapabilities makes: with no network this isn't the + // site's fault, and the connectivity banner would just stack on the global + // no-connection one. + PipelineResult(unreachableOrTransient(), latch = false) } flow.value = result.readiness // Latch the dedup gate only on a freshly live-probed Ready; a Ready served from stale cache @@ -383,6 +411,14 @@ class SiteProvisioningSource @Inject constructor( } } + /** [SiteReadiness.Unreachable] only when the device has a network; otherwise it's just offline. */ + private fun unreachableOrTransient(): SiteReadiness = + if (networkUtilsWrapper.isNetworkAvailable()) { + SiteReadiness.Unreachable + } else { + SiteReadiness.TransientError + } + private fun flowFor(siteLocalId: Int): MutableStateFlow = states.getOrPut(siteLocalId) { MutableStateFlow(SiteReadiness.Probing) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt index 3aad4c1739d6..cdfb44da25ab 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/ApplicationPasswordReauthNotifier.kt @@ -18,11 +18,18 @@ import javax.inject.Singleton class ApplicationPasswordReauthNotifier @Inject constructor() { private val listeners = mutableMapOf>() - /** Asks any listening UI to navigate to interactive re-auth for [siteUrl]. */ + /** + * Asks any listening UI to navigate to interactive re-auth for [siteUrl], returning whether a + * live listener actually took it. Listeners are registered per activity onResume/onPause, so a + * heal that settles while the app is backgrounded has nobody to tell — the caller needs to know + * that so it doesn't record the prompt as delivered. + */ @Synchronized - fun notifyReauthRequired(siteUrl: String) { + fun notifyReauthRequired(siteUrl: String): Boolean { cleanupDeadReferences() - listeners.values.forEach { it.get()?.onReauthRequired(siteUrl) } + val live = listeners.values.mapNotNull { it.get() } + live.forEach { it.onReauthRequired(siteUrl) } + return live.isNotEmpty() } @Synchronized diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt index 6cf038a15625..ac313a541cf5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/login/SiteXmlRpcUrlRecoverer.kt @@ -7,7 +7,7 @@ import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient import org.wordpress.android.fluxc.persistence.SiteSqlUtils import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.util.AppLog import javax.inject.Inject import javax.inject.Named @@ -34,10 +34,14 @@ class SiteXmlRpcUrlRecoverer @Inject constructor( private val siteXMLRPCClient: SiteXMLRPCClient, private val siteSqlUtils: SiteSqlUtils, private val appLogWrapper: AppLogWrapper, - @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, + // IO, not BG: verifyOrDiscoverXMLRPCEndpoint is a blocking chain of HTTP calls that can take + // tens of seconds against a dead host, and BG_THREAD is Dispatchers.Default — the same + // CPU-sized pool APPLICATION_SCOPE runs every site's pipeline on. This is the dispatcher the + // code in ApplicationPasswordViewModelSlice used before the move. + @param:Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, ) { @Suppress("SwallowedException", "TooGenericExceptionCaught") - suspend fun discoverAndVerifyXmlRpcUrl(site: SiteModel): XmlRpcRecovery = withContext(bgDispatcher) { + suspend fun discoverAndVerifyXmlRpcUrl(site: SiteModel): XmlRpcRecovery = withContext(ioDispatcher) { try { val endpoint = selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url) val result = siteXMLRPCClient.fetchSites( @@ -78,7 +82,7 @@ class SiteXmlRpcUrlRecoverer @Inject constructor( } } - suspend fun persistXmlRpcUrl(localId: Int, xmlRpcUrl: String): Boolean = withContext(bgDispatcher) { + suspend fun persistXmlRpcUrl(localId: Int, xmlRpcUrl: String): Boolean = withContext(ioDispatcher) { val rowsUpdated = siteSqlUtils.updateXmlRpcUrl(localId, xmlRpcUrl) if (rowsUpdated == 0) { appLogWrapper.w(AppLog.T.API, "Cannot persist xmlRpcUrl: no site with localId=$localId") diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 23e040f7cf69..629ebd20b8c3 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -7,6 +7,7 @@ import org.junit.Before import org.junit.Test import org.mockito.Mock import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast import org.mockito.kotlin.atMost import org.mockito.kotlin.eq import org.mockito.kotlin.mockingDetails @@ -106,6 +107,10 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { OnApplicationPasswordCreated(site, ApplicationPasswordCredentials("user", "pass", uuid = "u")) ) + /** A listener is registered (an activity is in the foreground), so prompts are delivered. */ + private fun stubReauthListenerPresent() = + whenever(applicationPasswordReauthNotifier.notifyReauthRequired(any())).thenReturn(true) + private suspend fun stubMintFailure() = whenever(siteStore.createApplicationPassword(any())).thenReturn( OnApplicationPasswordCreated(site, BaseNetworkError(GenericErrorType.UNKNOWN, "fail"), notSupported = false) @@ -403,6 +408,7 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `given a 401-triggered heal fails, then reauth is requested`() = test { + stubReauthListenerPresent() stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.Invalid) stubMintFailure() @@ -432,6 +438,7 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `given a 401 during an active run, the deferred heal still escalates a dead credential`() = test { + stubReauthListenerPresent() stubHasStoredCredentials(true) // The active run validates the not-yet-revoked credential (Valid -> Ready); the deferred re-heal // then sees it revoked (Invalid) and can't re-mint, so it must escalate rather than be swallowed @@ -449,6 +456,47 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { verify(applicationPasswordReauthNotifier).notifyReauthRequired(site.url) } + @Test + fun `given no listener for the re-auth prompt, then the episode is not consumed`() = test { + // Activities register their listener in onResume, so a heal settling while the app is + // backgrounded has nobody to tell. Recording it as prompted would burn the single + // per-episode escalation on an empty listener map (#22944 review). + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Invalid) + stubMintFailure() + whenever(applicationPasswordReauthNotifier.notifyReauthRequired(any())).thenReturn(false) + + source.onRequestedWithInvalidAuthentication(site) // heal settles while nothing is listening + source.await(site) + + // The app comes back to the foreground: an activity registers, and the onResume run retries + // the prompt even though it is a routine run. + stubReauthListenerPresent() + source.invalidate(site) + testScheduler.advanceUntilIdle() + + verify(applicationPasswordReauthNotifier, atLeast(2)).notifyReauthRequired(site.url) + } + + @Test + fun `given the pipeline throws while offline, then transient rather than unreachable`() = test { + // The connectivity banner would otherwise stack on the global no-connection banner. + stubHasStoredCredentials(true) + whenever(applicationPasswordValidator.validate(any())).thenThrow(RuntimeException("boom")) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.TransientError) + } + + @Test + fun `given the pipeline throws while online, then unreachable`() = test { + stubHasStoredCredentials(true) + whenever(applicationPasswordValidator.validate(any())).thenThrow(RuntimeException("boom")) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Unreachable) + } + @Test fun `given a 401 on a WPCom Simple site, then it is ignored`() = test { val simple = SiteModel().apply { @@ -672,6 +720,7 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { // A routine run never arms the heal flag, so its own tail skips escalation. The deferred handler // used to return "already escalated" on that outcome and swallow the 401 entirely, leaving no // interactive re-auth and dropping every later 401 via the Unprovisionable guard (#22944 c3). + stubReauthListenerPresent() stubHasStoredCredentials(true) stubValidate(ApplicationPasswordValidator.Outcome.Invalid) stubMintFailure() From a7659ee1ba0a3191cafb68e6ba23db40e20efe83 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Tue, 1 Sep 2026 15:16:02 -0400 Subject: [PATCH 30/32] Remove the blank line the dropped tests left before the class brace checkstyle's RegexpMultilineCheck runs over Kotlin too, and deleting the last test in the file left its trailing blank line sitting before the closing brace. --- .../applicationpassword/ApplicationPasswordViewModelSliceTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt index 6547e50641a8..522760372190 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSliceTest.kt @@ -183,5 +183,4 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() { assertNull(card) } - } From 0f39bb10ecd61f0925b3547b45e69fc7703aef4b Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 2 Sep 2026 07:02:13 -0400 Subject: [PATCH 31/32] Hide the site banner while the device is offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on the PR: with "Unable to connect to your site" showing, turning the device offline leaves it stacked on the global "no connection" bar. Losing the network doesn't re-run the pipeline, so the readiness the banner collects stays Unreachable and nothing re-evaluates. The old code had the same hole — its suppressForOffline was computed once inside fetchCapabilities — but this PR added a second route into Unreachable (site unreachable at the auth stage), so the state is easier to be sitting in when the network drops. The banner now combines readiness with connectivity through a MediatorLiveData. ConnectionStatusLiveData only emits on transitions and swallows its initial value, so it serves as a change trigger and the decision reads isNetworkAvailable() for the live answer. Starting offline already behaved correctly — ensureAuth settles Provisioning, not Unreachable. This is only about losing the network while the banner is up. --- .../SiteConnectivityBannerViewModelSlice.kt | 31 +++++++++++-- ...iteConnectivityBannerViewModelSliceTest.kt | 46 ++++++++++++++++++- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt index 5da0eacb131b..cb2a07ba82bd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSlice.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.mysite.cards.connectivity import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -10,18 +11,33 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.mysite.MySiteCardAndItem +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.viewmodel.helpers.ConnectionStatus import javax.inject.Inject class SiteConnectivityBannerViewModelSlice @Inject constructor( private val siteProvisioningSource: SiteProvisioningSource, + private val networkUtilsWrapper: NetworkUtilsWrapper, + connectionStatus: LiveData, ) { private lateinit var scope: CoroutineScope private var collectJob: Job? = null private var currentSite: SiteModel? = null - private val _uiModel = MutableLiveData() + private val readiness = MutableLiveData() + + private val _uiModel = MediatorLiveData() val uiModel: LiveData = _uiModel + init { + _uiModel.addSource(readiness) { render() } + // Losing the network doesn't re-run the pipeline, so the readiness we hold stays Unreachable + // and the banner would sit there stacked on the global "no connection" bar. This source is a + // change trigger only — it emits on transitions and swallows its initial value — so the + // decision reads the live availability rather than the emitted status. + _uiModel.addSource(connectionStatus) { render() } + } + fun initialize(scope: CoroutineScope) { this.scope = scope } @@ -40,12 +56,11 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor( currentSite = site if (isUserInitiated) siteProvisioningSource.invalidate(site) collectJob = scope.launch { - siteProvisioningSource.stateFor(site).collect { readiness -> + siteProvisioningSource.stateFor(site).collect { state -> // Bail if the user switched sites while suspended — postValue is // not a suspension point, so cancellation alone won't catch this. if (currentSite?.id != site.id) return@collect - val showBanner = readiness is SiteReadiness.Unreachable - _uiModel.postValue(if (showBanner) buildBanner() else null) + readiness.postValue(state) } } } @@ -53,7 +68,13 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor( fun clearBanner() { collectJob?.cancel() currentSite = null - _uiModel.postValue(null) + readiness.postValue(null) + } + + private fun render() { + val showBanner = readiness.value is SiteReadiness.Unreachable && + networkUtilsWrapper.isNetworkAvailable() + _uiModel.value = if (showBanner) buildBanner() else null } private fun buildBanner(): MySiteCardAndItem.Item.SingleActionCard = diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt index a1e9fa9b3d51..ee3dfd3c28e8 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/connectivity/SiteConnectivityBannerViewModelSliceTest.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.mysite.cards.connectivity +import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -20,6 +21,8 @@ import org.wordpress.android.repositories.SiteAuthState import org.wordpress.android.repositories.SiteProvisioningSource import org.wordpress.android.repositories.SiteReadiness import org.wordpress.android.ui.mysite.MySiteCardAndItem +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.viewmodel.helpers.ConnectionStatus private const val TEST_SITE_LOCAL_ID = 42 @@ -29,6 +32,11 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Mock lateinit var siteProvisioningSource: SiteProvisioningSource + @Mock + lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + private val connectionStatus = MutableLiveData() + private lateinit var siteTest: SiteModel private lateinit var slice: SiteConnectivityBannerViewModelSlice private val emittedBanners = mutableListOf() @@ -36,7 +44,12 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { @Before fun setUp() { siteTest = SiteModel().apply { id = TEST_SITE_LOCAL_ID } - slice = SiteConnectivityBannerViewModelSlice(siteProvisioningSource) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + slice = SiteConnectivityBannerViewModelSlice( + siteProvisioningSource, + networkUtilsWrapper, + connectionStatus, + ) slice.initialize(testScope()) slice.uiModel.observeForever { emittedBanners.add(it) } } @@ -62,6 +75,37 @@ class SiteConnectivityBannerViewModelSliceTest : BaseUnitTest() { assertThat(banner.showLearnMore).isFalse } + @Test + fun `given the banner is showing, when the device goes offline, then it is hidden`() = test { + // Losing the network doesn't re-run the pipeline, so readiness stays Unreachable. Without + // reacting to connectivity the banner sits stacked on the global "no connection" bar. + stubReadiness(siteTest, SiteReadiness.Unreachable) + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + assertThat(emittedBanners.last()).isNotNull + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + connectionStatus.value = ConnectionStatus.UNAVAILABLE + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNull() + } + + @Test + fun `given hidden while offline, when the network returns, then the banner comes back`() = test { + stubReadiness(siteTest, SiteReadiness.Unreachable) + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + slice.fetchCapabilities(siteTest, isUserInitiated = false) + advanceUntilIdle() + assertThat(emittedBanners.last()).isNull() + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + connectionStatus.value = ConnectionStatus.AVAILABLE + advanceUntilIdle() + + assertThat(emittedBanners.last()).isNotNull + } + @Test fun `given ready, when fetchCapabilities invoked, then banner is null`() = test { stubReadiness(siteTest, SiteReadiness.Ready) From 3fba8a09e3cdce156ac6295d0d962a627b815f8c Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Wed, 2 Sep 2026 07:36:32 -0400 Subject: [PATCH 32/32] Stop a failed column write from discarding a successful probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on the PR. The two recovery stages end in an unguarded WellSql update. Only the discovery half of XML-RPC recovery had a try/catch, so a throwing write — SQLiteException, database locked — escaped the async, cancelled the sibling through the enclosing coroutineScope, and settled the whole site Unreachable. The connectivity banner appeared and Ready never latched even though capability detection had already succeeded. The outer catch in launchPipeline anticipated a DB throw but contains it too coarsely: by then the successful result is gone. Both stages are best effort, so contain them individually and let the capability probe alone decide readiness. The report named the XML-RPC branch; persistApiRootUrl has the same hole, and worse placement — it runs before detectCapabilities in the capability branch, so a throw there kills the probe before it starts. Both are covered. --- .../repositories/SiteProvisioningSource.kt | 25 +++++++++++-- .../SiteProvisioningSourceTest.kt | 35 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt index 274909947af6..029c5b821f95 100644 --- a/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt @@ -435,10 +435,10 @@ class SiteProvisioningSource @Inject constructor( // the mint persists them via a single writer that the generic full-row update can no // longer clobber (#22947), so the re-read is now trustworthy (#22905). val capabilities = async { - recoverRestUrlIfNeeded(siteLocalId) + recoverQuietly("REST root recovery") { recoverRestUrlIfNeeded(siteLocalId) } detectCapabilities(siteLocalId) } - val xmlRpc = async { recoverXmlRpcIfNeeded(siteLocalId) } + val xmlRpc = async { recoverQuietly("XML-RPC recovery") { recoverXmlRpcIfNeeded(siteLocalId) } } xmlRpc.await() capabilities.await().copy(evidence = stage.evidence) } @@ -509,6 +509,27 @@ class SiteProvisioningSource @Inject constructor( return AuthStage.Stop(SiteAuthState.Unprovisionable(hadCredentials = hadCredentials)) } + /** + * Runs a best-effort recovery stage so its failure can't decide the site's readiness. + * + * Both stages heal a column when they can, and both end in an unguarded DB write — `WellSql` + * update calls that can throw. Left to propagate, that throw would cancel the sibling through + * the enclosing `coroutineScope` and settle the whole site `Unreachable`, discarding a + * capability probe that had already succeeded. Cancellation still propagates normally. + */ + private suspend fun recoverQuietly(label: String, recover: suspend () -> Unit) { + try { + recover() + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + appLogWrapper.e( + AppLog.T.MAIN, + "A_P: $label failed, continuing: ${e::class.simpleName}: ${e.message}" + ) + } + } + /** * Stage 2a — recover the REST API root for Atomic sites minted through the * Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null). diff --git a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt index 629ebd20b8c3..d0a1701f05ac 100644 --- a/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/repositories/SiteProvisioningSourceTest.kt @@ -246,6 +246,41 @@ class SiteProvisioningSourceTest : BaseUnitTest(StandardTestDispatcher()) { assertThat(source.isXmlRpcUnavailable(TEST_SITE_LOCAL_ID)).isFalse } + @Test + fun `given the XML-RPC column write throws, then a successful probe still settles Ready`() = test { + // Recovery is best effort and ends in an unguarded WellSql update. Left to propagate, that + // throw cancels the sibling capability probe through the enclosing coroutineScope and the + // whole site settles Unreachable — discarding a probe that already succeeded (#22944 review). + val selfHosted = selfHostedWithoutXmlRpc() + whenever(siteXmlRpcUrlRecoverer.discoverAndVerifyXmlRpcUrl(selfHosted)) + .thenReturn(XmlRpcRecovery.Recovered("https://selfhosted.example.com/xmlrpc.php")) + whenever(siteXmlRpcUrlRecoverer.persistXmlRpcUrl(any(), any())) + .thenThrow(RuntimeException("database is locked")) + + assertThat(source.await(selfHosted)).isEqualTo(SiteReadiness.Ready) + } + + @Test + fun `given the REST root write throws, then a successful probe still settles Ready`() = test { + // Same hole on the other branch, where the write runs before detectCapabilities. + val site = SiteModel().apply { + id = TEST_SITE_LOCAL_ID + url = "https://test.example.com" + xmlRpcUrl = "https://test.example.com/xmlrpc.php" // XML-RPC branch short-circuits + // no wpApiRestUrl -> the REST recovery branch runs + } + whenever(siteStore.getSiteByLocalId(TEST_SITE_LOCAL_ID)).thenReturn(site) + stubHasStoredCredentials(true) + stubValidate(ApplicationPasswordValidator.Outcome.Valid) + stubCapabilityProbe(ok = true) + whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(any())) + .thenReturn("https://test.example.com/custom-rest") + whenever(siteApiRestUrlRecoverer.persistApiRootUrl(any(), any())) + .thenThrow(RuntimeException("database is locked")) + + assertThat(source.await(site)).isEqualTo(SiteReadiness.Ready) + } + @Test fun `given XML-RPC recovery reaches a definitive negative, then the site is flagged unavailable`() = test { val selfHosted = selfHostedWithoutXmlRpc()