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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
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")
val READER_FONT_SIZE_PX = intPreferencesKey("reader_font_size_px")
}
// Matches the size ReaderWebView hardcoded before this setting existed, so an upgrading
// user's reader looks exactly the same until they deliberately change it.
const val DEFAULT_READER_FONT_SIZE_PX = 18
private const val READER_FONT_SIZE_MIN_PX = 14
private const val READER_FONT_SIZE_MAX_PX = 28
val READER_FONT_SIZE_RANGE_PX = READER_FONT_SIZE_MIN_PX..READER_FONT_SIZE_MAX_PX
/** 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 }
}
val readerFontSizePx: Flow<Int> =
context.settingsDataStore.data.map { it[SettingsKeys.READER_FONT_SIZE_PX] ?: DEFAULT_READER_FONT_SIZE_PX }
suspend fun readerFontSizePxNow(): Int = readerFontSizePx.first()
suspend fun setReaderFontSizePx(px: Int) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.READER_FONT_SIZE_PX] = px.coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last)
}
}
}
|