summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/data/Settings.kt
blob: 2e7eaaea1c952ee018a8b8fbe9964adfb9bb200e (plain)
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 }
    }
}