Skip to content

Commit 24b4046

Browse files
committed
feat: migrate settings to ProtoBuf, secure API keys, and add provider tracking
This commit performs a major refactor of the settings and history persistence layers, moving from a single JSON-based DataStore to a hybrid approach using Typed DataStore (ProtoBuf) for general preferences and Room for encrypted provider configurations. ### Build & Dependencies - Bumped `androidx.compose.material3` to `1.5.0-alpha25`. - Bumped `io.ktor:ktor-client-android` to `3.5.2`. - Added `androidx.datastore:datastore` and `kotlinx-serialization-protobuf`. ### Data & Persistence - **Storage Migration**: Migrated `UserPreferences` from `PreferencesDataStore` (JSON) to Typed `DataStore` using ProtoBuf for better performance and type safety. - **Room Database (v2)**: - Added `AIProviderConfigEntity` to store provider-specific settings (API keys, base URLs, models) in SQL. - Added a migration (1 -> 2) to create the `ai_provider_config` table and add `provider`/`model` columns to the history table. - **Security**: Introduced `SecurityUtil` using Android KeyStore (AES/GCM) to encrypt and decrypt API keys before persisting them to the database. - **Legacy Migration**: Implemented logic in `AppViewModel` to transparently migrate existing settings from the old DataStore to the new schema. ### UI Components - **ProviderIndicator**: Added a new reusable component to display the AI provider icon and a short model name badge. - **Summary Tracking**: Summaries now display which AI provider and model were used to generate them in both the `HomeScreen` and `HistoryScreen`. - **UI Improvements**: - Updated `SwipeToDismissBox` usage to align with newer Material3 APIs. - Refactored the provider selection bottom sheet to gray out unconfigured providers and provide feedback via Toast. - Improved `SummaryCard` layout to accommodate provider information. ### Logic - Refactored `AppViewModel` to use a `combine` flow, merging general preferences from DataStore with configuration entities from Room. - Updated `SummaryViewModel` to persist the specific provider and model used when saving a summary to history. - Simplified `UserPreferencesRepository` by removing provider-specific configuration logic, now handled by `AIProviderConfigDao`.
1 parent afbe83d commit 24b4046

16 files changed

Lines changed: 512 additions & 188 deletions

