Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
19a9d76
Unify editor capability detection behind a single per-site detector
jkmassel Jun 4, 2026
91059e1
Add release note for editor capability detection rework
jkmassel Jun 4, 2026
2907e6f
Remove CredentialsChangedNotifier event bus, superseded by the detector
jkmassel Jun 4, 2026
2473ae9
Fold provisioning + detection into one single-flight SiteProvisioning…
jkmassel Jun 4, 2026
e688348
Read fresh / write targeted per stage; run XML-RPC recovery in parallel
jkmassel Jun 4, 2026
4861cb8
Satisfy detekt and checkstyle
jkmassel Jun 4, 2026
c46b58e
Rename recover stages to recoverRestUrlIfNeeded / recoverXmlRpcIfNeeded
jkmassel Jun 4, 2026
86d8d7c
Don't gate capability detection behind a mint for WP.com Simple sites
jkmassel Jun 4, 2026
166309b
Probe the direct host for Jetpack capability detection, not the proxy
jkmassel Jun 4, 2026
bab2b77
Carry minted credentials to the capability probe as a value
jkmassel Jun 5, 2026
9b38f59
Contain unexpected throws in SiteXmlRpcUrlRecoverer
jkmassel Jun 13, 2026
3f20dc4
Drop credential forwarding now that app-password columns are single-w…
jkmassel Jun 13, 2026
830043e
Contain unexpected throws in the SiteProvisioningSource pipeline
jkmassel Jun 14, 2026
ba1394a
Heal revoked app passwords in the pipeline; reauth only on failure
jkmassel Jun 15, 2026
13fa432
Use getSiteByLocalId in the preloader; drop dead isAwaitingApplicatio…
jkmassel Jun 15, 2026
7447c5c
Harden SiteProvisioningSource and key the 401 heal to the exact site
jkmassel Jun 15, 2026
913c720
Give the Jetpack install step its own client so its 401 stays local
jkmassel Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.SiteProvisioningSource
import org.wordpress.android.support.ZendeskHelper
import org.wordpress.android.ui.ActivityId
import org.wordpress.android.ui.debug.cookies.DebugCookieManager
Expand Down Expand Up @@ -229,6 +230,9 @@ class AppInitializer @Inject constructor(
@Inject
lateinit var wpApiClientProvider: WpApiClientProvider

@Inject
lateinit var siteProvisioningSource: SiteProvisioningSource

@Inject
lateinit var openWebLinksWithJetpackHelper: DeepLinkOpenWebLinksWithJetpackHelper

Expand Down Expand Up @@ -717,6 +721,9 @@ class AppInitializer @Inject constructor(
// Clear cached wordpress-rs services and API clients
wpServiceProvider.clearAll()
wpApiClientProvider.clearAllClients()

// Drop per-site provisioning + capability state for the signed-out user
siteProvisioningSource.clear()
}

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,11 +97,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)
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, WeakReference<Listener>>()

/** 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)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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
import kotlin.coroutines.cancellation.CancellationException

/**
* 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 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
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", "TooGenericExceptionCaught")
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: 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
}
}

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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginStatus> {
suspend fun installJetpack(site: SiteModel, onInvalidAuth: () -> Unit): Result<PluginStatus> {
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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -95,7 +92,6 @@ class JetpackRestConnectionViewModel @Inject constructor(
_buttonType.value = ButtonType.Retry
}

wpAppNotifierHandler.removeListener(this)
_currentStep.value = null
}

Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading