Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
59 changes: 59 additions & 0 deletions play-services-core-proto/src/main/proto/account_state.proto
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>,
val disabled: Set<String>,
val pending: Set<String>,
val visibilityByCap: Map<String, List<String>>,
val syncTimeByCap: Map<String, Long>,
) {
/** 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<String>()
val disabled = mutableSetOf<String>()
val pending = mutableSetOf<String>()
val vis = mutableMapOf<String, List<String>>()
val times = mutableMapOf<String, Long>()

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<String>,
): Boolean {
val old = read(am, acc)

val enabled = fresh.enabled.toMutableSet()
val disabled = fresh.disabled.toMutableSet()
val realPending = mutableSetOf<String>()
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<String>): 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<String> =
am.getUserData(acc, key)
?.split(',')
?.filter { it.isNotEmpty() }
?.toHashSet() ?: emptySet()

private fun encodeVisMap(m: Map<String, List<String>>): String =
m.toSortedMap().entries.joinToString(";") { (cap, pkgs) ->
"$cap:${pkgs.toSortedSet().joinToString(",")}"
}

private fun decodeVisMap(raw: String?): Map<String, List<String>> {
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, Long>): String =
m.flatMap { listOf(it.key, it.value.toString()) }.joinToString(",")

private fun decodeSyncMap(raw: String?): Map<String, Long> {
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<String>, disabled: Set<String>, 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"
}
Loading