Skip to content
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* [**] Resolved an issue where the editor could become impossible to exit when it failed to load.
* [*] Atomic sites can now create application passwords without leaving the app.
* [**] Fixed a case where the editor failed to load on WP.com Atomic sites whose host doesn't expose `wp-block-editor/v1/settings`.
* [*] Editor now discovers the correct REST API root for sites with non-default API URLs.
* [*] Try out the next-generation block editor on a per-site basis from Site Settings.

26.7
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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.persistence.SiteSqlUtils
import org.wordpress.android.fluxc.utils.AppLogWrapper
import org.wordpress.android.modules.BG_THREAD
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper.DiscoverSuccessWrapper
import org.wordpress.android.util.AppLog
import rs.wordpress.api.kotlin.ApiDiscoveryResult
import rs.wordpress.api.kotlin.WpLoginClient
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException

/**
* Heals [SiteModel.wpApiRestUrl] when it's missing — WP.com `/me/sites` omits the field, and
* headless application-password mint runs through the Jetpack tunnel without doing discovery.
*
* - [discoverApiRootUrl] runs REST API autodiscovery and returns the discovered root URL.
* - [persistApiRootUrl] writes only that one column to the DB row for `localId`.
*
* Callers handle the "is it missing?" check and the in-memory assignment themselves so the
* mutation stays visible at the call site.
*/
@Singleton
class SiteApiRestUrlRecoverer @Inject constructor(
private val wpLoginClient: WpLoginClient,
private val discoverSuccessWrapper: DiscoverSuccessWrapper,
private val siteSqlUtils: SiteSqlUtils,
private val appLogWrapper: AppLogWrapper,
@param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
) {
@Suppress("TooGenericExceptionCaught")
suspend fun discoverApiRootUrl(siteUrl: String): String? = withContext(bgDispatcher) {
try {
when (val result = wpLoginClient.apiDiscovery(siteUrl)) {
is ApiDiscoveryResult.Success -> {
val apiRootUrl = discoverSuccessWrapper.getApiRootUrl(result)
if (apiRootUrl.isBlank()) null else apiRootUrl
}
else -> {
appLogWrapper.w(AppLog.T.API, "API discovery failed for $siteUrl")
null
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
appLogWrapper.e(
AppLog.T.API,
"API discovery threw for $siteUrl: ${e::class.simpleName}: ${e.message}"
)
null
}
}

suspend fun persistApiRootUrl(localId: Int, apiRootUrl: String): Boolean = withContext(bgDispatcher) {
val rowsUpdated = siteSqlUtils.updateWpApiRestUrl(localId, apiRootUrl)
if (rowsUpdated == 0) {
appLogWrapper.w(AppLog.T.API, "Cannot persist wpApiRestUrl: no site with localId=$localId")
false
} else {
appLogWrapper.d(AppLog.T.API, "Persisted wpApiRestUrl=$apiRootUrl for localId=$localId")
true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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.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
Expand All @@ -38,6 +39,7 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
private val applicationPasswordValidator: ApplicationPasswordValidator,
private val selfHostedEndpointFinder: SelfHostedEndpointFinder,
private val siteXMLRPCClient: SiteXMLRPCClient,
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
private val dispatcher: Dispatcher,
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
) {
Expand Down Expand Up @@ -81,6 +83,8 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
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
}
Expand Down Expand Up @@ -108,6 +112,10 @@ 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}")
// 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
}
Expand All @@ -127,6 +135,14 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
}
}

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 fun handleValidAuth(site: SiteModel) {
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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.ui.accounts.login.SiteApiRestUrlRecoverer
import org.wordpress.android.util.AppLog
import org.wordpress.gutenberg.model.EditorDependencies
import java.util.concurrent.ConcurrentHashMap
Expand Down Expand Up @@ -63,6 +64,7 @@ class GutenbergEditorPreloader @Inject constructor(
private val siteSettingsProvider: SiteSettingsProvider,
private val editorServiceProvider: EditorServiceProvider,
private val editorSettingsRepository: EditorSettingsRepository,
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher
) {
private sealed class PreloadState {
Expand Down Expand Up @@ -93,6 +95,10 @@ 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 }
}
editorSettingsRepository
.fetchEditorCapabilitiesForSite(site)
// Preloading produces EditorDependencies, which the editor
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package org.wordpress.android.ui.accounts.login

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.any
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever
import org.wordpress.android.BaseUnitTest
import org.wordpress.android.fluxc.persistence.SiteSqlUtils
import org.wordpress.android.fluxc.utils.AppLogWrapper
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper.DiscoverSuccessWrapper
import rs.wordpress.api.kotlin.ApiDiscoveryResult
import rs.wordpress.api.kotlin.WpLoginClient
import uniffi.wp_api.AutoDiscoveryAttemptSuccess
import uniffi.wp_api.DiscoveredAuthenticationMechanism
import uniffi.wp_api.ParseUrlException
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.assertFailsWith

private const val SITE_URL = "https://example.test"
private const val DISCOVERED_API_ROOT = "https://example.test/custom-api/"
private const val LOCAL_ID = 1

@ExperimentalCoroutinesApi
class SiteApiRestUrlRecovererTest : BaseUnitTest() {
@Mock lateinit var wpLoginClient: WpLoginClient
@Mock lateinit var discoverSuccessWrapper: DiscoverSuccessWrapper
@Mock lateinit var siteSqlUtils: SiteSqlUtils
@Mock lateinit var appLogWrapper: AppLogWrapper

private lateinit var recoverer: SiteApiRestUrlRecoverer

@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
recoverer = SiteApiRestUrlRecoverer(
wpLoginClient = wpLoginClient,
discoverSuccessWrapper = discoverSuccessWrapper,
siteSqlUtils = siteSqlUtils,
appLogWrapper = appLogWrapper,
bgDispatcher = testDispatcher(),
)
}

private suspend fun stubDiscoverySuccess(apiRootUrl: String) {
val result = ApiDiscoveryResult.Success(
AutoDiscoveryAttemptSuccess(
mock(), mock(), mock(),
DiscoveredAuthenticationMechanism.ApplicationPasswords(mock())
)
)
whenever(wpLoginClient.apiDiscovery(any())).thenReturn(result)
whenever(discoverSuccessWrapper.getApiRootUrl(eq(result)))
.thenReturn(apiRootUrl)
}

@Test
fun `discoverApiRootUrl returns the discovered URL on success`() = runTest {
stubDiscoverySuccess(DISCOVERED_API_ROOT)

val result = recoverer.discoverApiRootUrl(SITE_URL)

assertThat(result).isEqualTo(DISCOVERED_API_ROOT)
}

@Test
fun `discoverApiRootUrl returns null when the discovered URL is blank`() = runTest {
stubDiscoverySuccess(apiRootUrl = "")

val result = recoverer.discoverApiRootUrl(SITE_URL)

assertThat(result).isNull()
}

@Test
fun `discoverApiRootUrl returns null when discovery returns a failure`() = runTest {
whenever(wpLoginClient.apiDiscovery(any())).thenReturn(
ApiDiscoveryResult.FailureParseSiteUrl(ParseUrlException.Generic(""))
)

val result = recoverer.discoverApiRootUrl(SITE_URL)

assertThat(result).isNull()
}

@Test
fun `discoverApiRootUrl swallows non-cancellation exceptions and returns null`() = runTest {
whenever(wpLoginClient.apiDiscovery(any()))
.doThrow(RuntimeException("network error"))

val result = recoverer.discoverApiRootUrl(SITE_URL)

assertThat(result).isNull()
}

@Test
fun `discoverApiRootUrl rethrows CancellationException to preserve structured concurrency`() = runTest {
whenever(wpLoginClient.apiDiscovery(any()))
.doThrow(CancellationException("cancelled"))

assertFailsWith<CancellationException> {
recoverer.discoverApiRootUrl(SITE_URL)
}
}

@Test
fun `persistApiRootUrl returns true and writes the column when a row matches`() = runTest {
whenever(siteSqlUtils.updateWpApiRestUrl(LOCAL_ID, DISCOVERED_API_ROOT)).thenReturn(1)

val updated = recoverer.persistApiRootUrl(LOCAL_ID, DISCOVERED_API_ROOT)

assertThat(updated).isTrue()
}

@Test
fun `persistApiRootUrl returns false when no row matches the local id`() = runTest {
whenever(siteSqlUtils.updateWpApiRestUrl(LOCAL_ID, DISCOVERED_API_ROOT)).thenReturn(0)

val updated = recoverer.persistApiRootUrl(LOCAL_ID, DISCOVERED_API_ROOT)

assertThat(updated).isFalse()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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.MySiteCardAndItem
import kotlin.test.assertNotNull

Expand Down Expand Up @@ -66,6 +67,9 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
@Mock
lateinit var siteXMLRPCClient: SiteXMLRPCClient

@Mock
lateinit var siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer

@Mock
lateinit var dispatcher: Dispatcher

Expand All @@ -87,6 +91,7 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
applicationPasswordValidator,
selfHostedEndpointFinder,
siteXMLRPCClient,
siteApiRestUrlRecoverer,
dispatcher,
testDispatcher()
).apply {
Expand Down Expand Up @@ -171,6 +176,51 @@ class ApplicationPasswordViewModelSliceTest : BaseUnitTest() {
verify(applicationPasswordLoginHelper, never()).getAuthorizationUrlComplete(any())
}

@Test
fun `given headless mint succeeds, card hides without waiting for the recoverer`() = runTest {
stubMintSuccess()
val recoverGate = CompletableDeferred<Unit>()
whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(any()))
.doSuspendableAnswer { recoverGate.await(); null }

applicationPasswordViewModelSlice.buildCard(siteTest)

// Card has been hidden even though the recoverer is still suspended on the gate.
assertNull(applicationPasswordCard)
verify(siteApiRestUrlRecoverer).discoverApiRootUrl(siteTest.url)

// Release the recoverer so the test scope doesn't carry a dangling coroutine.
recoverGate.complete(Unit)
}

@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<Unit>()
whenever(siteApiRestUrlRecoverer.discoverApiRootUrl(any()))
.doSuspendableAnswer { recoverGate.await(); null }

applicationPasswordViewModelSlice.buildCard(siteTest)

assertNull(applicationPasswordCard)
verify(siteApiRestUrlRecoverer).discoverApiRootUrl(TEST_URL)

recoverGate.complete(Unit)
}

@Test
fun `given headless mint returns NotSupported, then fall back to discovery`() = runTest {
stubMintFailure(notSupported = true)
Expand Down
Loading
Loading