1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
package org.terst.feedreader.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
val Context.settingsDataStore by preferencesDataStore(name = "settings")
private const val SECURE_PREFS_NAME = "secure_settings"
private const val SECURE_API_KEY = "stash_api_key"
private object SettingsKeys {
val STASH_BASE_URL = stringPreferencesKey("stash_base_url")
// Old plaintext location for the API key -- read once (see migrateLegacyApiKeyIfNeeded)
// to move an existing user's key into encrypted storage, then cleared. Nothing writes
// to this key anymore.
val LEGACY_STASH_API_KEY = stringPreferencesKey("stash_api_key")
val LAST_FETCH_AT = longPreferencesKey("last_fetch_at")
val LAST_FETCH_NEW_ARTICLES = intPreferencesKey("last_fetch_new_articles")
val LAST_ACKNOWLEDGED_FETCH_AT = longPreferencesKey("last_acknowledged_fetch_at")
}
/** Stash connection settings, configured on the Settings screen. Both blank until set. */
class Settings(private val context: Context) {
val stashBaseUrl: Flow<String> =
context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" }
suspend fun stashBaseUrlNow(): String = stashBaseUrl.first()
// The API key is a bearer credential for the user's own Stash server (sent as
// X-API-Key on every StashClient request) -- stored via EncryptedSharedPreferences
// (Android Keystore-backed AES-256), unlike everything else here which lives in plain
// DataStore. Plain DataStore is sandboxed under normal circumstances, but is still
// recoverable in cleartext from a rooted device, an unlocked-bootloader device, or a
// local backup/extraction tool -- letting anyone with that level of access impersonate
// the user against their own server. `by lazy` because creating it touches the
// Keystore, not worth doing until something actually needs the key.
private val securePrefs by lazy {
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
EncryptedSharedPreferences.create(
context,
SECURE_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
suspend fun stashApiKeyNow(): String = withContext(Dispatchers.IO) {
migrateLegacyApiKeyIfNeeded()
securePrefs.getString(SECURE_API_KEY, "") ?: ""
}
// One-time move of a key saved before encrypted storage existed, so an existing user
// doesn't have to re-enter it: read the old plaintext DataStore value if present,
// write it into the encrypted store, then clear the plaintext copy.
private suspend fun migrateLegacyApiKeyIfNeeded() {
val legacy = context.settingsDataStore.data.map { it[SettingsKeys.LEGACY_STASH_API_KEY] }.first()
if (!legacy.isNullOrBlank()) {
securePrefs.edit().putString(SECURE_API_KEY, legacy).apply()
context.settingsDataStore.edit { prefs -> prefs.remove(SettingsKeys.LEGACY_STASH_API_KEY) }
}
}
suspend fun setStash(baseUrl: String, apiKey: String) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.STASH_BASE_URL] = baseUrl.trim().trimEnd('/')
prefs.remove(SettingsKeys.LEGACY_STASH_API_KEY)
}
withContext(Dispatchers.IO) {
securePrefs.edit().putString(SECURE_API_KEY, apiKey.trim()).apply()
}
}
// Durable record of "when did a feed fetch last finish, and with how many new
// articles" -- deliberately NOT derived from WorkManager's WorkInfo.State. A
// PeriodicWorkRequest's state cycles back to ENQUEUED after every successful run
// (it never settles on SUCCEEDED the way a one-time request does), so anything
// watching for SUCCEEDED only ever sees manual/alarm-triggered one-time fetches --
// the actual hourly background fetch is invisible to that approach. Writing the
// result here on every run (periodic or one-time alike) gives the UI one consistent
// signal regardless of which triggered it.
val lastFetchAt: Flow<Long> =
context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_AT] ?: 0L }
val lastFetchNewArticles: Flow<Int> =
context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_NEW_ARTICLES] ?: 0 }
val lastAcknowledgedFetchAt: Flow<Long> =
context.settingsDataStore.data.map { it[SettingsKeys.LAST_ACKNOWLEDGED_FETCH_AT] ?: 0L }
suspend fun recordFetchResult(newArticles: Int) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.LAST_FETCH_AT] = System.currentTimeMillis()
prefs[SettingsKeys.LAST_FETCH_NEW_ARTICLES] = newArticles
}
}
// Persisted (not just in-memory Compose state) so that navigating away from a screen
// and back doesn't forget a fetch was already acknowledged and re-show its snackbar.
suspend fun acknowledgeFetch(at: Long) {
context.settingsDataStore.edit { prefs -> prefs[SettingsKeys.LAST_ACKNOWLEDGED_FETCH_AT] = at }
}
}
|