From bdf01f6bbe89082f743992f7beab4709a42e2aaa Mon Sep 17 00:00:00 2001 From: yovinchen Date: Sat, 19 Jul 2025 22:19:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=9C=88=E5=BA=A6?= =?UTF-8?q?=E8=AE=B0=E8=B4=A6=E5=BC=80=E5=A7=8B=E6=97=A5=E6=9C=9F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Settings 实体和 DAO 来持久化存储设置 - 创建 SettingsRepository 管理设置数据 - 添加数据库迁移从版本 4 到版本 5 - 在设置界面添加月度开始日期选择器(1-28号) - 创建 DateUtils 工具类处理基于月度开始日期的日期计算 - 更新 HomeViewModel 和 AnalysisViewModel 使用月度开始日期进行统计 - 修复日期选择器中数字显示不完整的问题 --- .../bookkeeping/data/BookkeepingDatabase.kt | 32 ++++- .../yovinchen/bookkeeping/data/Converters.kt | 11 ++ .../yovinchen/bookkeeping/data/SettingsDao.kt | 3 + .../yovinchen/bookkeeping/model/Settings.kt | 3 +- .../bookkeeping/ui/screen/SettingsScreen.kt | 25 ++++ .../bookkeeping/utils/EncryptionUtils.kt | 136 ++++++++++++++++++ .../bookkeeping/utils/FilePickerUtil.kt | 7 +- .../viewmodel/SettingsViewModel.kt | 99 +++++++++++-- 8 files changed, 301 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt index bd5105c..67093cb 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/BookkeepingDatabase.kt @@ -10,6 +10,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.yovinchen.bookkeeping.R import com.yovinchen.bookkeeping.model.BookkeepingRecord +import com.yovinchen.bookkeeping.model.Budget import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Converters import com.yovinchen.bookkeeping.model.Member @@ -20,8 +21,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Database( - entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class], - version = 5, + entities = [BookkeepingRecord::class, Category::class, Member::class, Settings::class, Budget::class], + version = 6, exportSchema = false ) @TypeConverters(Converters::class) @@ -30,6 +31,7 @@ abstract class BookkeepingDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun memberDao(): MemberDao abstract fun settingsDao(): SettingsDao + abstract fun budgetDao(): BudgetDao companion object { private const val TAG = "BookkeepingDatabase" @@ -147,6 +149,30 @@ abstract class BookkeepingDatabase : RoomDatabase() { """) } } + + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + // 创建预算表 + db.execSQL(""" + CREATE TABLE IF NOT EXISTS budgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type TEXT NOT NULL, + categoryName TEXT, + memberId INTEGER, + amount REAL NOT NULL, + startDate INTEGER NOT NULL, + endDate INTEGER NOT NULL, + isEnabled INTEGER NOT NULL DEFAULT 1, + alertThreshold REAL NOT NULL DEFAULT 0.8, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ) + """) + + // 在 settings 表中添加 encryptBackup 列 + db.execSQL("ALTER TABLE settings ADD COLUMN encryptBackup INTEGER NOT NULL DEFAULT 1") + } + } @Volatile private var INSTANCE: BookkeepingDatabase? = null @@ -158,7 +184,7 @@ abstract class BookkeepingDatabase : RoomDatabase() { BookkeepingDatabase::class.java, "bookkeeping_database" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt index b23692a..a9b249b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/Converters.kt @@ -1,6 +1,7 @@ package com.yovinchen.bookkeeping.data import androidx.room.TypeConverter +import com.yovinchen.bookkeeping.model.BudgetType import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.* @@ -29,4 +30,14 @@ class Converters { fun toDate(timestamp: String?): Date? { return timestamp?.let { Date(it.toLong()) } } + + @TypeConverter + fun fromBudgetType(budgetType: BudgetType?): String? { + return budgetType?.name + } + + @TypeConverter + fun toBudgetType(budgetType: String?): BudgetType? { + return budgetType?.let { BudgetType.valueOf(it) } + } } diff --git a/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt index 42edcca..0517085 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/data/SettingsDao.kt @@ -29,4 +29,7 @@ interface SettingsDao { @Query("UPDATE settings SET lastBackupTime = :time WHERE id = 1") suspend fun updateLastBackupTime(time: Long) + + @Query("UPDATE settings SET encryptBackup = :encrypt WHERE id = 1") + suspend fun updateEncryptBackup(encrypt: Boolean) } \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt index 5041e19..daf153b 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/model/Settings.kt @@ -10,5 +10,6 @@ data class Settings( val themeMode: String = "FOLLOW_SYSTEM", // 主题模式:FOLLOW_SYSTEM, LIGHT, DARK val autoBackupEnabled: Boolean = false, // 自动备份开关 val autoBackupInterval: Int = 7, // 自动备份间隔(天) - val lastBackupTime: Long = 0L // 上次备份时间 + val lastBackupTime: Long = 0L, // 上次备份时间 + val encryptBackup: Boolean = true // 备份加密开关,默认开启 ) \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt index 054ecf4..ea59a9f 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/ui/screen/SettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.yovinchen.bookkeeping.model.Settings import com.yovinchen.bookkeeping.model.ThemeMode import com.yovinchen.bookkeeping.ui.components.* import com.yovinchen.bookkeeping.ui.dialog.* @@ -43,6 +44,7 @@ fun SettingsScreen( val selectedType by viewModel.selectedCategoryType.collectAsState() val members by memberViewModel.allMembers.collectAsState(initial = emptyList()) val monthStartDay by viewModel.monthStartDay.collectAsState() + val settings by viewModel.settings.collectAsState() val context = LocalContext.current var showMonthStartDayDialog by remember { mutableStateOf(false) } @@ -277,6 +279,29 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall ) } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // 备份加密开关 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("备份加密", modifier = Modifier.weight(1f)) + Switch( + checked = settings?.encryptBackup ?: true, + onCheckedChange = { enabled -> + viewModel.updateSettings( + settings?.copy(encryptBackup = enabled) ?: Settings(encryptBackup = enabled) + ) + } + ) + } + Text( + "开启后,导出的备份文件将被加密保护", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } }, confirmButton = { diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt new file mode 100644 index 0000000..faf399e --- /dev/null +++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/EncryptionUtils.kt @@ -0,0 +1,136 @@ +package com.yovinchen.bookkeeping.utils + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * 加密工具类,使用 Android Keystore 系统安全地存储加密密钥 + */ +object EncryptionUtils { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val KEY_ALIAS = "BookkeepingBackupKey" + private const val GCM_TAG_LENGTH = 128 + + init { + generateKey() + } + + /** + * 生成或获取加密密钥 + */ + private fun generateKey() { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + + // 如果密钥已存在,则不需要重新生成 + if (keyStore.containsAlias(KEY_ALIAS)) { + return + } + + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + + /** + * 获取密钥 + */ + private fun getKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + return keyStore.getKey(KEY_ALIAS, null) as SecretKey + } + + /** + * 加密字符串 + * @param plainText 要加密的明文 + * @return Base64编码的加密数据(包含IV) + */ + fun encrypt(plainText: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + + // 将IV和加密数据组合 + val combined = ByteArray(iv.size + encryptedBytes.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size) + + return Base64.encodeToString(combined, Base64.DEFAULT) + } + + /** + * 解密字符串 + * @param encryptedData Base64编码的加密数据 + * @return 解密后的明文 + */ + fun decrypt(encryptedData: String): String { + val combined = Base64.decode(encryptedData, Base64.DEFAULT) + + // 提取IV(前12字节) + val iv = combined.sliceArray(0..11) + val encryptedBytes = combined.sliceArray(12 until combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + + val decryptedBytes = cipher.doFinal(encryptedBytes) + return String(decryptedBytes, Charsets.UTF_8) + } + + /** + * 加密字节数组 + * @param data 要加密的数据 + * @return 加密后的数据(包含IV) + */ + fun encryptBytes(data: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(data) + + // 将IV和加密数据组合 + val combined = ByteArray(iv.size + encryptedBytes.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size) + + return combined + } + + /** + * 解密字节数组 + * @param encryptedData 加密的数据 + * @return 解密后的数据 + */ + fun decryptBytes(encryptedData: ByteArray): ByteArray { + // 提取IV(前12字节) + val iv = encryptedData.sliceArray(0..11) + val encryptedBytes = encryptedData.sliceArray(12 until encryptedData.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, getKey(), spec) + + return cipher.doFinal(encryptedBytes) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt index 59493e6..4d03ce2 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/utils/FilePickerUtil.kt @@ -107,11 +107,16 @@ object FilePickerUtil { private fun isValidFileType(fileName: String, mimeType: String?): Boolean { val fileExtension = fileName.lowercase() return fileExtension.endsWith(".csv") || + fileExtension.endsWith(".csv.enc") || fileExtension.endsWith(".xlsx") || + fileExtension.endsWith(".xlsx.enc") || fileExtension.endsWith(".xls") || + fileExtension.endsWith(".xls.enc") || + fileExtension.endsWith(".enc") || mimeType == "text/csv" || mimeType == "application/vnd.ms-excel" || - mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + mimeType == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + mimeType == "application/octet-stream" // 加密文件可能被识别为二进制流 } /** diff --git a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt index 4fcfa8a..f9709b3 100644 --- a/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/yovinchen/bookkeeping/viewmodel/SettingsViewModel.kt @@ -14,6 +14,7 @@ import com.yovinchen.bookkeeping.model.BookkeepingRecord import com.yovinchen.bookkeeping.model.Category import com.yovinchen.bookkeeping.model.Settings import com.yovinchen.bookkeeping.model.TransactionType +import com.yovinchen.bookkeeping.utils.EncryptionUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -30,6 +31,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook import java.io.File import java.io.FileReader import java.io.FileWriter +import java.io.StringWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -157,15 +159,24 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun exportToCSV(context: Context, customDir: File? = null) { viewModelScope.launch(Dispatchers.IO) { try { + val currentSettings = settings.value ?: Settings() val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - val fileName = "bookkeeping_backup_$timestamp.csv" + val shouldEncrypt = currentSettings.encryptBackup + val fileName = if (shouldEncrypt) { + "bookkeeping_backup_$timestamp.csv.enc" + } else { + "bookkeeping_backup_$timestamp.csv" + } val downloadsDir = customDir ?: Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS ) val file = File(downloadsDir, fileName) - CSVWriter(FileWriter(file)).use { writer -> + // 先创建CSV内容到字符串 + val csvContent = StringWriter().use { stringWriter -> + val writer = CSVWriter(stringWriter) + // 写入头部 writer.writeNext(arrayOf("日期", "类型", "金额", "类别", "备注", "成员")) @@ -189,11 +200,25 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application ) ) } + writer.close() + stringWriter.toString() + } + + // 根据设置决定是否加密 + if (shouldEncrypt) { + val encryptedContent = EncryptionUtils.encrypt(csvContent) + file.writeText(encryptedContent) + } else { + file.writeText(csvContent) } withContext(Dispatchers.Main) { - Toast.makeText(context, "CSV导出成功: ${file.absolutePath}", Toast.LENGTH_LONG) - .show() + val message = if (shouldEncrypt) { + "CSV导出成功(已加密): ${file.absolutePath}" + } else { + "CSV导出成功: ${file.absolutePath}" + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } catch (e: Exception) { e.printStackTrace() @@ -207,6 +232,9 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun exportToExcel(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { + val currentSettings = settings.value ?: Settings() + val shouldEncrypt = currentSettings.encryptBackup + val workbook = XSSFWorkbook() val sheet = workbook.createSheet("账目记录") @@ -239,18 +267,39 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) - val fileName = "bookkeeping_backup_$timestamp.xlsx" + val fileName = if (shouldEncrypt) { + "bookkeeping_backup_$timestamp.xlsx.enc" + } else { + "bookkeeping_backup_$timestamp.xlsx" + } val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) val file = File(downloadsDir, fileName) - workbook.write(file.outputStream()) + if (shouldEncrypt) { + // 将 workbook 写入字节数组 + val byteArrayOutputStream = java.io.ByteArrayOutputStream() + workbook.write(byteArrayOutputStream) + val excelBytes = byteArrayOutputStream.toByteArray() + + // 加密字节数组 + val encryptedBytes = EncryptionUtils.encryptBytes(excelBytes) + + // 写入加密文件 + file.writeBytes(encryptedBytes) + } else { + workbook.write(file.outputStream()) + } + workbook.close() withContext(Dispatchers.Main) { - Toast.makeText( - context, "Excel导出成功: ${file.absolutePath}", Toast.LENGTH_LONG - ).show() + val message = if (shouldEncrypt) { + "Excel导出成功(已加密): ${file.absolutePath}" + } else { + "Excel导出成功: ${file.absolutePath}" + } + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } catch (e: Exception) { e.printStackTrace() @@ -265,10 +314,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch(Dispatchers.IO) { try { when { + backupFile.name.endsWith(".csv.enc", ignoreCase = true) -> { + // 解密CSV文件 + val encryptedContent = backupFile.readText() + val decryptedContent = EncryptionUtils.decrypt(encryptedContent) + val tempFile = File(context.cacheDir, "temp_decrypted.csv") + tempFile.writeText(decryptedContent) + restoreFromCSV(tempFile) + tempFile.delete() + } + backupFile.name.endsWith(".csv", ignoreCase = true) -> { restoreFromCSV(backupFile) } + backupFile.name.endsWith(".xlsx.enc", ignoreCase = true) -> { + // 解密Excel文件 + val encryptedBytes = backupFile.readBytes() + val decryptedBytes = EncryptionUtils.decryptBytes(encryptedBytes) + val tempFile = File(context.cacheDir, "temp_decrypted.xlsx") + tempFile.writeBytes(decryptedBytes) + restoreFromExcel(tempFile) + tempFile.delete() + } + backupFile.name.endsWith(".xlsx", ignoreCase = true) -> { restoreFromExcel(backupFile) } @@ -287,7 +356,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application } catch (e: Exception) { e.printStackTrace() withContext(Dispatchers.Main) { - Toast.makeText(context, "数据恢复失败: ${e.message}", Toast.LENGTH_LONG).show() + val errorMessage = when { + e.message?.contains("decrypt") == true -> "解密失败,请确认文件未损坏" + else -> "数据恢复失败: ${e.message}" + } + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() } } } @@ -343,4 +416,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private suspend fun findMemberIdByName(name: String): Int? { return memberDao.getAllMembers().first().find { member -> member.name == name }?.id } + + fun updateSettings(settings: Settings) { + viewModelScope.launch { + settingsRepository.updateSettings(settings) + } + } }