app/build.gradle.kts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,15 @@ dependencies {
133133
implementation("androidx.compose.material:material-icons-extended")
134134
// Keep alpha override for material expressive features, as intended
135135
// https://developer.android.com/jetpack/androidx/releases/compose-material3#compose_material3_version_15_2
136-
implementation("androidx.compose.material3:material3:1.5.0-alpha24")
136+
implementation("androidx.compose.material3:material3:1.5.0-alpha25")
137137

138138
// Paging
139139
implementation("androidx.paging:paging-compose:3.5.0")
140140
implementation("androidx.paging:paging-runtime-ktx:3.5.0")
141141

142142
// Data Persistence
143143
implementation("androidx.datastore:datastore-preferences:1.2.1")
144+
implementation("androidx.datastore:datastore:1.2.1")
144145
implementation("androidx.room:room-runtime:${roomVersion}")
145146
implementation("androidx.room:room-paging:${roomVersion}")
146147
implementation("androidx.room:room-ktx:${roomVersion}")
@@ -172,10 +173,11 @@ dependencies {
172173
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0")
173174

174175
// Networking
175-
implementation("io.ktor:ktor-client-android:3.5.1")
176+
implementation("io.ktor:ktor-client-android:3.5.2")
176177

177178
// Serialization & Utilities
178179
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
180+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.11.0")
179181
implementation("org.jsoup:jsoup:1.23.1")
180182
implementation("io.coil-kt:coil-compose:2.7.0")
181183
implementation("io.coil-kt:coil-gif:2.7.0")

app/src/main/kotlin/me/nanova/summaryexpressive/UserPreferencesRepository.kt

Lines changed: 55 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,25 @@ package me.nanova.summaryexpressive
22

33
import android.content.Context
44
import androidx.datastore.core.DataStore
5+
import androidx.datastore.dataStore
56
import androidx.datastore.preferences.core.Preferences
6-
import androidx.datastore.preferences.core.edit
7-
import androidx.datastore.preferences.core.emptyPreferences
87
import androidx.datastore.preferences.core.stringPreferencesKey
98
import androidx.datastore.preferences.preferencesDataStore
109
import kotlinx.coroutines.flow.Flow
1110
import kotlinx.coroutines.flow.catch
12-
import kotlinx.coroutines.flow.map
11+
import kotlinx.coroutines.flow.firstOrNull
1312
import kotlinx.serialization.Serializable
1413
import kotlinx.serialization.json.Json
1514
import me.nanova.summaryexpressive.llm.AIProvider
1615
import me.nanova.summaryexpressive.llm.SummaryLength
1716
import java.io.IOException
1817

19-
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
18+
val Context.dataStore: DataStore<UserPreferences> by dataStore(
19+
fileName = "user_prefs.pb",
20+
serializer = UserPreferencesSerializer
21+
)
22+
23+
private val Context.legacyDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
2024

2125
@Serializable
2226
data class ProviderConfig(
@@ -26,10 +30,8 @@ data class ProviderConfig(
2630
)
2731

2832
@Serializable
29-
data class UserPreferences(
30-
// state
33+
data class LegacyUserPreferences(
3134
val isOnboarded: Boolean = false,
32-
// settings
3335
val useOriginalLanguage: Boolean = true,
3436
val dynamicColor: Boolean = true,
3537
val theme: Int = 0,
@@ -40,59 +42,68 @@ data class UserPreferences(
4042
val autoExtractUrl: Boolean = true,
4143
val sessData: String = "",
4244
val sessDataExpires: Long = 0L,
43-
// legacy fields for migration
4445
val baseUrl: String = "",
4546
val apiKey: String = "",
4647
val model: String = "",
4748
)
4849

49-
class UserPreferencesRepository(private val context: Context) {
50-
private val userPreferencesKey = stringPreferencesKey("user_preferences")
50+
@Serializable
51+
data class UserPreferences(
52+
// state
53+
val isOnboarded: Boolean = false,
54+
// settings
55+
val useOriginalLanguage: Boolean = true,
56+
val dynamicColor: Boolean = true,
57+
val theme: Int = 0,
58+
val aiProvider: String = AIProvider.OPENAI.name,
59+
val showLength: Boolean = true,
60+
val summaryLength: String = SummaryLength.MEDIUM.name,
61+
val autoExtractUrl: Boolean = true,
62+
val sessData: String = "",
63+
val sessDataExpires: Long = 0L,
64+
val hasMigratedFromLegacy: Boolean = false
65+
)
5166

67+
class UserPreferencesRepository(private val context: Context) {
5268
val preferencesFlow: Flow<UserPreferences> = context.dataStore.data
5369
.catch { exception ->
5470
if (exception is IOException) {
55-
emit(emptyPreferences())
71+
emit(UserPreferences())
5672
} else {
5773
throw exception
5874
}
59-
}.map { preferences ->
60-
preferences[userPreferencesKey]?.let { jsonString ->
61-
runCatching { Json.decodeFromString<UserPreferences>(jsonString) }.getOrNull()
62-
} ?: UserPreferences()
6375
}
6476

65-
private suspend fun updatePreferences(transform: (UserPreferences) -> UserPreferences) {
66-
context.dataStore.edit { preferences ->
67-
val currentPreferencesJson = preferences[userPreferencesKey]
68-
val currentPreferences = currentPreferencesJson?.let {
69-
runCatching { Json.decodeFromString<UserPreferences>(it) }.getOrNull()
70-
} ?: UserPreferences()
71-
val newPreferences = transform(currentPreferences)
72-
preferences[userPreferencesKey] = Json.encodeToString(newPreferences)
73-
}
77+
private suspend fun updatePreferences(transform: suspend (UserPreferences) -> UserPreferences) {
78+
context.dataStore.updateData { transform(it) }
7479
}
7580

76-
suspend fun migrateLegacyFields() {
77-
updatePreferences { userPrefs ->
78-
if (userPrefs.apiKey.isNotEmpty() || userPrefs.baseUrl.isNotEmpty() || userPrefs.model.isNotEmpty()) {
79-
val currentConfig = userPrefs.providerConfigs[userPrefs.aiProvider] ?: ProviderConfig()
80-
val updatedConfig = currentConfig.copy(
81-
apiKey = userPrefs.apiKey.takeIf { it.isNotEmpty() } ?: currentConfig.apiKey,
82-
baseUrl = userPrefs.baseUrl.takeIf { it.isNotEmpty() } ?: currentConfig.baseUrl,
83-
model = userPrefs.model.takeIf { it.isNotEmpty() } ?: currentConfig.model
84-
)
85-
val newConfigs = userPrefs.providerConfigs.toMutableMap()
86-
newConfigs[userPrefs.aiProvider] = updatedConfig
87-
userPrefs.copy(
88-
providerConfigs = newConfigs,
89-
apiKey = "",
90-
baseUrl = "",
91-
model = ""
92-
)
93-
} else {
94-
userPrefs
95-
}
81+
suspend fun getLegacyPreferences(): LegacyUserPreferences? {
82+
val userPreferencesKey = stringPreferencesKey("user_preferences")
83+
val prefs = context.legacyDataStore.data.firstOrNull() ?: return null
84+
val jsonString = prefs[userPreferencesKey] ?: return null
85+
return runCatching { Json { ignoreUnknownKeys = true }.decodeFromString<LegacyUserPreferences>(jsonString) }.getOrNull()
86+
}
87+
88+
suspend fun markMigratedFromLegacy() {
89+
updatePreferences { it.copy(hasMigratedFromLegacy = true) }
90+
}
91+
92+
suspend fun updateFromLegacy(legacy: LegacyUserPreferences) {
93+
updatePreferences {
94+
it.copy(
95+
isOnboarded = legacy.isOnboarded,
96+
useOriginalLanguage = legacy.useOriginalLanguage,
97+
dynamicColor = legacy.dynamicColor,
98+
theme = legacy.theme,
99+
aiProvider = legacy.aiProvider,
100+
showLength = legacy.showLength,
101+
summaryLength = legacy.summaryLength,
102+
autoExtractUrl = legacy.autoExtractUrl,
103+
sessData = legacy.sessData,
104+
sessDataExpires = legacy.sessDataExpires,
105+
hasMigratedFromLegacy = true
106+
)
96107
}
97108
}
98109

@@ -104,32 +115,8 @@ class UserPreferencesRepository(private val context: Context) {
104115

105116
suspend fun setTheme(value: Int) = updatePreferences { it.copy(theme = value) }
106117

107-
private suspend fun updateProviderConfig(transform: (ProviderConfig) -> ProviderConfig) {
108-
updatePreferences { prefs ->
109-
val currentConfig = prefs.providerConfigs[prefs.aiProvider] ?: ProviderConfig()
110-
val newConfigs = prefs.providerConfigs.toMutableMap()
111-
newConfigs[prefs.aiProvider] = transform(currentConfig)
112-
prefs.copy(providerConfigs = newConfigs)
113-
}
114-
}
115-
116-
suspend fun setProviderConfig(provider: String, baseUrl: String, apiKey: String) {
117-
updatePreferences { prefs ->
118-
val currentConfig = prefs.providerConfigs[provider] ?: ProviderConfig()
119-
val newConfigs = prefs.providerConfigs.toMutableMap()
120-
newConfigs[provider] = currentConfig.copy(baseUrl = baseUrl, apiKey = apiKey)
121-
prefs.copy(providerConfigs = newConfigs, aiProvider = provider)
122-
}
123-
}
124-
125-
suspend fun setBaseUrl(value: String) = updateProviderConfig { it.copy(baseUrl = value) }
126-
127-
suspend fun setApiKey(value: String) = updateProviderConfig { it.copy(apiKey = value) }
128-
129118
suspend fun setAIProvider(value: String) = updatePreferences { it.copy(aiProvider = value) }
130119

131-
suspend fun setModel(value: String) = updateProviderConfig { it.copy(model = value) }
132-
133120
suspend fun setIsOnboarded(value: Boolean) =
134121
updatePreferences { it.copy(isOnboarded = value) }
135122

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package me.nanova.summaryexpressive
2+
3+
import androidx.datastore.core.Serializer
4+
import kotlinx.serialization.SerializationException
5+
import kotlinx.serialization.protobuf.ProtoBuf
6+
import java.io.InputStream
7+
import java.io.OutputStream
8+
9+
object UserPreferencesSerializer : Serializer<UserPreferences> {
10+
override val defaultValue: UserPreferences = UserPreferences()
11+
12+
override suspend fun readFrom(input: InputStream): UserPreferences {
13+
return try {
14+
ProtoBuf.decodeFromByteArray(UserPreferences.serializer(), input.readBytes())
15+
} catch (exception: SerializationException) {
16+
exception.printStackTrace()
17+
defaultValue
18+
}
19+
}
20+
21+
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
22+
output.write(ProtoBuf.encodeToByteArray(UserPreferences.serializer(), t))
23+
}
24+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package me.nanova.summaryexpressive.data
2+
3+
import androidx.room.Dao
4+
import androidx.room.Insert
5+
import androidx.room.OnConflictStrategy
6+
import androidx.room.Query
7+
import kotlinx.coroutines.flow.Flow
8+
9+
@Dao
10+
interface AIProviderConfigDao {
11+
@Query("SELECT * FROM ai_provider_config WHERE provider = :provider")
12+
fun getConfigFlow(provider: String): Flow<AIProviderConfigEntity?>
13+
14+
@Query("SELECT * FROM ai_provider_config WHERE provider = :provider")
15+
suspend fun getConfig(provider: String): AIProviderConfigEntity?
16+
17+
@Query("SELECT * FROM ai_provider_config")
18+
fun getAllConfigsFlow(): Flow<List<AIProviderConfigEntity>>
19+
20+
@Query("SELECT * FROM ai_provider_config")
21+
suspend fun getAllConfigs(): List<AIProviderConfigEntity>
22+
23+
@Insert(onConflict = OnConflictStrategy.REPLACE)
24+
suspend fun insertConfig(config: AIProviderConfigEntity)
25+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package me.nanova.summaryexpressive.data
2+
3+
import androidx.room.Entity
4+
import androidx.room.PrimaryKey
5+
import me.nanova.summaryexpressive.util.SecurityUtil
6+
7+
@Entity(tableName = "ai_provider_config")
8+
data class AIProviderConfigEntity(
9+
@PrimaryKey val provider: String,
10+
val apiKey: String,
11+
val baseUrl: String,
12+
val model: String
13+
) {
14+
fun toProviderConfig(): me.nanova.summaryexpressive.ProviderConfig {
15+
return me.nanova.summaryexpressive.ProviderConfig(
16+
apiKey = SecurityUtil.decrypt(apiKey),
17+
baseUrl = baseUrl,
18+
model = model
19+
)
20+
}
21+
22+
companion object {
23+
fun fromProviderConfig(provider: String, config: me.nanova.summaryexpressive.ProviderConfig): AIProviderConfigEntity {
24+
return AIProviderConfigEntity(
25+
provider = provider,
26+
apiKey = SecurityUtil.encrypt(config.apiKey),
27+
baseUrl = config.baseUrl,
28+
model = config.model
29+
)
30+
}
31+
}
32+
}

app/src/main/kotlin/me/nanova/summaryexpressive/data/AppDatabase.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import androidx.room.Database
44
import androidx.room.RoomDatabase
55
import me.nanova.summaryexpressive.model.HistorySummary
66

7-
@Database(entities = [HistorySummary::class], version = 1, exportSchema = false)
7+
@Database(entities = [HistorySummary::class, AIProviderConfigEntity::class], version = 2, exportSchema = false)
88
abstract class AppDatabase : RoomDatabase() {
99
abstract fun historyDao(): HistoryDao
10+
abstract fun aiProviderConfigDao(): AIProviderConfigDao
1011
}

app/src/main/kotlin/me/nanova/summaryexpressive/di/AppModule.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,21 @@ object AppModule {
3333
@Provides
3434
@Singleton
3535
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
36+
val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) {
37+
override fun migrate(database: androidx.sqlite.db.SupportSQLiteDatabase) {
38+
database.execSQL("ALTER TABLE history ADD COLUMN provider TEXT DEFAULT NULL")
39+
database.execSQL("ALTER TABLE history ADD COLUMN model TEXT DEFAULT NULL")
40+
database.execSQL("CREATE TABLE IF NOT EXISTS `ai_provider_config` (`provider` TEXT NOT NULL, `apiKey` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `model` TEXT NOT NULL, PRIMARY KEY(`provider`))")
41+
}
42+
}
43+
3644
return Room.databaseBuilder(
3745
context,
3846
AppDatabase::class.java,
3947
"summary_expressive_db"
40-
).build()
48+
)
49+
.addMigrations(MIGRATION_1_2)
50+
.build()
4151
}
4252

4353
@Provides
@@ -46,6 +56,12 @@ object AppModule {
4656
return appDatabase.historyDao()
4757
}
4858

59+
@Provides
60+
@Singleton
61+
fun provideAIProviderConfigDao(appDatabase: AppDatabase): me.nanova.summaryexpressive.data.AIProviderConfigDao {
62+
return appDatabase.aiProviderConfigDao()
63+
}
64+
4965
@Provides
5066
@Singleton
5167
fun provideHistoryRepository(historyDao: HistoryDao): HistoryRepository {

app/src/main/kotlin/me/nanova/summaryexpressive/llm/LLMHandler.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ data class SummaryOutput(
6565
val isYoutubeLink: Boolean,
6666
val isBiliBiliLink: Boolean,
6767
val length: SummaryLength,
68+
val provider: String? = null,
69+
val model: String? = null,
6870
) : SummaryData
6971

7072
class LLMHandler(context: Context, private val httpClient: HttpClient) {

app/src/main/kotlin/me/nanova/summaryexpressive/model/HistorySummary.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package me.nanova.summaryexpressive.model
22

3+
import androidx.room.ColumnInfo
34
import androidx.room.Entity
45
import androidx.room.PrimaryKey
56
import androidx.room.TypeConverters
@@ -29,6 +30,10 @@ data class HistorySummary(
2930
val subtype: VideoSubtype? = null,
3031
val sourceLink: String? = null,
3132
val sourceText: String? = null,
33+
@ColumnInfo(defaultValue = "NULL")
34+
val provider: String? = null,
35+
@ColumnInfo(defaultValue = "NULL")
36+
val model: String? = null,
3237
) {
3338
val isYoutubeLink: Boolean
3439
get() = type == SummaryType.VIDEO && subtype == VideoSubtype.YOUTUBE

0 commit comments

Comments
 (0)