Prefs.kt
⎇
Raw
1package net.lexcom.opentracker.store
2
3import android.content.Context
4import androidx.core.content.edit
5import java.util.Base64
6
7/**
8 * The one persistent record this app keeps: the device's [Credentials].
9 *
10 * The server issues them exactly once, so losing this file costs the user a
11 * re-login. `TrackerService` can be restarted by the system with a null Intent,
12 * which is why the credentials have to come from here and not from an extra.
13 *
14 * Plain [android.content.SharedPreferences] in `MODE_PRIVATE`. The file lives in
15 * the app's private data directory, which only this uid can read. Backup and
16 * device-to-device transfer are shut off by `android:allowBackup="false"` in the
17 * manifest, for the reason stated there: restoring the token key onto a second
18 * device would silently clone a credential. That is also why `DataExtractionRules`
19 * is disabled in lint rather than answered with an XML file.
20 *
21 * Deliberately not `EncryptedSharedPreferences`: it is a new dependency
22 * (`androidx.security`), it is deprecated, and it protects against an attacker
23 * who has already read the private data directory, at which point the game is
24 * over anyway.
25 */
26class Prefs(context: Context) {
27
28 private val sp = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
29
30 /**
31 * Returns null unless every field reads back intact.
32 *
33 * A half-written or hand-edited record must not produce a [Credentials] with
34 * a 31-byte key. That would fail far away from here, as datagrams the server
35 * silently drops, instead of as a login prompt.
36 */
37 fun load(): Credentials? {
38 if (!sp.contains(KEY_TOKEN_ID)) return null
39 val tokenKey = decodeStoredKey(sp.getString(KEY_TOKEN_KEY, null)) ?: return null
40 val kRev = decodeStoredKey(sp.getString(KEY_REVOKE_KEY, null)) ?: return null
41 val host = sp.getString(KEY_UDP_HOST, null) ?: return null
42 val port = sp.getInt(KEY_UDP_PORT, 0)
43 if (host.isEmpty() || port !in 1..65535) return null
44 return Credentials(
45 // A Long round-trips exactly here, unlike through JSON.
46 tokenId = sp.getLong(KEY_TOKEN_ID, 0),
47 tokenKey = tokenKey,
48 kRev = kRev,
49 udpHost = host,
50 udpPort = port,
51 tlsUrl = sp.getString(KEY_TLS_URL, null),
52 configVersion = sp.getInt(KEY_CONFIG_VERSION, 0),
53 )
54 }
55
56 /** `commit()`, not `apply()`: this runs once per login and must survive a kill. */
57 fun save(c: Credentials) {
58 sp.edit(commit = true) {
59 putLong(KEY_TOKEN_ID, c.tokenId)
60 putString(KEY_TOKEN_KEY, encodeStoredKey(c.tokenKey))
61 putString(KEY_REVOKE_KEY, encodeStoredKey(c.kRev))
62 putString(KEY_UDP_HOST, c.udpHost)
63 putInt(KEY_UDP_PORT, c.udpPort)
64 putString(KEY_TLS_URL, c.tlsUrl)
65 putInt(KEY_CONFIG_VERSION, c.configVersion)
66 }
67 }
68
69 /**
70 * Whether the user wants to be tracked, as opposed to whether the service
71 * happens to be running right now.
72 *
73 * Without this flag a receiver cannot tell "the user tapped Stop" from "the
74 * system killed us", and those two need opposite answers. BootReceiver and
75 * WatchdogReceiver read it to decide whether restarting is wanted at all.
76 *
77 * Default false, so a fresh install never starts tracking on its own.
78 */
79 fun isTrackingEnabled(): Boolean = sp.getBoolean(KEY_TRACKING_ENABLED, false)
80
81 /** `commit()` for the same reason as [save]: the next reader may be a
82 * receiver in a process the system is about to kill. */
83 fun setTrackingEnabled(on: Boolean) {
84 sp.edit(commit = true) { putBoolean(KEY_TRACKING_ENABLED, on) }
85 }
86
87 /**
88 * Removes the keys, not just a flag.
89 *
90 * A `REVOKED` notice calls this. Leaving the token key on disk after it would
91 * keep a dead credential around for anyone who later gets the file.
92 *
93 * This wipes [KEY_TRACKING_ENABLED] too, because it clears the whole file.
94 * That is wanted: without credentials there is nothing to restart.
95 */
96 fun clear() {
97 sp.edit(commit = true) { clear() }
98 }
99
100 private companion object {
101 const val FILE = "credentials"
102 const val KEY_TOKEN_ID = "token_id"
103 const val KEY_TOKEN_KEY = "token_key"
104 const val KEY_REVOKE_KEY = "revoke_key"
105 const val KEY_UDP_HOST = "udp_host"
106 const val KEY_UDP_PORT = "udp_port"
107 const val KEY_TLS_URL = "tls_url"
108 const val KEY_CONFIG_VERSION = "config_version"
109 const val KEY_TRACKING_ENABLED = "tracking_enabled"
110 }
111}
112
113internal fun encodeStoredKey(key: ByteArray): String =
114 Base64.getEncoder().encodeToString(key)
115
116/** Null for missing, unparseable, or wrong-length input. See [Prefs.load]. */
117internal fun decodeStoredKey(b64: String?): ByteArray? =
118 b64?.let { runCatching { decodeKey(it, "stored key") }.getOrNull() }
119