Skip to content

Commit c79b53c

Browse files
authored
Merge branch 'master' into iap_security_certification
2 parents 276519e + 9a2ebbf commit c79b53c

9 files changed

Lines changed: 560 additions & 21 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026, microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
option java_package = "org.microg.gms.auth.capabilities.proto";
7+
option java_multiple_files = true;
8+
9+
message AccountStateRequestHeader {
10+
optional string packageName = 1;
11+
optional string appCertSha1Hex = 2;
12+
optional string extra = 3;
13+
}
14+
15+
message AccountStateRequest {
16+
optional AccountStateRequestHeader requestHeader = 1;
17+
}
18+
19+
enum CapabilityType {
20+
TYPE_UNKNOWN = 0;
21+
TYPE_DEFAULT = 1;
22+
}
23+
24+
enum CapabilityStatus {
25+
STATUS_UNKNOWN = 0;
26+
STATUS_ALLOWED = 1;
27+
STATUS_DENIED = 2;
28+
STATUS_PENDING = 3;
29+
}
30+
31+
message VisibilityPackage {
32+
optional string packageName = 1;
33+
}
34+
35+
message Capability {
36+
optional string name = 1;
37+
optional CapabilityType type = 2;
38+
optional CapabilityStatus status = 3;
39+
repeated VisibilityPackage visibility = 5;
40+
}
41+
42+
message Capabilities {
43+
repeated Capability entries = 1;
44+
repeated string pending = 2;
45+
}
46+
47+
message ProfileInfo {
48+
optional string firstName = 1;
49+
optional string lastName = 2;
50+
optional string displayName = 3;
51+
}
52+
53+
message AccountStateResponse {
54+
optional string primaryEmail = 1;
55+
repeated string services = 2;
56+
optional Capabilities capabilities = 3;
57+
optional ProfileInfo profile = 4;
58+
optional string obfuscatedGaiaId = 5;
59+
}

play-services-core/src/main/java/org/microg/gms/auth/AuthManagerServiceImpl.java

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import com.google.android.gms.auth.TokenData;
4343
import com.google.android.gms.common.api.Scope;
4444

45+
import org.microg.gms.auth.capabilities.HasCapabilitiesHandler;
4546
import org.microg.gms.common.GooglePackagePermission;
4647
import org.microg.gms.common.PackageUtils;
4748

@@ -238,14 +239,9 @@ public Bundle requestGoogleAccountsAccess(String packageName) throws RemoteExcep
238239
@Override
239240
public int hasCapabilities(HasCapabilitiesRequest request) throws RemoteException {
240241
PackageUtils.assertGooglePackagePermission(context, GooglePackagePermission.ACCOUNT);
241-
List<String> services = Arrays.asList(AccountManager.get(context).getUserData(request.account, "services").split(","));
242-
for (String capability : request.capabilities) {
243-
if (capability.startsWith("service_") && !services.contains(capability.substring(8)) || !services.contains(capability)) {
244-
return 6;
245-
}
246-
}
247-
Log.w(TAG, "Not fully implemented: hasCapabilities(" + request.account + ", " + Arrays.toString(request.capabilities) + ")");
248-
return 1;
242+
int result = new HasCapabilitiesHandler(context).handle(request);
243+
Log.d(TAG, "hasCapabilities(" + request.account + ", " + Arrays.toString(request.capabilities) + ") = " + result);
244+
return result;
249245
}
250246

