Skip to content
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* [*] Sharing content to the app now lists self-hosted sites connected with an application password.
* [**] 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.

26.9
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package org.wordpress.android.fluxc.network.rest.wpapi.jetpack

import okhttp3.Interceptor
import org.wordpress.android.fluxc.module.OkHttpClientQualifiers
import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpNetworkAvailabilityProvider
import org.wordpress.android.fluxc.utils.AppLogWrapper
import org.wordpress.android.util.AppLog
import rs.wordpress.api.kotlin.EmptyAppNotifier
import rs.wordpress.api.kotlin.WpRequestExecutor
import uniffi.wp_api.JetpackConnectionClient
import uniffi.wp_api.JetpackConnectionStatus
import uniffi.wp_api.ParsedUrl
import uniffi.wp_api.WpApiClientDelegate
import uniffi.wp_api.WpApiMiddlewarePipeline
import uniffi.wp_api.WpAuthenticationProvider
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

/**
* A self-hosted site's Jetpack connection to WordPress.com. [wpComSiteId] is the blog ID WordPress.com
* assigned the site when it connected, and is null when it isn't connected.
*/
data class JetpackConnectionState(val wpComSiteId: Long?) {
val isConnected: Boolean get() = wpComSiteId != null
}

/**
* Reads a self-hosted site's Jetpack connection state over wordpress-rs.
*
* Only the Jetpack plugin knows whether a site is connected to WordPress.com and which blog ID it was
* given. Sites added with an application password have no other source for either: the WP.com REST site
* payload that populates those fields for Jetpack sites is never fetched for them.
*/
@Singleton
class JetpackConnectionStatusFetcher @Inject constructor(
@Named(OkHttpClientQualifiers.INTERCEPTORS) interceptors: Set<@JvmSuppressWildcards Interceptor>,
networkAvailabilityProvider: WpNetworkAvailabilityProvider,
private val appLogWrapper: AppLogWrapper
) {
// One executor for the singleton: each WpRequestExecutor builds its own OkHttpClient (with its own
// connection pool), and the executor is site-independent -- auth lives in the per-call delegate.
private val requestExecutor = WpRequestExecutor(
interceptors = interceptors.toList(),
networkAvailabilityProvider = networkAvailabilityProvider
)

/**
* Returns the site's Jetpack connection state, or null when it can't be determined — the site is
* unreachable, the endpoint isn't there, or Jetpack is older than 14.2, which is where these
* endpoints were added. Callers should read null as "unchanged", not as "not connected".
*/
@Suppress("TooGenericExceptionCaught")
suspend fun fetch(
apiRootUrl: String,
username: String,
password: String
): JetpackConnectionState? = try {
buildClient(apiRootUrl, username, password).use { client ->
val blogId = when (val status = client.status()) {
is JetpackConnectionStatus.NotConnected -> null
is JetpackConnectionStatus.Site -> status.blogId
is JetpackConnectionStatus.User -> status.blogId
}
JetpackConnectionState(wpComSiteId = blogId?.toLong())
}
} catch (e: Exception) {
appLogWrapper.d(AppLog.T.API, "$TAG: couldn't read the Jetpack connection status: ${e.message}")
null
}

private fun buildClient(
apiRootUrl: String,
username: String,
password: String
) = JetpackConnectionClient(
apiRootUrl = ParsedUrl.parse(apiRootUrl),
delegate = WpApiClientDelegate(
authProvider = WpAuthenticationProvider.staticWithUsernameAndPassword(username, password),
requestExecutor = requestExecutor,
middlewarePipeline = WpApiMiddlewarePipeline(emptyList()),
// Reading the status is a passive check made during a site refresh, so a rejected
// credential shouldn't raise the app-wide "re-authenticate" prompt on its own.
appNotifier = EmptyAppNotifier()
)
)

companion object {
private const val TAG = "JetpackConnectionStatusFetcher"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.wordpress.android.fluxc.network.rest.wpapi.site

import com.android.volley.RequestQueue
import com.google.gson.reflect.TypeToken
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.wordpress.android.fluxc.Dispatcher
import org.wordpress.android.fluxc.model.SiteModel
Expand All @@ -13,6 +15,9 @@ import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIDiscoveryUtils
import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder
import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error
import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success
import org.wordpress.android.fluxc.network.rest.wpapi.jetpack.JetpackConnectionState
import org.wordpress.android.fluxc.network.rest.wpapi.jetpack.JetpackConnectionStatusFetcher
import org.wordpress.android.fluxc.network.rest.wpapi.plugin.PluginResponseModel
import org.wordpress.android.fluxc.store.SiteStore.FetchWPAPISitePayload
import org.wordpress.android.fluxc.utils.extensions.getPasswordProcessed
import org.wordpress.android.fluxc.utils.extensions.getUserNameProcessed
Expand All @@ -26,19 +31,33 @@ import javax.inject.Singleton
class SiteWPAPIRestClient @Inject constructor(
private val wpapiGsonRequestBuilder: WPAPIGsonRequestBuilder,
private val discoveryWPAPIRestClient: DiscoveryWPAPIRestClient,
private val jetpackConnectionStatusFetcher: JetpackConnectionStatusFetcher,
dispatcher: Dispatcher,
@Named(OkHttpClientQualifiers.CUSTOM_SSL) requestQueue: RequestQueue,
userAgent: UserAgent
) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) {
companion object {
private const val WOO_API_NAMESPACE_PREFIX = "wc/"
private const val JETPACK_API_NAMESPACE_PREFIX = "jetpack/"
private const val FETCH_API_CALL_FIELDS =
"name,description,gmt_offset,url,authentication,namespaces"
private const val APPLICATION_PASSWORDS_URL_SUFFIX = "authorize-application.php"
private const val PLUGINS_PATH = "wp/v2/plugins"
private const val PLUGINS_FIELDS = "plugin,status,version"
private const val JETPACK_PLUGIN_SEARCH = "jetpack"
private const val JETPACK_PLUGIN_ID = "jetpack/jetpack"
private const val PLUGIN_STATUS_ACTIVE = "active"
private const val PLUGIN_STATUS_NETWORK_ACTIVE = "network-active"
}

/**
* @param previousSite the site this fetch is refreshing, when there is one. The model returned here
* replaces the stored row wholesale, so fields this fetch can't determine are carried forward from it
* rather than reset.
*/
suspend fun fetchWPAPISite(
payload: FetchWPAPISitePayload
payload: FetchWPAPISitePayload,
previousSite: SiteModel? = null
): SiteModel {
val cleanedUrl = UrlUtils.addUrlSchemeIfNeeded(payload.url, false).let { urlWithScheme ->
DiscoveryUtils.stripKnownPaths(urlWithScheme)
Expand All @@ -57,14 +76,22 @@ class SiteWPAPIRestClient @Inject constructor(
return when (result) {
is Success -> {
val response = result.data
val jetpackPlugin = fetchJetpackPluginState(response?.namespaces, discoveredWpApiUrl, payload)
val jetpackConnection = fetchJetpackConnectionState(jetpackPlugin, discoveredWpApiUrl, payload)
SiteModel().apply {
// Carry the local id so SiteStore.updateSite finds the stored row and preserves the
// editor preferences, and so SiteSqlUtils matches the row by local id rather than by
// SITE_ID + URL -- that match misses, and inserts a duplicate site, as soon as a real
// WP.com blog id is stored.
id = previousSite?.id ?: 0
name = response?.name
description = response?.description
timezone = response?.gmtOffset
origin = SiteModel.ORIGIN_WPAPI
hasWooCommerce = response?.namespaces?.any {
it.startsWith(WOO_API_NAMESPACE_PREFIX)
} ?: false
applyJetpackState(jetpackPlugin, jetpackConnection, previousSite)

applicationPasswordsAuthorizeUrl = response?.authentication?.applicationPasswords
?.endpoints?.authorization
Expand Down Expand Up @@ -104,8 +131,133 @@ class SiteWPAPIRestClient @Inject constructor(
password = site.getPasswordProcessed(),
isApplicationPassword =
site.hasApplicationPassword(),
)
),
previousSite = site
)
}

/**
* The Jetpack plugin, as reported by the site itself. [isActive] is what
* [SiteModel.isJetpackInstalled] documents -- installed *and* activated.
*/
private data class JetpackPluginState(val isActive: Boolean, val version: String?)

/**
* Determines whether the site is running the Jetpack plugin, or null when it can't be determined.
*
* The `jetpack/` REST namespace is only a first filter: it's registered by the shared
* `automattic/jetpack-connection` package, which also ships inside Jetpack Boost, Protect, Social and
* VaultPress Backup, so its presence does *not* mean the Jetpack plugin is installed. What it does give
* us for free is a reliable negative -- no namespace, no active Jetpack -- which keeps the plugin lookup
* off the refresh path for the sites that have nothing to do with Jetpack.
*
* Reading the plugin list needs credentials and the `activate_plugins` capability, so it returns null for
* sites without an application password and for users who aren't administrators. Callers must read null
* as "unchanged", not as "not installed".
*/
private suspend fun fetchJetpackPluginState(
namespaces: List<String>?,
apiRootUrl: String,
payload: FetchWPAPISitePayload
): JetpackPluginState? {
val hasJetpackNamespace = namespaces?.any { it.startsWith(JETPACK_API_NAMESPACE_PREFIX) } ?: false
if (!hasJetpackNamespace) return JetpackPluginState(isActive = false, version = null)

val username = payload.username
val password = payload.password
return if (payload.isApplicationPassword && !username.isNullOrEmpty() && !password.isNullOrEmpty()) {
requestJetpackPlugin(apiRootUrl, username, password)
} else {
null
}
}

private suspend fun requestJetpackPlugin(
apiRootUrl: String,
username: String,
password: String
): JetpackPluginState? {
val result = wpapiGsonRequestBuilder.syncGetRequest<List<PluginResponseModel>>(
restClient = this,
url = apiRootUrl.trimEnd('/') + "/" + PLUGINS_PATH,
params = mapOf("search" to JETPACK_PLUGIN_SEARCH, "_fields" to PLUGINS_FIELDS),
type = object : TypeToken<List<PluginResponseModel>>() {}.type,
headers = mapOf("Authorization" to Credentials.basic(username, password))
)

return when (result) {
is Success -> {
val jetpack = result.data?.firstOrNull { it.plugin == JETPACK_PLUGIN_ID }
JetpackPluginState(
isActive = jetpack?.status == PLUGIN_STATUS_ACTIVE ||
jetpack?.status == PLUGIN_STATUS_NETWORK_ACTIVE,
version = jetpack?.version
)
}

is Error -> null
}
}

/**
* Reads the site's Jetpack connection to WordPress.com, or null when it can't be determined. Only the
* Jetpack plugin knows the blog ID WordPress.com assigned the site, and without it the row keeps
* SITE_ID = 0 -- which is what makes a later /me/sites sync insert a duplicate site instead of matching
* and upgrading this one. Skipped entirely when the plugin isn't there to ask.
*/
private suspend fun fetchJetpackConnectionState(
plugin: JetpackPluginState?,
apiRootUrl: String,
payload: FetchWPAPISitePayload
): JetpackConnectionState? {
// isActive = true is only ever produced by requestJetpackPlugin, which already required the
// application-password credentials, so no separate credential check is needed here.
if (plugin?.isActive != true) return null
val username = payload.username
val password = payload.password
return if (username != null && password != null) {
jetpackConnectionStatusFetcher.fetch(apiRootUrl, username, password)
} else {
null
}
}

/**
* A null [plugin] or [connection] means that state couldn't be read on this run, in which case the
* stored values are carried forward rather than reset. A non-null result is definitive, including
* its null fields -- a disconnected site clears the blog id rather than keeping a stale one.
*
* Note that [SiteModel.isJetpackConnected] only says the site is connected to *some* WordPress.com
* account, not necessarily the one signed in here -- callers that need the stronger claim have to check
* account access separately. See CMM-2344.
*/
private fun SiteModel.applyJetpackState(
plugin: JetpackPluginState?,
connection: JetpackConnectionState?,
previousSite: SiteModel?
) {
if (plugin != null) {
setIsJetpackInstalled(plugin.isActive)
jetpackVersion = plugin.version
} else {
setIsJetpackInstalled(previousSite?.isJetpackInstalled ?: false)
jetpackVersion = previousSite?.jetpackVersion
}
when {
// The plugin is definitively gone, so its WordPress.com connection is too.
plugin != null && !plugin.isActive -> {
setIsJetpackConnected(false)
siteId = 0
}
connection != null -> {
setIsJetpackConnected(connection.isConnected)
siteId = connection.wpComSiteId ?: 0L
}
else -> {
setIsJetpackConnected(previousSite?.isJetpackConnected ?: false)
siteId = previousSite?.siteId ?: 0L
}
}
}

private fun discoverApiEndpoint(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,14 +415,21 @@ class SiteSqlUtils
return updateXmlRpcUrl(localId, xmlRpcUrl)
}

private fun wpApiSiteLocalIdByUrl(siteUrl: String): Int? =
/**
* The stored ORIGIN_WPAPI row for [siteUrl], for fetches that build a fresh model with no local id and
* need the identity and the columns the response can't carry. Keyed by URL for the same reason as
* [updateWpApiRestUrlForWPAPISite].
*/
fun getWPAPISiteByUrl(siteUrl: String): SiteModel? =
WellSql.select(SiteModel::class.java)
.where().beginGroup()
.equals(SiteModelTable.URL, siteUrl)
.equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPAPI)
.endGroup().endWhere()
.asModel
.firstOrNull()?.id
.firstOrNull()

private fun wpApiSiteLocalIdByUrl(siteUrl: String): Int? = getWPAPISiteByUrl(siteUrl)?.id

val wPComSites: SelectQuery<SiteModel>
get() = WellSql.select(SiteModel::class.java)
Expand Down
Loading
Loading