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 import kotlin.math.roundToInt 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 // Pure so the pinch-to-zoom math is testable without a WebView/gesture detector in the loop -- // ReaderWebView.kt calls this once per gesture (at pinch-end, with the cumulative scale factor // accumulated across the whole gesture), not per touch-move frame. fun pinchZoomFontSizePx(basePx: Int, scaleFactor: Float): Int = (basePx * scaleFactor).roundToInt().coerceIn(READER_FONT_SIZE_RANGE_PX.first, READER_FONT_SIZE_RANGE_PX.last) /** Stash connection settings, configured on the Settings screen. Both blank until set. */ class Settings(private val context: Context) { val stashBaseUrl: Flow = 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 = context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_AT] ?: 0L } val lastFetchNewArticles: Flow = context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_NEW_ARTICLES] ?: 0 } val lastAcknowledgedFetchAt: Flow = 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 = 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) } } }