251247
@Override
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026, microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.microg.gms.auth.capabilities
6+
7+
import android.accounts.Account
8+
import android.content.Context
9+
import android.util.Log
10+
import org.microg.gms.auth.AuthManager
11+
import org.microg.gms.auth.capabilities.proto.AccountStateRequest
12+
import org.microg.gms.auth.capabilities.proto.AccountStateRequestHeader
13+
import org.microg.gms.auth.capabilities.proto.AccountStateResponse
14+
import org.microg.gms.checkin.LastCheckinInfo
15+
import org.microg.gms.common.Constants
16+
import org.microg.gms.common.PackageUtils
17+
import java.io.IOException
18+
import java.net.HttpURLConnection
19+
import java.net.URL
20+
21+
/**
22+
* HTTP client for the GMS account_state lookup endpoint:
23+
* POST https://android.googleapis.com/auth/lookup/account_state?rt=b
24+
*/
25+
class AccountStateClient(private val context: Context) {
26+
27+
companion object {
28+
private const val TAG = "AccountStateClient"
29+
30+
private const val URL_ENDPOINT =
31+
"https://android.googleapis.com/auth/lookup/account_state?rt=b"
32+
33+
private const val ACCOUNT_STATE_SCOPE =
34+
"oauth2:https://www.googleapis.com/auth/userinfo.email " +
35+
"https://www.googleapis.com/auth/account.capabilities " +
36+
"https://www.googleapis.com/auth/account.service_flags"
37+
38+
// Request-flow tag identifying a forced GAIA services sync over the GMS network stack.
39+
private const val GMSCORE_FLOW = "36"
40+
41+
private const val TIMEOUT_MS = 5_000
42+
}
43+
44+
fun sync(account: Account): AccountStateResponse {
45+
val token = fetchAccessToken(account)
46+
?: throw IOException("couldn't fetch accessToken for AANG scope")
47+
48+
val certSha1 = PackageUtils.firstSignatureDigest(context, Constants.GMS_PACKAGE_NAME)
49+
?.lowercase()
50+
?: throw IOException("no signature for ${Constants.GMS_PACKAGE_NAME}")
51+
52+
val request = AccountStateRequest(
53+
requestHeader = AccountStateRequestHeader(
54+
packageName = Constants.GMS_PACKAGE_NAME,
55+
appCertSha1Hex = certSha1,
56+
)
57+
)
58+
59+
val conn = (URL(URL_ENDPOINT).openConnection() as HttpURLConnection).apply {
60+
connectTimeout = TIMEOUT_MS
61+
readTimeout = TIMEOUT_MS
62+
requestMethod = "POST"
63+
doOutput = true
64+
setRequestProperty("Content-Type", "application/x-protobuf")
65+
setRequestProperty("Authorization", "Bearer $token")
66+
setRequestProperty("app", Constants.GMS_PACKAGE_NAME)
67+
setRequestProperty("device", java.lang.Long.toHexString(LastCheckinInfo.read(context).androidId))
68+
setRequestProperty("gmsversion", Constants.GMS_VERSION_CODE.toString())
69+
setRequestProperty("gmscoreFlow", GMSCORE_FLOW)
70+
}
71+
72+
try {
73+
conn.outputStream.use { it.write(AccountStateRequest.ADAPTER.encode(request)) }
74+
val code = conn.responseCode
75+
if (code !in 200..299) {
76+
val err = runCatching { conn.errorStream?.bufferedReader()?.readText() }.getOrNull()
77+
throw IOException("account_state HTTP $code: $err")
78+
}
79+
val bytes = conn.inputStream.use { it.readBytes() }
80+
return AccountStateResponse.ADAPTER.decode(bytes)
81+
} finally {
82+
conn.disconnect()
83+
}
84+
}
85+
86+
private fun fetchAccessToken(account: Account): String? {
87+
return try {
88+
val authManager = AuthManager(context, account.name, Constants.GMS_PACKAGE_NAME, ACCOUNT_STATE_SCOPE)
89+
authManager.packageSignature = Constants.GMS_PACKAGE_SIGNATURE_SHA1
90+
authManager.requestAuth(false).auth
91+
} catch (e: Exception) {
92+
Log.w(TAG, "requestAuth failed: ${e.message}")
93+
null
94+
}
95+
}
96+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026, microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.microg.gms.auth.capabilities
6+
7+
import android.accounts.Account
8+
import android.accounts.AccountManager
9+
import org.microg.gms.auth.capabilities.proto.Capabilities
10+
import org.microg.gms.auth.capabilities.proto.CapabilityStatus
11+
import org.microg.gms.auth.capabilities.proto.CapabilityType
12+
13+
/**
14+
* Local cache of account capabilities: decodes the server response into
15+
* enabled / disabled / pending sets and merges them into AccountManager
16+
* user-data.
17+
*/
18+
data class CapabilityState(
19+
val enabled: Set<String>,
20+
val disabled: Set<String>,
21+
val pending: Set<String>,
22+
val visibilityByCap: Map<String, List<String>>,
23+
val syncTimeByCap: Map<String, Long>,
24+
) {
25+
/** Cache is considered populated once at least one allowed/denied entry exists. */
26+
val isValidCache: Boolean get() = enabled.isNotEmpty() || disabled.isNotEmpty()
27+
}
28+
29+
object CapabilityStore {
30+
31+
/** Decode a server response into the enabled/disabled/pending-set form. */
32+
fun decode(caps: Capabilities, now: Long = System.currentTimeMillis()): CapabilityState {
33+
val enabled = mutableSetOf<String>()
34+
val disabled = mutableSetOf<String>()
35+
val pending = mutableSetOf<String>()
36+
val vis = mutableMapOf<String, List<String>>()
37+
val times = mutableMapOf<String, Long>()
38+
39+
for (c in caps.entries) {
40+
// Only DEFAULT-typed capabilities are server-managed; skip the rest.
41+
if (c.type != CapabilityType.TYPE_DEFAULT) continue
42+
val name = c.name?.takeIf { it.isNotEmpty() } ?: continue
43+
44+
if (c.visibility.isNotEmpty()) {
45+
vis[name] = c.visibility.mapNotNull { it.packageName }
46+
}
47+
48+
when (c.status) {
49+
CapabilityStatus.STATUS_DENIED -> {
50+
disabled += name; times[name] = now
51+
}
52+
CapabilityStatus.STATUS_PENDING -> {
53+
pending += name
54+
}
55+
// Treat ALLOWED and UNKNOWN the same — default to enabled.
56+
else -> {
57+
enabled += name; times[name] = now
58+
}
59+
}
60+
}
61+
return CapabilityState(enabled, disabled, pending, vis, times)
62+
}
63+
64+
/** Read the current cached state from AccountManager user-data. */
65+
fun read(am: AccountManager, acc: Account): CapabilityState = CapabilityState(
66+
enabled = readSet(am, acc, UserDataKeys.ENABLED_CAPS),
67+
disabled = readSet(am, acc, UserDataKeys.DISABLED_CAPS),
68+
pending = readSet(am, acc, UserDataKeys.FAILED_CAPS),
69+
visibilityByCap = decodeVisMap(am.getUserData(acc, UserDataKeys.PACKAGE_VISIBILITY)),
70+
syncTimeByCap = decodeSyncMap(am.getUserData(acc, UserDataKeys.SYNC_TIME)),
71+
)
72+
73+
/**
74+
* Merge a freshly decoded server state with prior local state and write
75+
* everything back to AccountManager.UserData.
76+
*
77+
* Returns true when an ACCOUNT_CAPABILITIES_CHANGED broadcast should fire.
78+
*/
79+
fun writeMerged(
80+
am: AccountManager,
81+
acc: Account,
82+
fresh: CapabilityState,
83+
services: Collection<String>,
84+
): Boolean {
85+
val old = read(am, acc)
86+
87+
val enabled = fresh.enabled.toMutableSet()
88+
val disabled = fresh.disabled.toMutableSet()
89+
val realPending = mutableSetOf<String>()
90+
for (cap in fresh.pending) when (cap) {
91+
in old.enabled -> enabled += cap
92+
in old.disabled -> disabled += cap
93+
else -> realPending += cap
94+
}
95+
96+
am.setUserData(acc, UserDataKeys.ENABLED_CAPS, enabled.joinToString(","))
97+
am.setUserData(acc, UserDataKeys.DISABLED_CAPS, disabled.joinToString(","))
98+
am.setUserData(acc, UserDataKeys.FAILED_CAPS, realPending.joinToString(","))
99+
am.setUserData(acc, UserDataKeys.CAPABILITIES_VERSION, "1")
100+
am.setUserData(acc, UserDataKeys.PACKAGE_VISIBILITY, encodeVisMap(fresh.visibilityByCap))
101+
am.setUserData(acc, UserDataKeys.SYNC_TIME, encodeSyncMap(fresh.syncTimeByCap))
102+
103+
am.setUserData(
104+
acc, UserDataKeys.HAS_PASSWORD, resolveBoolCap(
105+
enabled, disabled, UserDataKeys.CAP_HAS_PASSWORD,
106+
default = am.getUserData(acc, UserDataKeys.HAS_PASSWORD) != "0"
107+
).bit()
108+
)
109+
am.setUserData(
110+
acc, UserDataKeys.HAS_USERNAME, resolveBoolCap(
111+
enabled, disabled, UserDataKeys.CAP_HAS_USERNAME,
112+
default = am.getUserData(acc, UserDataKeys.HAS_USERNAME) != "0"
113+
).bit()
114+
)
115+
116+
if (services.isNotEmpty()) {
117+
am.setUserData(acc, UserDataKeys.SERVICES, services.joinToString(","))
118+
}
119+
120+
return old.enabled != enabled ||
121+
old.disabled != disabled ||
122+
old.visibilityByCap != fresh.visibilityByCap
123+
}
124+
125+
/**
126+
* Given a local [state] and a set of requested caps, produce a result
127+
* code matching [HasCapabilitiesResult].
128+
*/
129+
fun evaluate(state: CapabilityState, request: Collection<String>): Int {
130+
if (request.isEmpty()) return HasCapabilitiesResult.ALLOWED
131+
var result = HasCapabilitiesResult.ALLOWED
132+
for (cap in request) {
133+
when (cap) {
134+
in state.enabled -> continue
135+
in state.disabled -> return HasCapabilitiesResult.DENIED
136+
in state.pending ->
137+
if (result == HasCapabilitiesResult.ALLOWED)
138+
result = HasCapabilitiesResult.UNKNOWN
139+
else -> result = HasCapabilitiesResult.NETWORK_RETRY
140+
}
141+
}
142+
return result
143+
}
144+
145+
// ---- Serialization helpers ----
146+
147+
private fun readSet(am: AccountManager, acc: Account, key: String): Set<String> =
148+
am.getUserData(acc, key)
149+
?.split(',')
150+
?.filter { it.isNotEmpty() }
151+
?.toHashSet() ?: emptySet()
152+
153+
private fun encodeVisMap(m: Map<String, List<String>>): String =
154+
m.toSortedMap().entries.joinToString(";") { (cap, pkgs) ->
155+
"$cap:${pkgs.toSortedSet().joinToString(",")}"
156+
}
157+
158+
private fun decodeVisMap(raw: String?): Map<String, List<String>> {
159+
if (raw.isNullOrEmpty()) return emptyMap()
160+
return raw.split(';').mapNotNull {
161+
val parts = it.split(':', limit = 2)
162+
if (parts.size != 2) null else parts[0] to parts[1].split(',')
163+
}.toMap()
164+
}
165+
166+
private fun encodeSyncMap(m: Map<String, Long>): String =
167+
m.flatMap { listOf(it.key, it.value.toString()) }.joinToString(",")
168+
169+
private fun decodeSyncMap(raw: String?): Map<String, Long> {
170+
if (raw.isNullOrEmpty()) return emptyMap()
171+
val parts = raw.split(',')
172+
if (parts.size % 2 != 0) return emptyMap()
173+
return (parts.indices step 2).associate { parts[it] to (parts[it + 1].toLongOrNull() ?: 0L) }
174+
}
175+
176+
private fun resolveBoolCap(
177+
enabled: Set<String>, disabled: Set<String>, key: String, default: Boolean
178+
): Boolean = when (key) {
179+
in enabled -> true
180+
in disabled -> false
181+
else -> default
182+
}
183+
184+
private fun Boolean.bit(): String = if (this) "1" else "0"
185+
}

0 commit comments

Comments
 (0)