Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8bd6033
Bump version number
wpmobilebot May 28, 2026
9fa1534
Update draft release notes for 26.8
wpmobilebot May 28, 2026
6ab689e
Update draft release notes for Jetpack 26.8.
wpmobilebot May 28, 2026
594be87
Release Notes: add new section for next version (26.9)
wpmobilebot May 28, 2026
75ab7eb
Use wordpress-rs 0.4.0
crazytonyli May 28, 2026
7a62d02
Merge strings from libraries for translation
wpmobilebot May 28, 2026
f7a491a
Freeze strings for translation
wpmobilebot May 28, 2026
03386ba
Keep JNA/UniFFI classes so wordpress-rs survives R8 in release builds…
crazytonyli May 29, 2026
1f8a113
Add temporary release notes
crazytonyli May 29, 2026
44cbf48
Merge release_notes/26.8 into release/26.8 (#22917)
wpmobilebot May 29, 2026
337275c
Update translations
wpmobilebot May 29, 2026
5dd426c
Update translations
wpmobilebot May 29, 2026
e67f712
Update WordPress metadata translations for 26.8
wpmobilebot May 29, 2026
cb3eecd
Update Jetpack metadata translations for 26.8
wpmobilebot May 29, 2026
987f0e3
Bump version number
wpmobilebot May 29, 2026
56758c6
Merge release_notes/26.8 into release/26.8 (#22922)
wpmobilebot May 30, 2026
e8c7ae5
Update WordPress metadata translations for 26.8
wpmobilebot May 30, 2026
2f943bc
Update Jetpack metadata translations for 26.8
wpmobilebot May 30, 2026
eb84b1a
Bump version number
wpmobilebot May 30, 2026
2775848
Bump version number
wpmobilebot Jun 1, 2026
616d914
Fix Czech confirm_remove_site translation breaking release lint
jkmassel Jun 1, 2026
7d7e1d1
Fix Czech delete-confirmation translations breaking release lint
jkmassel Jun 1, 2026
8c993a6
Bump version number
wpmobilebot Jun 1, 2026
ddea2c4
Merge release_notes/26.8 into release/26.8 (#22937)
wpmobilebot Jun 4, 2026
840c7c0
Fix false connectivity banner on private Atomic sites (#22926)
jkmassel Jun 4, 2026
d086608
Merge trunk into merge/release-26.8-into-trunk to resolve conflicts
jkmassel Jun 4, 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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ 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 @@ -139,24 +150,66 @@ class EditorSettingsRepository @Inject constructor(
* assume the API lives at `/wp-json` (custom permalink structures or
* REST API paths would break that assumption), then use the routes list
* returned by discovery directly — no second request needed.
*
* Discovery is unauthenticated, so it can't reach a *private* Atomic host
* — the host gates anonymous requests and the API root never loads. When
* the site has application-password credentials, fall back to an
* authenticated probe against the same direct host (Basic auth), which is
* exactly the transport the editor uses there. Without credentials there's
* nothing to authenticate with, so we report failure. See #22883.
*/
private suspend fun fetchRouteSupportViaDirectHostDiscovery(
site: SiteModel
): Boolean {
val discovery = wpLoginClient.apiDiscovery(site.url)
if (discovery !is ApiDiscoveryResult.Success) {
if (discovery is ApiDiscoveryResult.Success) {
val resolver = wpApiClientProvider.urlResolverFor(
discovery.success.apiRootUrl
)
persistRouteSupport(site, discovery.success.apiDetails, resolver)
return true
}
AppLog.w(
T.EDITOR,
"Direct-host API discovery failed for" +
" site=${site.name}: ${discovery::class.simpleName}"
)
return if (site.hasApplicationPasswordCredentials()) {
fetchRouteSupportViaApplicationPasswordClient(site)
} else {
AppLog.w(
T.EDITOR,
"Direct-host API discovery failed for" +
" site=${site.name}: ${discovery::class.simpleName}"
"No application password for site=${site.name};" +
" skipping authenticated direct-host probe"
)
return false
false
}
}

/**
* Authenticated direct-host route probe for Atomic sites whose host
* rejects the anonymous discovery request (e.g. private sites). Uses the
* site's application-password (Basic auth) client and resolves routes
* against the same direct host — mirrors
* [fetchRouteSupportViaConfiguredClient] but bypasses the WP.com proxy.
*/
private suspend fun fetchRouteSupportViaApplicationPasswordClient(
site: SiteModel
): Boolean {
val client = wpApiClientProvider.getApplicationPasswordClient(site)
val resolver = wpApiClientProvider.getDirectHostApiUrlResolver(site)
val response = client.request { it.apiRoot().get() }
return if (response is WpRequestResult.Success) {
persistRouteSupport(site, response.response.data, resolver)
true
} else {
AppLog.w(
T.EDITOR,
"Authenticated direct-host probe failed for" +
" site=${site.name}: ${response::class.simpleName}"
)
false
}
val resolver = wpApiClientProvider.urlResolverFor(
discovery.success.apiRootUrl
)
persistRouteSupport(site, discovery.success.apiDetails, resolver)
return true
}

private fun persistRouteSupport(
Expand Down Expand Up @@ -215,3 +268,7 @@ class EditorSettingsRepository @Inject constructor(
false
}
}

private fun SiteModel.hasApplicationPasswordCredentials(): Boolean =
!apiRestUsernamePlain.isNullOrEmpty() &&
!apiRestPasswordPlain.isNullOrEmpty()
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ 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 @@ -148,6 +149,7 @@ 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,34 @@
package org.wordpress.android.ui.accounts.login

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton

/**
* App-scoped signal that an application password was newly established for a site — either by the
* headless Jetpack-tunnel mint on the My Site screen or by the interactive application-password
* login. Lets credential-dependent work (e.g. editor capability detection) re-run as soon as the
* password exists, instead of waiting for the next My Site resume/refresh.
*
* Emits the site's local id; collectors should re-read a fresh SiteModel so they observe the
* just-persisted credentials rather than a stale in-memory copy.
*/
@Singleton
class CredentialsChangedNotifier @Inject constructor() {
// replay = 1 so a collector that subscribes just after an emit still sees it — closes the
// emit-before-collect race. DROP_OLDEST keeps tryEmit non-suspending without an unbounded buffer.
private val _events = MutableSharedFlow<Int>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<Int> = _events.asSharedFlow()

/** Signals that [siteLocalId]'s application-password credentials were just established. */
fun notifyChanged(siteLocalId: Int) {
_events.tryEmit(siteLocalId)
}
}
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.CredentialsChangedNotifier
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
Expand All @@ -41,6 +42,7 @@ class ApplicationPasswordViewModelSlice @Inject constructor(
private val siteXMLRPCClient: SiteXMLRPCClient,
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
private val dispatcher: Dispatcher,
private val credentialsChangedNotifier: CredentialsChangedNotifier,
@Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher,
) {
lateinit var scope: CoroutineScope
Expand Down Expand Up @@ -112,6 +114,7 @@ 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}")
credentialsChangedNotifier.notifyChanged(storedSite.id)
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import kotlinx.coroutines.launch
import org.wordpress.android.R
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.repositories.EditorSettingsRepository
import org.wordpress.android.ui.accounts.login.CredentialsChangedNotifier
import org.wordpress.android.ui.mysite.MySiteCardAndItem
import org.wordpress.android.ui.mysite.SelectedSiteRepository
import org.wordpress.android.util.NetworkUtilsWrapper
import javax.inject.Inject

class SiteConnectivityBannerViewModelSlice @Inject constructor(
private val editorSettingsRepository: EditorSettingsRepository,
private val networkUtilsWrapper: NetworkUtilsWrapper,
private val credentialsChangedNotifier: CredentialsChangedNotifier,
private val selectedSiteRepository: SelectedSiteRepository,
) {
private lateinit var scope: CoroutineScope
private var currentJob: Job? = null
Expand All @@ -31,6 +35,18 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor(

fun initialize(scope: CoroutineScope) {
this.scope = scope
// Re-run detection the moment an application password is established for the selected site
// (e.g. the headless mint finished after our first fetch lost the race), instead of waiting
// for the next resume/refresh. Re-read the selected site so we see the just-persisted
// credentials; isUserInitiated = false so a replayed event is a no-op once cached.
scope.launch {
credentialsChangedNotifier.events.collect { siteLocalId ->
val site = selectedSiteRepository.getSelectedSite()
if (site != null && site.id == siteLocalId) {
fetchCapabilities(site, isUserInitiated = false)
}
}
}
}

fun fetchCapabilities(site: SiteModel, isUserInitiated: Boolean) {
Expand All @@ -52,7 +68,17 @@ class SiteConnectivityBannerViewModelSlice @Inject constructor(
// connection" banner already covers this case, and stacking warnings
// for the same root cause is just noise.
val suppressForOffline = !ok && !networkUtilsWrapper.isNetworkAvailable()
_uiModel.postValue(if (ok || hasCache || suppressForOffline) null else buildBanner())
// Atomic sites probe the direct host with an application password that's minted
// asynchronously on this same screen, so a first-login fetch can fail purely because
// the credential isn't ready yet. Treat that as pending, not a connection failure —
// the application-password card owns that state and a later fetch will succeed.
val suppressForPendingAuth =
!ok && editorSettingsRepository.isAwaitingApplicationPassword(site)
// Show the banner only as a last resort — not when detection succeeded, when we have
// cached capabilities, or while a transient non-error state (offline / pending creds)
// already explains the failure.
val suppressBanner = ok || hasCache || suppressForOffline || suppressForPendingAuth
_uiModel.postValue(if (suppressBanner) null else buildBanner())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
@Mock
lateinit var wpApiClient: WpApiClient

@Mock
lateinit var appPasswordClient: WpApiClient

@Mock
lateinit var apiUrlResolver: ApiUrlResolver

Expand Down Expand Up @@ -285,6 +288,84 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
verify(wpApiClientProvider, never()).getWpApiClient(atomicSite)
}

@Test
fun `atomic site falls back to authenticated probe when discovery fails`() =
runTest {
val atomicSite = SiteModel().apply {
id = 5
url = "https://atomic.example.com"
setIsWPCom(true)
setIsWPComAtomic(true)
apiRestUsernamePlain = "user"
apiRestPasswordPlain = "secret"
}
mockDiscoveryFailure(atomicSite.url)
whenever(
wpApiClientProvider.getApplicationPasswordClient(atomicSite)
).thenReturn(appPasswordClient)
whenever(
wpApiClientProvider.getDirectHostApiUrlResolver(atomicSite)
).thenReturn(directHostResolver)
mockApiRootResponseFor(
client = appPasswordClient,
resolver = directHostResolver,
hasEditorSettings = true,
hasEditorAssets = true,
)
whenever(themeRepository.fetchCurrentTheme(atomicSite))
.thenReturn(buildTheme(isBlockTheme = false))

val result =
repository.fetchEditorCapabilitiesForSite(atomicSite)

assertThat(result).isTrue()
verify(appPrefsWrapper)
.setSiteSupportsEditorSettings(atomicSite, true)
verify(appPrefsWrapper)
.setSiteSupportsEditorAssets(atomicSite, true)
}

@Test
fun `atomic site returns false when authenticated probe also fails`() =
runTest {
val atomicSite = SiteModel().apply {
id = 6
url = "https://atomic.example.com"
setIsWPCom(true)
setIsWPComAtomic(true)
apiRestUsernamePlain = "user"
apiRestPasswordPlain = "secret"
}
mockDiscoveryFailure(atomicSite.url)
whenever(
wpApiClientProvider.getApplicationPasswordClient(atomicSite)
).thenReturn(appPasswordClient)
whenever(
wpApiClientProvider.getDirectHostApiUrlResolver(atomicSite)
).thenReturn(directHostResolver)
mockApiRootErrorFor(appPasswordClient)
whenever(themeRepository.fetchCurrentTheme(atomicSite))
.thenReturn(buildTheme(isBlockTheme = false))

val result =
repository.fetchEditorCapabilitiesForSite(atomicSite)

assertThat(result).isFalse()
verify(appPrefsWrapper, never())
.setSiteSupportsEditorSettings(any(), any())
verify(appPrefsWrapper, never())
.setSiteSupportsEditorAssets(any(), any())
}

private suspend fun mockDiscoveryFailure(siteUrl: String) {
whenever(wpLoginClient.apiDiscovery(siteUrl))
.thenReturn(
ApiDiscoveryResult.FailureParseSiteUrl(
ParseUrlException.Generic("")
)
)
}

private suspend fun mockApiRootResponse(
hasEditorSettings: Boolean,
hasEditorAssets: Boolean
Expand Down Expand Up @@ -363,15 +444,17 @@ class EditorSettingsRepositoryTest : BaseUnitTest() {
)
}

private suspend fun mockApiRootError() = mockApiRootErrorFor(wpApiClient)

@Suppress("UNCHECKED_CAST")
private suspend fun mockApiRootError() {
private suspend fun mockApiRootErrorFor(client: WpApiClient) {
val error = WpRequestResult.UnknownError<Any>(
statusCode = 500u,
response = "Internal Server Error",
requestUrl = "https://test.wordpress.com/wp-json",
requestMethod = uniffi.wp_api.RequestMethod.GET
)
whenever(wpApiClient.request<Any>(any()))
whenever(client.request<Any>(any()))
.thenReturn(error)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() {
@Mock
lateinit var wpApiClientProvider: WpApiClientProvider

@Mock
lateinit var credentialsChangedNotifier: CredentialsChangedNotifier

private lateinit var applicationPasswordLoginHelper: ApplicationPasswordLoginHelper

@Before
Expand All @@ -92,7 +95,8 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() {
apiRootUrlCache,
discoverSuccessWrapper,
crashLogging,
wpApiClientProvider
wpApiClientProvider,
credentialsChangedNotifier
)
}

Expand Down Expand Up @@ -206,6 +210,7 @@ class ApplicationPasswordLoginHelperTest : BaseUnitTest() {
verify(siteStore).sites
verify(dispatcherWrapper).updateApplicationPassword(eq(siteModel))
verify(wpApiClientProvider).clearSelfHostedClient(eq(siteModel.id))
verify(credentialsChangedNotifier).notifyChanged(eq(siteModel.id))
}

@Test
Expand Down
Loading
Loading