|
| 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