Skip to content

Commit d2852d3

Browse files
authored
Merge pull request #1679 from DimensionDev/copilot/sub-pr-1676-another-one
Add validation and user confirmation to database import
2 parents 3fccc03 + c322cd7 commit d2852d3

File tree

7 files changed

+194
-14
lines changed

7 files changed

+194
-14
lines changed

app/src/main/java/dev/dimension/flare/ui/screen/settings/StorageScreen.kt

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.Column
88
import androidx.compose.foundation.layout.padding
99
import androidx.compose.foundation.rememberScrollState
1010
import androidx.compose.foundation.verticalScroll
11+
import androidx.compose.material3.AlertDialog
1112
import androidx.compose.material3.ExperimentalMaterial3Api
1213
import androidx.compose.material3.ListItemDefaults
1314
import androidx.compose.material3.SegmentedListItem
1415
import androidx.compose.material3.Text
16+
import androidx.compose.material3.TextButton
1517
import androidx.compose.material3.TopAppBarDefaults
1618
import androidx.compose.runtime.Composable
1719
import androidx.compose.runtime.LaunchedEffect
@@ -68,6 +70,46 @@ internal fun StorageScreen(
6870
val state by producePresenter {
6971
storagePresenter(context = context)
7072
}
73+
74+
var showImportConfirmation by remember { mutableStateOf(false) }
75+
var pendingImportUri by remember { mutableStateOf<android.net.Uri?>(null) }
76+
77+
if (showImportConfirmation) {
78+
AlertDialog(
79+
onDismissRequest = {
80+
showImportConfirmation = false
81+
pendingImportUri = null
82+
},
83+
title = {
84+
Text(text = stringResource(id = R.string.import_confirmation_title))
85+
},
86+
text = {
87+
Text(text = stringResource(id = R.string.import_confirmation_message))
88+
},
89+
confirmButton = {
90+
TextButton(
91+
onClick = {
92+
pendingImportUri?.let { state.import(it) }
93+
showImportConfirmation = false
94+
pendingImportUri = null
95+
},
96+
) {
97+
Text(text = stringResource(id = android.R.string.ok))
98+
}
99+
},
100+
dismissButton = {
101+
TextButton(
102+
onClick = {
103+
showImportConfirmation = false
104+
pendingImportUri = null
105+
},
106+
) {
107+
Text(text = stringResource(id = android.R.string.cancel))
108+
}
109+
},
110+
)
111+
}
112+
71113
FlareScaffold(
72114
topBar = {
73115
FlareLargeFlexibleTopAppBar(
@@ -199,7 +241,10 @@ internal fun StorageScreen(
199241
rememberLauncherForActivityResult(
200242
contract = ActivityResultContracts.OpenDocument(),
201243
onResult = { uri ->
202-
uri?.let { state.import(it) }
244+
uri?.let {
245+
pendingImportUri = it
246+
showImportConfirmation = true
247+
}
203248
},
204249
)
205250

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,13 @@
384384
<string name="settings_storage_export_data">Export Data</string>
385385
<string name="settings_storage_export_data_description">Export all data including accounts and credentials (keep file secure)</string>
386386
<string name="settings_storage_import_data">Import Data</string>
387-
<string name="settings_storage_import_data_description">Import data from file (overwrites existing data)</string>
387+
<string name="settings_storage_import_data_description">Import data from file (merges with existing data, replaces duplicates)</string>
388388
<string name="save_completed">Save completed</string>
389389
<string name="save_error">Failed to save data</string>
390390
<string name="import_completed">Import completed</string>
391391
<string name="import_error">Failed to import data</string>
392+
<string name="import_confirmation_title">Confirm Import</string>
393+
<string name="import_confirmation_message">This will import data from the file. Existing records with matching IDs will be replaced. Do you want to continue?</string>
392394

393395
<string name="changelog_current">
394396
<![CDATA[

desktopApp/src/main/composeResources/values/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,13 @@
334334
<string name="settings_storage_export_data">Export Data</string>
335335
<string name="settings_storage_export_data_description">Export all data including accounts and credentials (keep file secure)</string>
336336
<string name="settings_storage_import_data">Import Data</string>
337-
<string name="settings_storage_import_data_description">Import data from file (overwrites existing data)</string>
337+
<string name="settings_storage_import_data_description">Import data from file (merges with existing data, replaces duplicates)</string>
338338
<string name="action_export">Export</string>
339339
<string name="action_import">Import</string>
340340

341341
<string name="save_error">Failed to save data</string>
342342
<string name="import_completed">Import completed</string>
343343
<string name="import_error">Failed to import data</string>
344+
<string name="import_confirmation_title">Confirm Import</string>
345+
<string name="import_confirmation_message">This will import data from the file. Existing records with matching IDs will be replaced. Do you want to continue?</string>
344346
</resources>

desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ import dev.dimension.flare.delete
6363
import dev.dimension.flare.edit
6464
import dev.dimension.flare.home_login
6565
import dev.dimension.flare.import_completed
66+
import dev.dimension.flare.import_confirmation_message
67+
import dev.dimension.flare.import_confirmation_title
6668
import dev.dimension.flare.import_error
6769
import dev.dimension.flare.ok
6870
import dev.dimension.flare.remove_account
@@ -950,6 +952,30 @@ internal fun SettingsScreen(
950952
}
951953
},
952954
)
955+
956+
ContentDialog(
957+
title = stringResource(Res.string.import_confirmation_title),
958+
visible = state.storageState.showImportConfirmation,
959+
content = {
960+
Text(
961+
text = stringResource(Res.string.import_confirmation_message),
962+
modifier = Modifier.padding(16.dp),
963+
)
964+
},
965+
primaryButtonText = stringResource(Res.string.ok),
966+
closeButtonText = stringResource(Res.string.cancel),
967+
onButtonClick = {
968+
when (it) {
969+
ContentDialogButton.Primary -> {
970+
state.storageState.confirmImport()
971+
}
972+
else -> {
973+
state.storageState.cancelImport()
974+
}
975+
}
976+
},
977+
)
978+
953979
Expander(
954980
state.aiConfigState.expanded,
955981
onExpandedChanged = state.aiConfigState::setExpanded,
@@ -1212,6 +1238,9 @@ private fun storagePresenter(
12121238
val importPresenter = remember(importJson) { importJson?.let { ImportDataPresenter(it) } }
12131239
val importState = importPresenter?.body()
12141240

1241+
var showImportConfirmation by remember { mutableStateOf(false) }
1242+
var pendingImportFile by remember { mutableStateOf<File?>(null) }
1243+
12151244
LaunchedEffect(importState) {
12161245
importState?.let {
12171246
try {
@@ -1239,6 +1268,7 @@ private fun storagePresenter(
12391268
object : StorageState by state {
12401269
val expanded = expanded
12411270
val imageCacheSize = imageCacheSize
1271+
val showImportConfirmation = showImportConfirmation
12421272

12431273
fun clearImageCache() {
12441274
SingletonImageLoader.get(PlatformContext.INSTANCE).diskCache?.clear()
@@ -1265,8 +1295,22 @@ private fun storagePresenter(
12651295

12661296
fun import() {
12671297
onImportFilePicker()?.let { file ->
1298+
pendingImportFile = file
1299+
showImportConfirmation = true
1300+
}
1301+
}
1302+
1303+
fun confirmImport() {
1304+
pendingImportFile?.let { file ->
12681305
importJson = file.readText()
12691306
}
1307+
showImportConfirmation = false
1308+
pendingImportFile = null
1309+
}
1310+
1311+
fun cancelImport() {
1312+
showImportConfirmation = false
1313+
pendingImportFile = null
12701314
}
12711315

12721316
fun setExpanded(value: Boolean) {

iosApp/flare/Localizable.xcstrings

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26612,6 +26612,26 @@
2661226612
}
2661326613
}
2661426614
},
26615+
"import_confirmation_title" : {
26616+
"localizations" : {
26617+
"en" : {
26618+
"stringUnit" : {
26619+
"state" : "translated",
26620+
"value" : "Confirm Import"
26621+
}
26622+
}
26623+
}
26624+
},
26625+
"import_confirmation_message" : {
26626+
"localizations" : {
26627+
"en" : {
26628+
"stringUnit" : {
26629+
"state" : "translated",
26630+
"value" : "This will import data from the file. Existing records with matching IDs will be replaced. Do you want to continue?"
26631+
}
26632+
}
26633+
}
26634+
},
2661526635
"Invalid URL" : {
2661626636
"extractionState" : "stale",
2661726637
"localizations" : {
@@ -81115,7 +81135,7 @@
8111581135
"en" : {
8111681136
"stringUnit" : {
8111781137
"state" : "translated",
81118-
"value" : "Import data from file (overwrites existing data)"
81138+
"value" : "Import data from file (merges with existing data, replaces duplicates)"
8111981139
}
8112081140
}
8112181141
}

iosApp/flare/UI/Screen/StorageScreen.swift

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ struct StorageScreen: View {
1111
@State private var showImageClearAlert = false
1212
@State private var showFileExporter = false
1313
@State private var showFileImporter = false
14+
@State private var showImportConfirmation = false
15+
@State private var pendingImportJson: String?
1416
@State private var jsonFile: JSONFile?
1517
var body: some View {
1618
List {
@@ -108,15 +110,8 @@ struct StorageScreen: View {
108110
do {
109111
let data = try Data(contentsOf: url)
110112
if let json = String(data: data, encoding: .utf8) {
111-
let importPresenter = ImportDataPresenter(jsonContent: json)
112-
Task {
113-
do {
114-
try await importPresenter.models.value.import()
115-
Drops.show(.init(stringLiteral: .init(localized: "import_completed")))
116-
} catch {
117-
Drops.show(.init(stringLiteral: .init(localized: "import_error")))
118-
}
119-
}
113+
pendingImportJson = json
114+
showImportConfirmation = true
120115
}
121116
} catch {
122117
Drops.show(.init(stringLiteral: .init(localized: "import_error")))
@@ -125,6 +120,29 @@ struct StorageScreen: View {
125120
Drops.show(.init(stringLiteral: .init(localized: "import_error")))
126121
}
127122
}
123+
.alert("import_confirmation_title", isPresented: $showImportConfirmation) {
124+
Button("Cancel", role: .cancel) {
125+
showImportConfirmation = false
126+
pendingImportJson = nil
127+
}
128+
Button("Ok") {
129+
if let json = pendingImportJson {
130+
let importPresenter = ImportDataPresenter(jsonContent: json)
131+
Task {
132+
do {
133+
try await importPresenter.models.value.import()
134+
Drops.show(.init(stringLiteral: .init(localized: "import_completed")))
135+
} catch {
136+
Drops.show(.init(stringLiteral: .init(localized: "import_error")))
137+
}
138+
}
139+
}
140+
showImportConfirmation = false
141+
pendingImportJson = nil
142+
}
143+
} message: {
144+
Text("import_confirmation_message")
145+
}
128146
}
129147
.navigationTitle("storage_title")
130148
}

shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/ImportAppDatabasePresenter.kt

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@ public class ImportAppDatabasePresenter(
2525
}
2626

2727
public suspend fun import() {
28-
val export = jsonContent.decodeJson<AppDatabaseExport>()
28+
// Parse and validate JSON structure
29+
val export =
30+
try {
31+
jsonContent.decodeJson<AppDatabaseExport>()
32+
} catch (e: Exception) {
33+
throw IllegalArgumentException("Invalid import file format: ${e.message}", e)
34+
}
35+
36+
// Validate imported data
37+
validateImportData(export)
38+
39+
// Perform import within a transaction (automatically rolls back on error)
40+
// Note: DAO insert methods use OnConflictStrategy.REPLACE - existing records with matching primary keys will be replaced
2941
appDatabase.connect {
3042
export.accounts.forEach { appDatabase.accountDao().insert(it) }
3143

@@ -40,4 +52,41 @@ public class ImportAppDatabasePresenter(
4052
}
4153
}
4254
}
55+
56+
private fun validateImportData(export: AppDatabaseExport) {
57+
// Validate account data
58+
export.accounts.forEach { account ->
59+
require(account.credential_json.isNotBlank()) {
60+
"Invalid account data: credential_json cannot be empty"
61+
}
62+
}
63+
64+
// Validate application data
65+
export.applications.forEach { application ->
66+
require(application.host.isNotBlank()) {
67+
"Invalid application data: host cannot be empty"
68+
}
69+
}
70+
71+
// Validate keyword filters
72+
export.keywordFilters.forEach { filter ->
73+
require(filter.keyword.isNotBlank()) {
74+
"Invalid keyword filter: keyword cannot be empty"
75+
}
76+
}
77+
78+
// Validate search histories
79+
export.searchHistories.forEach { history ->
80+
require(history.search.isNotBlank()) {
81+
"Invalid search history: search term cannot be empty"
82+
}
83+
}
84+
85+
// Validate RSS sources
86+
export.rssSources.forEach { source ->
87+
require(source.url.isNotBlank()) {
88+
"Invalid RSS source: URL cannot be empty"
89+
}
90+
}
91+
}
4392
}

0 commit comments

Comments
 (0)