Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
41081e8
Merge trunk into jkmassel/editorcapabilitystate-visibility
nbradbury Aug 31, 2026
ce8edf5
Fix a 401 relaunch loop, a lost connectivity banner, and a swallowed …
nbradbury Aug 31, 2026
4bde97c
Model the auth stage and heal evidence as their own types
nbradbury Aug 31, 2026
9fc8358
Extract the 401 heal predicate to satisfy detekt's ReturnCount
nbradbury Aug 31, 2026
8d14e32
Move the release note to the active section
nbradbury Aug 31, 2026
6989221
Tell the user their site is private instead of "not supported"
nbradbury Aug 31, 2026
bb96371
Bound the 401 heal by budget, not by evidence type
nbradbury Sep 1, 2026
bc62e4d
Cover the application-password card click handlers
nbradbury Sep 1, 2026
18e348e
Merge remote-tracking branch 'origin/trunk' into jkmassel/editorcapab…
nbradbury Sep 1, 2026
c08e876
Retire the bearer-token framing after trunk stopped it notifying
nbradbury Sep 1, 2026
991a1de
Tell the user their site is private instead of showing nothing
nbradbury Sep 1, 2026
9193828
Centre the private-site card and drop its invisible icon
nbradbury Sep 1, 2026
971230a
Drop card-click tests that cover unchanged behaviour
nbradbury Sep 1, 2026
5a68412
Run XML-RPC discovery on IO, and stop losing backgrounded re-auth pro…
nbradbury Sep 1, 2026
a7659ee
Remove the blank line the dropped tests left before the class brace
nbradbury Sep 1, 2026
0f39bb1
Hide the site banner while the device is offline
nbradbury Sep 2, 2026
3fba8a0
Stop a failed column write from discarding a successful probe
nbradbury Sep 2, 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 @@ -14,11 +14,11 @@
* [**] 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
-----


26.8
-----
* [**] Resolved an issue where the editor could become impossible to exit when it failed to load.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import org.wordpress.android.networking.NetworkConnectionMonitor
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 @@ -228,6 +229,9 @@ class AppInitializer @Inject constructor(
@Inject
lateinit var wpApiClientProvider: WpApiClientProvider

@Inject
lateinit var siteProvisioningSource: SiteProvisioningSource

@Inject
lateinit var openWebLinksWithJetpackHelper: DeepLinkOpenWebLinksWithJetpackHelper

Expand Down Expand Up @@ -721,6 +725,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 @@ -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
Expand All @@ -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,
Expand All @@ -48,19 +55,46 @@ 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

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,

/**
* Discovery succeeded but the site advertises no application-passwords endpoint, so it
* genuinely can't be logged into this way.
*/
NotSupported,
}
}

@Suppress("TooGenericExceptionCaught")
Expand All @@ -84,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
Expand All @@ -105,15 +148,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 {
Expand Down Expand Up @@ -167,7 +234,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 Expand Up @@ -438,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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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], 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): Boolean {
cleanupDeadReferences()
val live = listeners.values.mapNotNull { it.get() }
live.forEach { it.onReauthRequired(siteUrl) }
return live.isNotEmpty()
}

@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.

Loading
Loading