Skip to content

Commit 52fe9d4

Browse files
committed
refactor: improve preferences serialization and security utility
This commit enhances the robustness of data persistence and optimizes the security infrastructure. ### Serialization & Data - **UserPreferencesSerializer**: Now performs write operations on `Dispatchers.IO`. - **UserPreferencesSerializer**: Replaced silent error recovery with `CorruptionException` when decoding fails. - Cleaned up code by replacing fully qualified names with imports in `AIProviderConfigEntity`, `AppModule`, `HomeScreen`, and `SettingsScreen`. ### Security - **SecurityUtil**: Implemented `SecretKey` caching to reduce Keystore access overhead. - **SecurityUtil**: Introduced constants for `IV_SIZE` (12) and `TAG_SIZE` (128) to replace magic numbers. - **SecurityUtil**: Improved error handling by using `Log.e` instead of `printStackTrace` and returning empty strings on failure to ensure consistent state.
1 parent 779ca69 commit 52fe9d4

6 files changed

Lines changed: 45 additions & 22 deletions

File tree

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

3+
import androidx.datastore.core.CorruptionException
34
import androidx.datastore.core.Serializer
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.withContext
7+
import kotlinx.serialization.ExperimentalSerializationApi
48
import kotlinx.serialization.SerializationException
59
import kotlinx.serialization.protobuf.ProtoBuf
610
import java.io.InputStream
@@ -9,16 +13,19 @@ import java.io.OutputStream
913
object UserPreferencesSerializer : Serializer<UserPreferences> {
1014
override val defaultValue: UserPreferences = UserPreferences()
1115

16+
@OptIn(ExperimentalSerializationApi::class)
1217
override suspend fun readFrom(input: InputStream): UserPreferences {
1318
return try {
1419
ProtoBuf.decodeFromByteArray(UserPreferences.serializer(), input.readBytes())
1520
} catch (exception: SerializationException) {
16-
exception.printStackTrace()
17-
defaultValue
21+
throw CorruptionException("Cannot read ProtoBuf preferences", exception)
1822
}
1923
}
2024

25+
@OptIn(ExperimentalSerializationApi::class)
2126
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
22-
output.write(ProtoBuf.encodeToByteArray(UserPreferences.serializer(), t))
27+
withContext(Dispatchers.IO) {
28+
output.write(ProtoBuf.encodeToByteArray(UserPreferences.serializer(), t))
29+
}
2330
}
2431
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package me.nanova.summaryexpressive.data
22

33
import androidx.room.Entity
44
import androidx.room.PrimaryKey
5+
import me.nanova.summaryexpressive.ProviderConfig
56
import me.nanova.summaryexpressive.util.SecurityUtil
67

