Skip to content

Commit 6c8fe5a

Browse files
committed
Unify editor capability detection behind a single per-site detector
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.
1 parent 421d550 commit 6c8fe5a

12 files changed

Lines changed: 689 additions & 189 deletions

File tree

WordPress/src/main/java/org/wordpress/android/AppInitializer.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import org.wordpress.android.networking.ConnectionChangeReceiver
7171
import org.wordpress.android.networking.OAuthAuthenticator
7272
import org.wordpress.android.networking.RestClientUtils
7373
import org.wordpress.android.push.GCMRegistrationScheduler
74+
import org.wordpress.android.repositories.EditorCapabilityDetector
7475
import org.wordpress.android.support.ZendeskHelper
7576
import org.wordpress.android.ui.ActivityId
7677
import org.wordpress.android.ui.debug.cookies.DebugCookieManager
@@ -229,6 +230,9 @@ class AppInitializer @Inject constructor(
229230
@Inject
230231
lateinit var wpApiClientProvider: WpApiClientProvider
231232

233+
@Inject
234+
lateinit var editorCapabilityDetector: EditorCapabilityDetector
235+
232236
@Inject
233237
lateinit var openWebLinksWithJetpackHelper: DeepLinkOpenWebLinksWithJetpackHelper
234238

@@ -717,6 +721,9 @@ class AppInitializer @Inject constructor(
717721
// Clear cached wordpress-rs services and API clients
718722
wpServiceProvider.clearAll()
719723
wpApiClientProvider.clearAllClients()
724+
725+
// Drop per-site editor-capability detection state for the signed-out user
726+
editorCapabilityDetector.clear()
720727
}
721728

722729
/*
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package org.wordpress.android.repositories
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Job
5+
import kotlinx.coroutines.flow.MutableStateFlow
6+
import kotlinx.coroutines.flow.StateFlow
7+
import kotlinx.coroutines.launch
8+
import org.wordpress.android.fluxc.model.SiteModel
9+
import org.wordpress.android.modules.APPLICATION_SCOPE
10+
import org.wordpress.android.util.NetworkUtilsWrapper
11+
import java.util.concurrent.ConcurrentHashMap
12+
import javax.inject.Inject
13+
import javax.inject.Named
14+
import javax.inject.Singleton
15+
16+
/**
17+
* Single owner of "what does this site's editor REST API support" as an
18+
* observable, per-site state.
19+
*
20+
* Capability detection ([EditorSettingsRepository.fetchEditorCapabilitiesForSite])
21+
* has two async preconditions on Atomic sites — an application password and a
22+
* recovered REST root — provisioned elsewhere on the My Site screen. Routing
23+
* every consumer (connectivity banner, editor preloader) through this detector
24+
* means one probe per site, shared and deduplicated, instead of each consumer
25+
* re-deriving the same state and racing the same preconditions.
26+
*
27+
* State is keyed by [SiteModel.id] — the local DB row id, stable across the
28+
* process lifetime — mirroring `GutenbergEditorPreloader`.
29+
*
30+
* ## Entry points
31+
* - [stateFor] — the reactive entry point. Returns a shared [StateFlow]; the
32+
* first access starts detection, later accesses reuse the cached result
33+
* (capabilities rarely change). A failed probe is retried on the next access.
34+
* - [awaitProbe] — the one-shot entry point for callers that just need the
35+
* probe to have run (and its capabilities persisted) before continuing.
36+
* - [refresh] — forces a re-probe, bypassing the once-per-site gate
37+
* (pull-to-refresh, banner retry, newly established credentials).
38+
* - [clear] — cancels all work and drops all state; wire into sign-out.
39+
*/
40+
@Singleton
41+
class EditorCapabilityDetector @Inject constructor(
42+
private val editorSettingsRepository: EditorSettingsRepository,
43+
private val networkUtilsWrapper: NetworkUtilsWrapper,
44+
@Named(APPLICATION_SCOPE) private val appScope: CoroutineScope,
45+
) {
46+
private val states =
47+
ConcurrentHashMap<Int, MutableStateFlow<EditorCapabilityDetectionState>>()
48+
private val jobs = ConcurrentHashMap<Int, Job>()
49+
50+
// Sites whose live probe succeeded this process — the dedup gate. Only a
51+
// successful fetch latches; a failed one is left to retry on the next
52+
// access, matching the connectivity banner's previous per-slice behaviour.
53+
// Reset by refresh / clear.
54+
private val probedOk = ConcurrentHashMap.newKeySet<Int>()
55+
56+
/**
57+
* The shared detection state for [site]. The first call starts detection;
58+
* later calls return the same flow without re-probing once it has
59+
* succeeded. Collect it to react to capability changes.
60+
*/
61+
@Synchronized
62+
fun stateFor(site: SiteModel): StateFlow<EditorCapabilityDetectionState> {
63+
val flow = flowFor(site.id)
64+
if (shouldProbe(site.id)) launchDetection(site)
65+
return flow
66+
}
67+
68+
/**
69+
* Ensures detection has run for [site] (so its capabilities are persisted)
70+
* and returns the settled state. Respects the once-per-site gate; call
71+
* [refresh] first to force a fresh probe.
72+
*/
73+
suspend fun awaitProbe(site: SiteModel): EditorCapabilityDetectionState {
74+
stateFor(site)
75+
jobs[site.id]?.join()
76+
return states[site.id]?.value ?: EditorCapabilityDetectionState.Pending
77+
}
78+
79+
/**
80+
* Forces a re-probe for [site], bypassing the once-per-site gate. A no-op
81+
* while a probe is already in flight — that probe's result is fresh enough.
82+
*/
83+
@Synchronized
84+
fun refresh(site: SiteModel) {
85+
if (jobs[site.id]?.isActive == true) return
86+
probedOk.remove(site.id)
87+
launchDetection(site)
88+
}
89+
90+
/** Cancels all in-flight detection and drops all cached state (sign-out). */
91+
@Synchronized
92+
fun clear() {
93+
jobs.values.forEach { it.cancel() }
94+
jobs.clear()
95+
states.clear()
96+
probedOk.clear()
97+
}
98+
99+
@Synchronized
100+
private fun launchDetection(site: SiteModel) {
101+
jobs[site.id]?.cancel()
102+
val flow = flowFor(site.id)
103+
jobs[site.id] = appScope.launch {
104+
flow.value = detect(site)
105+
}
106+
}
107+
108+
private fun flowFor(siteLocalId: Int): MutableStateFlow<EditorCapabilityDetectionState> =
109+
states.getOrPut(siteLocalId) {
110+
MutableStateFlow(EditorCapabilityDetectionState.Pending)
111+
}
112+
113+
private fun shouldProbe(siteLocalId: Int): Boolean =
114+
jobs[siteLocalId]?.isActive != true && siteLocalId !in probedOk
115+
116+
private suspend fun detect(site: SiteModel): EditorCapabilityDetectionState {
117+
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
118+
if (ok) probedOk.add(site.id)
119+
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
120+
return when {
121+
ok || hasCache -> EditorCapabilityDetectionState.Ready
122+
editorSettingsRepository.isAwaitingApplicationPassword(site) ->
123+
EditorCapabilityDetectionState.Pending
124+
!networkUtilsWrapper.isNetworkAvailable() ->
125+
EditorCapabilityDetectionState.TransientError
126+
else -> EditorCapabilityDetectionState.Unreachable
127+
}
128+
}
129+
}
130+
131+
/**
132+
* Observable lifecycle of editor-capability detection for one site — distinct
133+
* from `org.wordpress.android.ui.posts.EditorCapabilityState`, which models a
134+
* resolved settings-row capability. This is the *detection* state the
135+
* connectivity banner and editor preloader subscribe to.
136+
*/
137+
sealed interface EditorCapabilityDetectionState {
138+
/**
139+
* Not determined yet — still probing, or waiting on an application password
140+
* being minted asynchronously. Consumers hold; the banner stays hidden.
141+
*/
142+
data object Pending : EditorCapabilityDetectionState
143+
144+
/**
145+
* Capabilities are known (freshly detected, or cached from a prior run).
146+
* Read them via [EditorSettingsRepository]'s getters.
147+
*/
148+
data object Ready : EditorCapabilityDetectionState
149+
150+
/**
151+
* Credentials are present but the transport probe failed — the site looks
152+
* unreachable. The only state that surfaces the connectivity banner.
153+
*/
154+
data object Unreachable : EditorCapabilityDetectionState
155+
156+
/** A transient failure (e.g. device offline). Retried on the next probe. */
157+
data object TransientError : EditorCapabilityDetectionState
158+
}

WordPress/src/main/java/org/wordpress/android/repositories/EditorSettingsRepository.kt

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ class EditorSettingsRepository @Inject constructor(
3434
fun hasCachedCapabilities(site: SiteModel): Boolean =
3535
appPrefsWrapper.hasSiteEditorCapabilities(site)
3636

37+
/**
38+
* True when capability detection can't run yet because an Atomic site's
39+
* direct-host probe needs an application password that hasn't been
40+
* provisioned. The password is minted asynchronously on the My Site
41+
* screen (see ApplicationPasswordViewModelSlice), so a first-login fetch
42+
* can fail purely for lack of credentials — callers should treat this as
43+
* pending, not a connection failure.
44+
*/
45+
fun isAwaitingApplicationPassword(site: SiteModel): Boolean =
46+
site.isWPComAtomic && !site.hasApplicationPasswordCredentials()
47+
3748
/**
3849
* Returns whether the site is known to support the
3950
* `wp-block-editor/v1/settings` endpoint, based on
@@ -139,24 +150,66 @@ class EditorSettingsRepository @Inject constructor(
139150
* assume the API lives at `/wp-json` (custom permalink structures or
140151
* REST API paths would break that assumption), then use the routes list
141152
* returned by discovery directly — no second request needed.
153+
*
154+
* Discovery is unauthenticated, so it can't reach a *private* Atomic host
155+
* — the host gates anonymous requests and the API root never loads. When
156+
* the site has application-password credentials, fall back to an
157+
* authenticated probe against the same direct host (Basic auth), which is
158+
* exactly the transport the editor uses there. Without credentials there's
159+
* nothing to authenticate with, so we report failure. See #22883.
142160
*/
143161
private suspend fun fetchRouteSupportViaDirectHostDiscovery(
144162
site: SiteModel
145163
): Boolean {
146164
val discovery = wpLoginClient.apiDiscovery(site.url)
147-
if (discovery !is ApiDiscoveryResult.Success) {
165+
if (discovery is ApiDiscoveryResult.Success) {
166+
val resolver = wpApiClientProvider.urlResolverFor(
167+
discovery.success.apiRootUrl
168+
)
169+
persistRouteSupport(site, discovery.success.apiDetails, resolver)
170+
return true
171+
}
172+
AppLog.w(
173+
T.EDITOR,
174+
"Direct-host API discovery failed for" +
175+
" site=${site.name}: ${discovery::class.simpleName}"
176+
)
177+
return if (site.hasApplicationPasswordCredentials()) {
178+
fetchRouteSupportViaApplicationPasswordClient(site)
179+
} else {
148180
AppLog.w(
149181
T.EDITOR,
150-
"Direct-host API discovery failed for" +
151-
" site=${site.name}: ${discovery::class.simpleName}"
182+
"No application password for site=${site.name};" +
183+
" skipping authenticated direct-host probe"
152184
)
153-
return false
185+
false
186+
}
187+
}
188+
189+
/**
190+
* Authenticated direct-host route probe for Atomic sites whose host
191+
* rejects the anonymous discovery request (e.g. private sites). Uses the
192+
* site's application-password (Basic auth) client and resolves routes
193+
* against the same direct host — mirrors
194+
* [fetchRouteSupportViaConfiguredClient] but bypasses the WP.com proxy.
195+
*/
196+
private suspend fun fetchRouteSupportViaApplicationPasswordClient(
197+
site: SiteModel
198+
): Boolean {
199+
val client = wpApiClientProvider.getApplicationPasswordClient(site)
200+
val resolver = wpApiClientProvider.getDirectHostApiUrlResolver(site)
201+
val response = client.request { it.apiRoot().get() }
202+
return if (response is WpRequestResult.Success) {
203+
persistRouteSupport(site, response.response.data, resolver)
204+
true
205+
} else {
206+
AppLog.w(
207+
T.EDITOR,
208+
"Authenticated direct-host probe failed for" +
209+
" site=${site.name}: ${response::class.simpleName}"
210+
)
211+
false
154212
}
155-
val resolver = wpApiClientProvider.urlResolverFor(
156-
discovery.success.apiRootUrl
157-
)
158-
persistRouteSupport(site, discovery.success.apiDetails, resolver)
159-
return true
160213
}
161214

162215
private fun persistRouteSupport(
@@ -215,3 +268,7 @@ class EditorSettingsRepository @Inject constructor(
215268
false
216269
}
217270
}
271+
272+
private fun SiteModel.hasApplicationPasswordCredentials(): Boolean =
273+
!apiRestUsernamePlain.isNullOrEmpty() &&
274+
!apiRestPasswordPlain.isNullOrEmpty()

WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/applicationpassword/ApplicationPasswordViewModelSlice.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient
1717
import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider
1818
import org.wordpress.android.fluxc.store.SiteStore
1919
import org.wordpress.android.fluxc.utils.AppLogWrapper
20+
import org.wordpress.android.repositories.EditorCapabilityDetector
2021
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper
2122
import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer
2223
import org.wordpress.android.ui.mysite.MySiteCardAndItem
@@ -41,6 +42,7 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
4142
private val siteXMLRPCClient: SiteXMLRPCClient,
4243
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
4344
private val dispatcher: Dispatcher,
45+
private val editorCapabilityDetector: EditorCapabilityDetector,
4446
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
4547
) {
4648
lateinit var scope: CoroutineScope
@@ -112,6 +114,11 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
112114
if (!createResult.isError && createResult.credentials != null) {
113115
wpApiClientProvider.clearSelfHostedClient(storedSite.id)
114116
appLogWrapper.d(AppLog.T.MAIN, "A_P: Headless mint succeeded for ${storedSite.url}")
117+
// The first-login capability probe can lose the race to this async mint. storedSite
118+
// was just mutated in place with the new credentials (SiteStore
119+
// .persistApplicationPasswordCredentials), so re-probe against this exact instance —
120+
// no stale-SiteModel re-read, and capabilities settle without a manual pull-to-refresh.
121+
editorCapabilityDetector.refresh(storedSite)
115122
// The mint goes through the Jetpack tunnel and never runs discovery — without this
116123
// step, freshly minted Atomic sites end up with working creds but a NULL
117124
// wpApiRestUrl in the local DB. Run in the background so the card hides immediately.

0 commit comments

Comments
 (0)