diff --git a/play-services-core-proto/src/main/proto/account_state.proto b/play-services-core-proto/src/main/proto/account_state.proto new file mode 100644 index 0000000000..f2c0dfd444 --- /dev/null +++ b/play-services-core-proto/src/main/proto/account_state.proto @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +option java_package = "org.microg.gms.auth.capabilities.proto"; +option java_multiple_files = true; + +message AccountStateRequestHeader { + optional string packageName = 1; + optional string appCertSha1Hex = 2; + optional string extra = 3; +} + +message AccountStateRequest { + optional AccountStateRequestHeader requestHeader = 1; +} + +enum CapabilityType { + TYPE_UNKNOWN = 0; + TYPE_DEFAULT = 1; +} + +enum CapabilityStatus { + STATUS_UNKNOWN = 0; + STATUS_ALLOWED = 1; + STATUS_DENIED = 2; + STATUS_PENDING = 3; +} + +message VisibilityPackage { + optional string packageName = 1; +} + +message Capability { + optional string name = 1; + optional CapabilityType type = 2; + optional CapabilityStatus status = 3; + repeated VisibilityPackage visibility = 5; +} + +message Capabilities { + repeated Capability entries = 1; + repeated string pending = 2; +} + +message ProfileInfo { + optional string firstName = 1; + optional string lastName = 2; + optional string displayName = 3; +} + +message AccountStateResponse { + optional string primaryEmail = 1; + repeated string services = 2; + optional Capabilities capabilities = 3; + optional ProfileInfo profile = 4; + optional string obfuscatedGaiaId = 5; +} diff --git a/play-services-core/src/main/java/org/microg/gms/auth/AuthManagerServiceImpl.java b/play-services-core/src/main/java/org/microg/gms/auth/AuthManagerServiceImpl.java index e0ae41cc9e..19a465cb07 100644 --- a/play-services-core/src/main/java/org/microg/gms/auth/AuthManagerServiceImpl.java +++ b/play-services-core/src/main/java/org/microg/gms/auth/AuthManagerServiceImpl.java @@ -42,6 +42,7 @@ import com.google.android.gms.auth.TokenData; import com.google.android.gms.common.api.Scope; +import org.microg.gms.auth.capabilities.HasCapabilitiesHandler; import org.microg.gms.common.GooglePackagePermission; import org.microg.gms.common.PackageUtils; @@ -238,14 +239,9 @@ public Bundle requestGoogleAccountsAccess(String packageName) throws RemoteExcep @Override public int hasCapabilities(HasCapabilitiesRequest request) throws RemoteException { PackageUtils.assertGooglePackagePermission(context, GooglePackagePermission.ACCOUNT); - List services = Arrays.asList(AccountManager.get(context).getUserData(request.account, "services").split(",")); - for (String capability : request.capabilities) { - if (capability.startsWith("service_") && !services.contains(capability.substring(8)) || !services.contains(capability)) { - return 6; - } - } - Log.w(TAG, "Not fully implemented: hasCapabilities(" + request.account + ", " + Arrays.toString(request.capabilities) + ")"); - return 1; + int result = new HasCapabilitiesHandler(context).handle(request); + Log.d(TAG, "hasCapabilities(" + request.account + ", " + Arrays.toString(request.capabilities) + ") = " + result); + return result; } @Override diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/AccountStateClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/AccountStateClient.kt new file mode 100644 index 0000000000..fee9b8c27a --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/AccountStateClient.kt @@ -0,0 +1,111 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + * + * HTTP client for the GMS account_state lookup endpoint: + * POST https://android.googleapis.com/auth/lookup/account_state?rt=b + */ +package org.microg.gms.auth.capabilities + +import android.accounts.Account +import android.content.Context +import android.util.Log +import org.microg.gms.auth.AuthManager +import org.microg.gms.auth.capabilities.proto.AccountStateRequest +import org.microg.gms.auth.capabilities.proto.AccountStateRequestHeader +import org.microg.gms.auth.capabilities.proto.AccountStateResponse +import org.microg.gms.checkin.LastCheckinInfo +import org.microg.gms.common.Constants +import org.microg.gms.common.PackageUtils +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +class AccountStateClient(private val context: Context) { + + companion object { + private const val TAG = "AccountStateClient" + + private const val URL_ENDPOINT = + "https://android.googleapis.com/auth/lookup/account_state?rt=b" + + /** + * OAuth2 scope used when fetching the bearer token for the REST + * account_state endpoint. Covers userinfo.email, account capabilities + * and account service flags. + */ + private const val ACCOUNT_STATE_SCOPE = + "oauth2:https://www.googleapis.com/auth/userinfo.email " + + "https://www.googleapis.com/auth/account.capabilities " + + "https://www.googleapis.com/auth/account.service_flags" + + // Request-flow tag identifying a forced GAIA services sync over the GMS network stack. + private const val GMSCORE_FLOW = "36" + + private const val TIMEOUT_MS = 5_000 + } + + /** + * Synchronously fetch the account state. Throws IOException on any network + * or auth failure (caller is expected to translate that to result code 8). + */ + @Throws(IOException::class) + fun sync(account: Account): AccountStateResponse { + val token = fetchAccessToken(account) + ?: throw IOException("couldn't fetch accessToken for AANG scope") + + val certSha1 = PackageUtils.firstSignatureDigest(context, Constants.GMS_PACKAGE_NAME) + ?.lowercase() + ?: throw IOException("no signature for ${Constants.GMS_PACKAGE_NAME}") + + val request = AccountStateRequest( + requestHeader = AccountStateRequestHeader( + packageName = Constants.GMS_PACKAGE_NAME, + appCertSha1Hex = certSha1, + ) + ) + + val conn = (URL(URL_ENDPOINT).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + requestMethod = "POST" + doOutput = true + setRequestProperty("Content-Type", "application/x-protobuf") + setRequestProperty("Authorization", "Bearer $token") + setRequestProperty("app", Constants.GMS_PACKAGE_NAME) + setRequestProperty("device", java.lang.Long.toHexString(LastCheckinInfo.read(context).androidId)) + setRequestProperty("gmsversion", Constants.GMS_VERSION_CODE.toString()) + setRequestProperty("gmscoreFlow", GMSCORE_FLOW) + } + + try { + conn.outputStream.use { it.write(AccountStateRequest.ADAPTER.encode(request)) } + val code = conn.responseCode + if (code !in 200..299) { + val err = runCatching { conn.errorStream?.bufferedReader()?.readText() }.getOrNull() + throw IOException("account_state HTTP $code: $err") + } + val bytes = conn.inputStream.use { it.readBytes() } + return AccountStateResponse.ADAPTER.decode(bytes) + } finally { + conn.disconnect() + } + } + + /** + * Reuse MicroG's AuthManager to obtain an OAuth2 access token under the + * AANG scope. This will call the same backend as regular app auth; in + * practice the server's cert-fingerprint check may reject the result + * unless MicroG is configured with a known-good GMS signature. + */ + private fun fetchAccessToken(account: Account): String? { + return try { + AuthManager(context, account.name, Constants.GMS_PACKAGE_NAME, ACCOUNT_STATE_SCOPE) + .requestAuth(false) + .auth + } catch (e: Exception) { + Log.w(TAG, "requestAuth failed: ${e.message}") + null + } + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/CapabilityStore.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/CapabilityStore.kt new file mode 100644 index 0000000000..a77283e13a --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/CapabilityStore.kt @@ -0,0 +1,184 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + * + * Local cache of account capabilities: decodes the server response into + * enabled / disabled / pending sets and merges them into AccountManager + * user-data. + */ +package org.microg.gms.auth.capabilities + +import android.accounts.Account +import android.accounts.AccountManager +import org.microg.gms.auth.capabilities.proto.Capabilities +import org.microg.gms.auth.capabilities.proto.CapabilityStatus +import org.microg.gms.auth.capabilities.proto.CapabilityType + +data class CapabilityState( + val enabled: Set, + val disabled: Set, + val pending: Set, + val visibilityByCap: Map>, + val syncTimeByCap: Map, +) { + /** Cache is considered populated once at least one allowed/denied entry exists. */ + val isValidCache: Boolean get() = enabled.isNotEmpty() || disabled.isNotEmpty() +} + +object CapabilityStore { + + /** Decode a server response into the enabled/disabled/pending-set form. */ + fun decode(caps: Capabilities, now: Long = System.currentTimeMillis()): CapabilityState { + val enabled = mutableSetOf() + val disabled = mutableSetOf() + val pending = mutableSetOf() + val vis = mutableMapOf>() + val times = mutableMapOf() + + for (c in caps.entries) { + // Only DEFAULT-typed capabilities are server-managed; skip the rest. + if (c.type != CapabilityType.TYPE_DEFAULT) continue + val name = c.name?.takeIf { it.isNotEmpty() } ?: continue + + if (c.visibility.isNotEmpty()) { + vis[name] = c.visibility.mapNotNull { it.packageName } + } + + when (c.status) { + CapabilityStatus.STATUS_DENIED -> { + disabled += name; times[name] = now + } + CapabilityStatus.STATUS_PENDING -> { + pending += name + } + // Treat ALLOWED and UNKNOWN the same — default to enabled. + else -> { + enabled += name; times[name] = now + } + } + } + return CapabilityState(enabled, disabled, pending, vis, times) + } + + /** Read the current cached state from AccountManager user-data. */ + fun read(am: AccountManager, acc: Account): CapabilityState = CapabilityState( + enabled = readSet(am, acc, UserDataKeys.ENABLED_CAPS), + disabled = readSet(am, acc, UserDataKeys.DISABLED_CAPS), + pending = readSet(am, acc, UserDataKeys.FAILED_CAPS), + visibilityByCap = decodeVisMap(am.getUserData(acc, UserDataKeys.PACKAGE_VISIBILITY)), + syncTimeByCap = decodeSyncMap(am.getUserData(acc, UserDataKeys.SYNC_TIME)), + ) + + /** + * Merge a freshly decoded server state with prior local state and write + * everything back to AccountManager.UserData. + * + * Returns true when an ACCOUNT_CAPABILITIES_CHANGED broadcast should fire. + */ + fun writeMerged( + am: AccountManager, + acc: Account, + fresh: CapabilityState, + services: Collection, + ): Boolean { + val old = read(am, acc) + + val enabled = fresh.enabled.toMutableSet() + val disabled = fresh.disabled.toMutableSet() + val realPending = mutableSetOf() + for (cap in fresh.pending) when (cap) { + in old.enabled -> enabled += cap + in old.disabled -> disabled += cap + else -> realPending += cap + } + + am.setUserData(acc, UserDataKeys.ENABLED_CAPS, enabled.joinToString(",")) + am.setUserData(acc, UserDataKeys.DISABLED_CAPS, disabled.joinToString(",")) + am.setUserData(acc, UserDataKeys.FAILED_CAPS, realPending.joinToString(",")) + am.setUserData(acc, UserDataKeys.CAPABILITIES_VERSION, "1") + am.setUserData(acc, UserDataKeys.PACKAGE_VISIBILITY, encodeVisMap(fresh.visibilityByCap)) + am.setUserData(acc, UserDataKeys.SYNC_TIME, encodeSyncMap(fresh.syncTimeByCap)) + + am.setUserData( + acc, UserDataKeys.HAS_PASSWORD, resolveBoolCap( + enabled, disabled, UserDataKeys.CAP_HAS_PASSWORD, + default = am.getUserData(acc, UserDataKeys.HAS_PASSWORD) != "0" + ).bit() + ) + am.setUserData( + acc, UserDataKeys.HAS_USERNAME, resolveBoolCap( + enabled, disabled, UserDataKeys.CAP_HAS_USERNAME, + default = am.getUserData(acc, UserDataKeys.HAS_USERNAME) != "0" + ).bit() + ) + + if (services.isNotEmpty()) { + am.setUserData(acc, UserDataKeys.SERVICES, services.joinToString(",")) + } + + return old.enabled != enabled || + old.disabled != disabled || + old.visibilityByCap != fresh.visibilityByCap + } + + /** + * Given a local [state] and a set of requested caps, produce a result + * code matching [HasCapabilitiesResult]. + */ + fun evaluate(state: CapabilityState, request: Collection): Int { + if (request.isEmpty()) return HasCapabilitiesResult.ALLOWED + var result = HasCapabilitiesResult.ALLOWED + for (cap in request) { + when (cap) { + in state.enabled -> continue + in state.disabled -> return HasCapabilitiesResult.DENIED + in state.pending -> + if (result == HasCapabilitiesResult.ALLOWED) + result = HasCapabilitiesResult.UNKNOWN + else -> result = HasCapabilitiesResult.NETWORK_RETRY + } + } + return result + } + + // ---- Serialization helpers ---- + + private fun readSet(am: AccountManager, acc: Account, key: String): Set = + am.getUserData(acc, key) + ?.split(',') + ?.filter { it.isNotEmpty() } + ?.toHashSet() ?: emptySet() + + private fun encodeVisMap(m: Map>): String = + m.toSortedMap().entries.joinToString(";") { (cap, pkgs) -> + "$cap:${pkgs.toSortedSet().joinToString(",")}" + } + + private fun decodeVisMap(raw: String?): Map> { + if (raw.isNullOrEmpty()) return emptyMap() + return raw.split(';').mapNotNull { + val parts = it.split(':', limit = 2) + if (parts.size != 2) null else parts[0] to parts[1].split(',') + }.toMap() + } + + private fun encodeSyncMap(m: Map): String = + m.flatMap { listOf(it.key, it.value.toString()) }.joinToString(",") + + private fun decodeSyncMap(raw: String?): Map { + if (raw.isNullOrEmpty()) return emptyMap() + val parts = raw.split(',') + if (parts.size % 2 != 0) return emptyMap() + return (parts.indices step 2).associate { parts[it] to (parts[it + 1].toLongOrNull() ?: 0L) } + } + + private fun resolveBoolCap( + enabled: Set, disabled: Set, key: String, default: Boolean + ): Boolean = when (key) { + in enabled -> true + in disabled -> false + else -> default + } + + private fun Boolean.bit(): String = if (this) "1" else "0" +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/HasCapabilitiesHandler.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/HasCapabilitiesHandler.kt new file mode 100644 index 0000000000..efbe7dac91 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/HasCapabilitiesHandler.kt @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + * + * Top-level handler for IAuthManagerService.hasCapabilities. Combines a + * local-cache fast-path with a network sync against the account_state + * endpoint. + */ +package org.microg.gms.auth.capabilities + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.Context +import android.content.Intent +import android.os.SystemClock +import android.util.Log +import com.google.android.gms.auth.HasCapabilitiesRequest +import java.util.concurrent.ConcurrentHashMap + +class HasCapabilitiesHandler(private val context: Context) { + + private val am = AccountManager.get(context) + + /** + * Returns a result code: + * 1 = allowed, 2 = denied, 3 = unknown, 4 = visibility-denied, + * 5 = network-retry, 6 = not-in-cache, 8 = IO error. + */ + fun handle(request: HasCapabilitiesRequest): Int { + val account: Account = request.account ?: return HasCapabilitiesResult.NOT_IN_CACHE + val caps = request.capabilities?.toSet().orEmpty() + if (caps.isEmpty()) return HasCapabilitiesResult.ALLOWED + + // 1) services-list short-circuit: caps that correspond to a service + // already known on this account are considered granted. + val services = am.getUserData(account, UserDataKeys.SERVICES) + ?.split(',')?.filter { it.isNotEmpty() }?.toSet().orEmpty() + Log.d(TAG, "handle: caps=$caps services=$services") + val unresolved = caps.filterNot { cap -> + if (cap.startsWith("service_")) + services.contains(cap.removePrefix("service_")) + else + services.contains(cap) + } + if (unresolved.isEmpty()) return HasCapabilitiesResult.ALLOWED + + // 2) Local capability cache + var state = CapabilityStore.read(am, account) + + // 3) Force a sync if the cache is empty. + if (!state.isValidCache) { + if (!syncOnce(account)) return HasCapabilitiesResult.NOT_IN_CACHE + state = CapabilityStore.read(am, account) + if (!state.isValidCache) return HasCapabilitiesResult.NOT_IN_CACHE + } + + // 4) Evaluate, and if the answer is NETWORK_RETRY refresh one more time. + val first = CapabilityStore.evaluate(state, unresolved) + if (first != HasCapabilitiesResult.NETWORK_RETRY) return first + + syncOnce(account) + state = CapabilityStore.read(am, account) + return CapabilityStore.evaluate(state, unresolved) + } + + /** + * Fetch the latest state, merge it into AccountManager, and emit the + * broadcast. Returns false on any error. + */ + private fun syncOnce(account: Account): Boolean { + val lock = lockFor(account) + return synchronized(lock) { + // Throttle: if a previous sync for this account succeeded within + // [SYNC_THROTTLE_MS], reuse that result. Failed syncs do not + // update the timestamp, so offline/retry paths are unaffected. + val since = SystemClock.elapsedRealtime() - (lastSyncAt[account] ?: 0L) + if (since < SYNC_THROTTLE_MS) { + Log.d(TAG, "syncOnce: throttled (${since}ms since last success)") + return@synchronized true + } + try { + Log.d(TAG, "syncOnce: ") + val resp = AccountStateClient(context).sync(account) + val fresh = resp.capabilities?.let { CapabilityStore.decode(it) } + ?: CapabilityState(emptySet(), emptySet(), emptySet(), emptyMap(), emptyMap()) + + val changed = CapabilityStore.writeMerged(am, account, fresh, resp.services) + + if (changed) { + context.sendBroadcast( + Intent(CapabilityBroadcasts.ACTION_CHANGED) + .putExtra(CapabilityBroadcasts.EXTRA_ACCOUNT, account) + ) + } + lastSyncAt[account] = SystemClock.elapsedRealtime() + true + } catch (e: Exception) { + Log.w(TAG, "Account state sync failed: ${e.message}") + false + } + } + } + + /** + * API-19-safe get-or-put for [accountLocks]. `computeIfAbsent` requires + * Android API 24+, so we use [ConcurrentHashMap.putIfAbsent] (available + * since API 1) instead. + */ + private fun lockFor(account: Account): Any { + accountLocks[account]?.let { return it } + val candidate = Any() + return accountLocks.putIfAbsent(account, candidate) ?: candidate + } + + companion object { + private const val TAG = "HasCapabilitiesHandler" + + /** + * Time window after a successful sync during which duplicate + * syncOnce calls for the same account are short-circuited. 5 s + * collapses the burst of concurrent hasCapabilities calls that + * typically follows first-time account login, while leaving any + * manual refresh issued more than 5 s later unaffected. + */ + private const val SYNC_THROTTLE_MS = 5_000L + + /** + * Per-account mutex. Shared across all [HasCapabilitiesHandler] + * instances in this process — the handler itself is instantiated + * per-call in [AuthManagerServiceImpl], so the lock map must be + * process-scoped. Entries are never removed; the set is bounded by + * the number of Google accounts on the device (typically 1-3). + */ + private val accountLocks = ConcurrentHashMap() + + /** Timestamp (elapsedRealtime) of the most recent successful sync. */ + private val lastSyncAt = ConcurrentHashMap() + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/UserDataKeys.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/UserDataKeys.kt new file mode 100644 index 0000000000..e6ebd4eea6 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/capabilities/UserDataKeys.kt @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + * + * AccountManager.UserData keys used by the GMS-compatible account + * capability storage. String values match the on-disk keys the GMS client + * reads so existing caches remain interoperable. + */ +package org.microg.gms.auth.capabilities + +object UserDataKeys { + const val GOOGLE_USER_ID = "GoogleUserId" + const val CAPABILITIES_VERSION = "capabilities_version" + const val DISABLED_CAPS = "disabled_capabilities" + const val ENABLED_CAPS = "enabled_capabilities" + const val FAILED_CAPS = "failed_capabilities" + const val HAS_PASSWORD = "hasPassword" + const val HAS_USERNAME = "hasUsername" + const val FIRST_NAME = "firstName" + const val LAST_NAME = "lastName" + const val SERVICES = "services" + const val PACKAGE_VISIBILITY = "package_visibilities_for_capabilities" + const val SYNC_TIME = "capability_sync_time" + + // Pseudo-capabilities that encode boolean account flags. + const val CAP_HAS_PASSWORD = "geytglldmfya" + const val CAP_HAS_USERNAME = "geydolldmfya" +} + +object CapabilityBroadcasts { + const val ACTION_CHANGED = "com.google.android.gms.auth.ACCOUNT_CAPABILITIES_CHANGED" + const val EXTRA_ACCOUNT = "account" +} + +/** Result codes returned by IAuthManagerService.hasCapabilities. */ +object HasCapabilitiesResult { + const val ALLOWED = 1 + const val DENIED = 2 + const val UNKNOWN = 3 + const val VIS_DENIED = 4 + const val NETWORK_RETRY = 5 + const val NOT_IN_CACHE = 6 + const val IO_ERROR = 8 +}