78
@Entity(tableName = "ai_provider_config")
@@ -11,16 +12,16 @@ data class AIProviderConfigEntity(
1112
val baseUrl: String,
1213
val model: String
1314
) {
14-
fun toProviderConfig(): me.nanova.summaryexpressive.ProviderConfig {
15-
return me.nanova.summaryexpressive.ProviderConfig(
15+
fun toProviderConfig(): ProviderConfig {
16+
return ProviderConfig(
1617
apiKey = SecurityUtil.decrypt(apiKey),
1718
baseUrl = baseUrl,
1819
model = model
1920
)
2021
}
2122

2223
companion object {
23-
fun fromProviderConfig(provider: String, config: me.nanova.summaryexpressive.ProviderConfig): AIProviderConfigEntity {
24+
fun fromProviderConfig(provider: String, config: ProviderConfig): AIProviderConfigEntity {
2425
return AIProviderConfigEntity(
2526
provider = provider,
2627
apiKey = SecurityUtil.encrypt(config.apiKey),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage
1414
import io.ktor.client.plugins.cookies.HttpCookies
1515
import io.ktor.serialization.kotlinx.json.json
1616
import me.nanova.summaryexpressive.UserPreferencesRepository
17+
import me.nanova.summaryexpressive.data.AIProviderConfigDao
1718
import me.nanova.summaryexpressive.data.AppDatabase
1819
import me.nanova.summaryexpressive.data.HistoryDao
1920
import me.nanova.summaryexpressive.data.HistoryRepository
@@ -58,7 +59,7 @@ object AppModule {
5859

5960
@Provides
6061
@Singleton
61-
fun provideAIProviderConfigDao(appDatabase: AppDatabase): me.nanova.summaryexpressive.data.AIProviderConfigDao {
62+
fun provideAIProviderConfigDao(appDatabase: AppDatabase): AIProviderConfigDao {
6263
return appDatabase.aiProviderConfigDao()
6364
}
6465

app/src/main/kotlin/me/nanova/summaryexpressive/ui/page/HomeScreen.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.ContentValues
55
import android.net.Uri
66
import android.os.Environment
77
import android.provider.MediaStore
8+
import android.widget.Toast
89
import androidx.activity.compose.BackHandler
910
import androidx.activity.compose.rememberLauncherForActivityResult
1011
import androidx.activity.result.contract.ActivityResultContracts
@@ -521,7 +522,11 @@ fun ProviderModelSheetContent(
521522
modifier = Modifier
522523
.matchParentSize()
523524
.clickable {
524-
android.widget.Toast.makeText(context, "Please go to settings page to configure before use", android.widget.Toast.LENGTH_SHORT).show()
525+
Toast.makeText(
526+
context,
527+
"Please go to settings page to configure before use",
528+
Toast.LENGTH_SHORT
529+
).show()
525530
}
526531
)
527532
}

app/src/main/kotlin/me/nanova/summaryexpressive/ui/page/SettingsScreen.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import androidx.compose.ui.unit.dp
104104
import androidx.core.net.toUri
105105
import kotlinx.coroutines.launch
106106
import me.nanova.summaryexpressive.BuildConfig
107+
import me.nanova.summaryexpressive.ProviderConfig
107108
import me.nanova.summaryexpressive.R
108109
import me.nanova.summaryexpressive.llm.AIProvider
109110
import me.nanova.summaryexpressive.ui.Nav
@@ -762,7 +763,7 @@ private fun ThemeSettingsDialog(
762763
private fun AIProviderSettingsDialog(
763764
onDismissRequest: () -> Unit,
764765
initialProvider: AIProvider,
765-
providerConfigs: Map<String, me.nanova.summaryexpressive.ProviderConfig>,
766+
providerConfigs: Map<String, ProviderConfig>,
766767
onConfirm: (provider: AIProvider, baseUrl: String, apiKey: String) -> Unit,
767768
onNext: (provider: AIProvider, baseUrl: String, apiKey: String) -> Unit,
768769
) {

app/src/main/kotlin/me/nanova/summaryexpressive/util/SecurityUtil.kt

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package me.nanova.summaryexpressive.util
33
import android.security.keystore.KeyGenParameterSpec
44
import android.security.keystore.KeyProperties
55
import android.util.Base64
6+
import android.util.Log
67
import java.security.KeyStore
78
import javax.crypto.Cipher
89
import javax.crypto.KeyGenerator
@@ -13,23 +14,30 @@ object SecurityUtil {
1314
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
1415
private const val KEY_ALIAS = "summary_expressive_api_key_alias"
1516
private const val TRANSFORMATION = "AES/GCM/NoPadding"
17+
private const val IV_SIZE = 12
18+
private const val TAG_SIZE = 128
1619

20+
private var cachedKey: SecretKey? = null
21+
22+
@Synchronized
1723
private fun getSecretKey(): SecretKey {
18-
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
19-
keyStore.load(null)
24+
cachedKey?.let { return it }
25+
26+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
2027
if (!keyStore.containsAlias(KEY_ALIAS)) {
2128
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
22-
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
29+
val spec = KeyGenParameterSpec.Builder(
2330
KEY_ALIAS,
2431
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
2532
)
2633
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
2734
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
2835
.build()
29-
keyGenerator.init(keyGenParameterSpec)
36+
keyGenerator.init(spec)
3037
keyGenerator.generateKey()
3138
}
32-
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
39+
40+
return (keyStore.getKey(KEY_ALIAS, null) as SecretKey).also { cachedKey = it }
3341
}
3442

3543
fun encrypt(plainText: String): String {
@@ -44,26 +52,26 @@ object SecurityUtil {
4452
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
4553
Base64.encodeToString(combined, Base64.DEFAULT)
4654
} catch (e: Exception) {
47-
e.printStackTrace()
48-
plainText
55+
Log.e("SecurityUtil", "Encryption failed", e)
56+
""
4957
}
5058
}
5159

5260
fun decrypt(encryptedText: String): String {
5361
if (encryptedText.isEmpty()) return ""
5462
return try {
5563
val combined = Base64.decode(encryptedText, Base64.DEFAULT)
56-
if (combined.size < 12) return encryptedText // Not encrypted or corrupted
64+
if (combined.size < IV_SIZE) return encryptedText // Not encrypted or corrupted
5765
val cipher = Cipher.getInstance(TRANSFORMATION)
58-
val iv = combined.copyOfRange(0, 12)
59-
val encryptedBytes = combined.copyOfRange(12, combined.size)
60-
val spec = GCMParameterSpec(128, iv)
66+
val iv = combined.copyOfRange(0, IV_SIZE)
67+
val encryptedBytes = combined.copyOfRange(IV_SIZE, combined.size)
68+
val spec = GCMParameterSpec(TAG_SIZE, iv)
6169
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
6270
val decryptedBytes = cipher.doFinal(encryptedBytes)
6371
String(decryptedBytes, Charsets.UTF_8)
6472
} catch (e: Exception) {
65-
e.printStackTrace()
66-
encryptedText
73+
Log.e("SecurityUtil", "Decryption failed", e)
74+
""
6775
}
6876
}
6977
}

0 commit comments

Comments
 (0)