@@ -2,6 +2,8 @@ package org.wordpress.android.repositories
22
33import kotlinx.coroutines.CoroutineScope
44import kotlinx.coroutines.Job
5+ import kotlinx.coroutines.async
6+ import kotlinx.coroutines.coroutineScope
57import kotlinx.coroutines.flow.MutableStateFlow
68import kotlinx.coroutines.flow.StateFlow
79import kotlinx.coroutines.launch
@@ -12,6 +14,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper
1214import org.wordpress.android.modules.APPLICATION_SCOPE
1315import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper
1416import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer
17+ import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer
1518import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator
1619import org.wordpress.android.util.AppLog
1720import org.wordpress.android.util.NetworkUtilsWrapper
@@ -22,28 +25,35 @@ import javax.inject.Singleton
2225
2326/* *
2427 * The single source of truth for getting a site ready to use: it provisions
25- * application-password credentials, recovers the REST API root, and detects
26- * editor capabilities — **in that order, each stage awaited before the next** .
28+ * application-password credentials, recovers the REST API root, recovers the
29+ * XML-RPC endpoint (self-hosted), and detects editor capabilities .
2730 *
28- * Those three things have a genuine ordering dependency (you can't probe the
29- * REST API of a private Atomic host until you've minted a credential for it),
30- * and they used to be spread across the connectivity banner, the editor
31- * preloader, and the application-password card, each triggering its slice of
32- * the work independently and racing the others. Running them as one serialized
33- * per-site pipeline makes the race structurally impossible: [detectCapabilities]
34- * is downstream of [ensureAuth], so the probe can never run before the mint.
31+ * Capability detection can't run until a credential exists (you can't probe a
32+ * private Atomic host's REST API unauthenticated), so **auth is awaited first**;
33+ * after that, the REST-capability branch and the XML-RPC branch are independent
34+ * and run **in parallel**. Routing every consumer (connectivity banner, editor
35+ * preloader, application-password card) through this one pipeline means the
36+ * first-login race is structurally impossible — the probe is downstream of the
37+ * mint — and there's one shared, deduplicated run per site instead of each
38+ * consumer racing the others.
39+ *
40+ * ### No model is held across stages
41+ * Stages take a **`siteLocalId`**, read the `SiteModel` fresh from the store at
42+ * the point of use, and write back **only the one column they changed**
43+ * (`persistApiRootUrl` / `persistXmlRpcUrl`). Nothing keeps a mutated `SiteModel`
44+ * around, so the two parallel branches can't clobber each other and there's no
45+ * stale-model write (see #22905). The passed `SiteModel` is used for its id only.
3546 *
3647 * Per site there is at most one in-flight pipeline (single-flight, keyed by
37- * [SiteModel.id]); concurrent callers join it rather than starting a second —
38- * which also subsumes the application-password card's old single-flight guard
39- * (two concurrent mints hit a 409 that destroys the winner's credentials).
48+ * [SiteModel.id]); concurrent callers join it — which also subsumes the
49+ * application-password card's old single-flight guard (two concurrent mints hit
50+ * a 409 that destroys the winner's credentials).
4051 *
4152 * ## Entry points
42- * - [stateFor] — reactive: returns a shared [StateFlow]; the first access runs
43- * the pipeline, later accesses reuse a [SiteReadiness.Ready] result.
53+ * - [stateFor] — reactive: returns a shared [StateFlow]; first access runs the
54+ * pipeline, later accesses reuse a [SiteReadiness.Ready] result.
4455 * - [await] — one-shot: runs the pipeline (if needed) and returns the result.
45- * - [invalidate] — forces a re-run, bypassing the once-per-site gate
46- * (pull-to-refresh, retry).
56+ * - [invalidate] — forces a re-run (pull-to-refresh, retry).
4757 * - [clear] — cancels all work and drops all state; wire into sign-out.
4858 */
4959@Singleton
@@ -54,6 +64,7 @@ class SiteProvisioningSource @Inject constructor(
5464 private val applicationPasswordValidator : ApplicationPasswordValidator ,
5565 private val wpApiClientProvider : WpApiClientProvider ,
5666 private val siteApiRestUrlRecoverer : SiteApiRestUrlRecoverer ,
67+ private val siteXmlRpcUrlRecoverer : SiteXmlRpcUrlRecoverer ,
5768 private val editorSettingsRepository : EditorSettingsRepository ,
5869 private val networkUtilsWrapper : NetworkUtilsWrapper ,
5970 private val appLogWrapper : AppLogWrapper ,
@@ -64,18 +75,18 @@ class SiteProvisioningSource @Inject constructor(
6475
6576 // Sites whose pipeline reached Ready this process — the dedup gate. Only a fully-ready site
6677 // latches; auth-needed / unreachable / transient outcomes are left to re-run on the next
67- // access (so a later resume re-mints or re-probes) . Reset by invalidate / clear.
78+ // access. Reset by invalidate / clear.
6879 private val ready = ConcurrentHashMap .newKeySet<Int >()
6980
7081 /* *
7182 * The shared readiness state for [site]. The first call starts the pipeline;
7283 * later calls return the same flow without re-running once it reached
73- * [SiteReadiness.Ready]. Collect it to react to provisioning / capability changes .
84+ * [SiteReadiness.Ready]. Only [SiteModel.id] is read from [site] .
7485 */
7586 @Synchronized
7687 fun stateFor (site : SiteModel ): StateFlow <SiteReadiness > {
7788 val flow = flowFor(site.id)
78- if (shouldRun(site.id)) launchPipeline(site)
89+ if (shouldRun(site.id)) launchPipeline(site.id )
7990 return flow
8091 }
8192
@@ -98,7 +109,7 @@ class SiteProvisioningSource @Inject constructor(
98109 fun invalidate (site : SiteModel ) {
99110 if (jobs[site.id]?.isActive == true ) return
100111 ready.remove(site.id)
101- launchPipeline(site)
112+ launchPipeline(site.id )
102113 }
103114
104115 /* * Cancels all in-flight pipelines and drops all cached state (sign-out). */
@@ -111,13 +122,13 @@ class SiteProvisioningSource @Inject constructor(
111122 }
112123
113124 @Synchronized
114- private fun launchPipeline (site : SiteModel ) {
115- jobs[site.id ]?.cancel()
116- val flow = flowFor(site.id )
117- jobs[site.id ] = appScope.launch {
118- val readiness = runPipeline(site )
125+ private fun launchPipeline (siteLocalId : Int ) {
126+ jobs[siteLocalId ]?.cancel()
127+ val flow = flowFor(siteLocalId )
128+ jobs[siteLocalId ] = appScope.launch {
129+ val readiness = runPipeline(siteLocalId )
119130 flow.value = readiness
120- if (readiness is SiteReadiness .Ready ) ready.add(site.id )
131+ if (readiness is SiteReadiness .Ready ) ready.add(siteLocalId )
121132 }
122133 }
123134
@@ -127,39 +138,40 @@ class SiteProvisioningSource @Inject constructor(
127138 private fun shouldRun (siteLocalId : Int ): Boolean =
128139 jobs[siteLocalId]?.isActive != true && siteLocalId !in ready
129140
130- private suspend fun runPipeline (site : SiteModel ): SiteReadiness {
131- // Re-read from the store so we provision against the persisted SiteModel and mutate that
132- // instance in place — later stages (URL recovery, capability probe) see the fresh creds.
133- val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ? : site
134- return when (val auth = ensureAuth(storedSite)) {
135- SiteAuthState .Provisioned -> {
136- recoverRestUrl(storedSite)
137- detectCapabilities(storedSite)
141+ private suspend fun runPipeline (siteLocalId : Int ): SiteReadiness =
142+ when (val auth = ensureAuth(siteLocalId)) {
143+ SiteAuthState .Provisioned -> coroutineScope {
144+ // Post-auth, the REST-capability chain and the XML-RPC recovery are independent —
145+ // each reads the site fresh and writes only its own column — so run them in
146+ // parallel. recoverRestUrl precedes detectCapabilities within its branch because
147+ // the probe needs the recovered REST root.
148+ val capabilities = async { recoverRestUrl(siteLocalId); detectCapabilities(siteLocalId) }
149+ val xmlRpc = async { recoverXmlRpc(siteLocalId) }
150+ xmlRpc.await()
151+ capabilities.await()
138152 }
139153 else -> SiteReadiness .NeedsAuth (auth)
140154 }
141- }
142155
143156 /* *
144157 * Stage 1 — ensure the site has working application-password credentials.
145158 * Validates stored creds with Basic auth against the direct host; on a
146159 * confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
147- * tunnel (the only path that works for Atomic / Jetpack-WPCom-REST sites) .
160+ * tunnel. The mint persists the credentials, so later stages read them back .
148161 */
149- private suspend fun ensureAuth (site : SiteModel ): SiteAuthState {
162+ private suspend fun ensureAuth (siteLocalId : Int ): SiteAuthState {
163+ val site = siteStore.getSiteByLocalId(siteLocalId)
164+ ? : return SiteAuthState .Unprovisionable (hadCredentials = false )
150165 val hadCredentials = ! applicationPasswordLoginHelper.siteHasBadCredentials(site)
151166 if (hadCredentials) {
152167 when (applicationPasswordValidator.validate(site)) {
153168 ApplicationPasswordValidator .Outcome .Valid ->
154169 return SiteAuthState .Provisioned
155170 ApplicationPasswordValidator .Outcome .NetworkUnavailable -> {
156- // Don't punish flaky networks — treat as in-progress and retry next run.
157171 appLogWrapper.d(AppLog .T .MAIN , " A_P: Validation network error for ${site.url} " )
158172 return SiteAuthState .Provisioning
159173 }
160174 ApplicationPasswordValidator .Outcome .Invalid -> {
161- // Stored creds are stale (revoked, deleted) — clear them so the mint below
162- // creates fresh ones, and invalidate the cached client.
163175 appLogWrapper.d(AppLog .T .MAIN , " A_P: Stored creds invalid for ${site.url} , clearing" )
164176 siteStore.deleteStoredApplicationPasswordCredentials(site)
165177 wpApiClientProvider.clearSelfHostedClient(site.id)
@@ -180,15 +192,29 @@ class SiteProvisioningSource @Inject constructor(
180192 }
181193
182194 /* *
183- * Stage 2 — recover the REST API root for Atomic sites minted through the
184- * Jetpack tunnel, which never runs discovery and so leaves `wpApiRestUrl`
185- * null. One owner for the heal that the card and preloader used to duplicate .
195+ * Stage 2a — recover the REST API root for Atomic sites minted through the
196+ * Jetpack tunnel ( which never runs discovery and leaves `wpApiRestUrl` null).
197+ * Persists the one column; the capability probe re-reads it .
186198 */
187- private suspend fun recoverRestUrl (site : SiteModel ) {
199+ private suspend fun recoverRestUrl (siteLocalId : Int ) {
200+ val site = siteStore.getSiteByLocalId(siteLocalId) ? : return
188201 if (! site.wpApiRestUrl.isNullOrEmpty()) return
189202 siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl ->
190- site.wpApiRestUrl = apiRootUrl
191- siteApiRestUrlRecoverer.persistApiRootUrl(site.id, apiRootUrl)
203+ siteApiRestUrlRecoverer.persistApiRootUrl(siteLocalId, apiRootUrl)
204+ }
205+ }
206+
207+ /* *
208+ * Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted
209+ * sites that don't have one. Discovers + authenticates against it, and on
210+ * success persists the one column; the application-password card re-reads it.
211+ */
212+ private suspend fun recoverXmlRpc (siteLocalId : Int ) {
213+ val site = siteStore.getSiteByLocalId(siteLocalId) ? : return
214+ // WP.com / Atomic / Jetpack-WPCom-REST sites talk REST end-to-end and don't use XML-RPC.
215+ if (site.isUsingWpComRestApi || ! site.xmlRpcUrl.isNullOrEmpty()) return
216+ siteXmlRpcUrlRecoverer.discoverAndVerifyXmlRpcUrl(site)?.let { endpoint ->
217+ siteXmlRpcUrlRecoverer.persistXmlRpcUrl(siteLocalId, endpoint)
192218 }
193219 }
194220
@@ -198,7 +224,8 @@ class SiteProvisioningSource @Inject constructor(
198224 * guaranteed present: a failure here is a real transport problem, not a
199225 * pending mint.
200226 */
201- private suspend fun detectCapabilities (site : SiteModel ): SiteReadiness {
227+ private suspend fun detectCapabilities (siteLocalId : Int ): SiteReadiness {
228+ val site = siteStore.getSiteByLocalId(siteLocalId) ? : return SiteReadiness .Unreachable
202229 val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
203230 val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
204231 return when {
0 commit comments