Repository navigation
ADFA-5446: Confirm template uninstall/replace instead of failing silently #2045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
81df219
e2e0888
6588402
b6844f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,7 +137,15 @@ class TemplateRepositoryImpl( | |
| } | ||
| } | ||
|
|
||
| override suspend fun uninstallTemplate(item: CgtFileItem): Result<Unit> = | ||
| /** | ||
| * On a post-copy delete failure: rolls back (deletes the Downloads copy this call created) when | ||
| * `overwrite` was false, but leaves the Downloads copy in place when `overwrite` was true, since | ||
| * the pre-existing content there was already replaced and can't be recovered either way. | ||
| */ | ||
| override suspend fun uninstallTemplate( | ||
| item: CgtFileItem, | ||
| overwrite: Boolean, | ||
| ): Result<Unit> = | ||
| withContext(Dispatchers.IO) { | ||
| try { | ||
| check(item.installed) { "'${item.name}' is not installed" } | ||
|
|
@@ -146,11 +154,31 @@ class TemplateRepositoryImpl( | |
| // Restore a copy to Downloads BEFORE removing it from the store: if the restore | ||
| // throws, the store copy below is never touched, so the user's only copy survives. | ||
| val restored = File(downloadDir, item.file.name) | ||
| check(!restored.exists()) { "A download named '${restored.name}' already exists in $downloadDir" } | ||
| item.file.copyTo(restored, overwrite = false) | ||
| val hadExistingDownload = restored.exists() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jimturner-adfa IMPORTANT: this conflict check is case-sensitive, but the list filter that hides Downloads twins is not — so the silent overwrite this PR exists to fix is still reachable.
Narrow trigger, but it's the exact bug class the PR targets. Matching the case-insensitive lookup |
||
| if (hadExistingDownload && !overwrite) { | ||
| // Not thrown, so it skips the catch blocks below - log it here instead, or a | ||
| // repeated replace-conflict leaves no trace for support to find. | ||
| logger.warn( | ||
| "Uninstall of '{}' would overwrite an existing Downloads file; asking for confirmation", | ||
| item.name, | ||
| ) | ||
| return@withContext Result.failure(TemplateReplaceConflictException(restored.name)) | ||
| } | ||
| item.file.copyTo(restored, overwrite = overwrite) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jimturner-adfa HIGH: The comment below reasons carefully about the delete failing after a successful copy. But if the copy itself fails partway — storage full, I/O error on a large Suggest handling the copy failure on its own when |
||
| if (!item.file.delete()) { | ||
| restored.delete() | ||
| throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") | ||
| // Only roll back a copy this call created itself. When `overwrite` replaced a | ||
| // pre-existing Downloads file, that original content is already gone - deleting | ||
| // `restored` here would destroy the new copy too and leave the user with nothing, | ||
| // whereas the still-installed source (its delete just failed) means leaving the | ||
| // new copy in place costs nothing and loses no data. | ||
| if (!hadExistingDownload) { | ||
| restored.delete() | ||
| throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") | ||
| } | ||
| throw IOException( | ||
| "Replaced '${restored.name}' in Downloads, but failed to delete the installed copy at " + | ||
| "${item.file.absolutePath} - the template now exists in both places", | ||
| ) | ||
| } | ||
| ITemplateProvider.getInstance(reload = true) | ||
| Result.success(Unit) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,46 @@ fun DeleteTemplateConfirmationDialog( | |
| ) | ||
| } | ||
|
|
||
| /** Confirm before uninstalling [item], since it moves the file back to Downloads. */ | ||
| @Composable | ||
| fun UninstallTemplateConfirmationDialog( | ||
| item: CgtFileItem, | ||
| onConfirm: () -> Unit, | ||
| onDismiss: () -> Unit, | ||
| ) { | ||
| AlertDialog( | ||
| onDismissRequest = onDismiss, | ||
| title = { Text(stringResource(R.string.title_uninstall_template)) }, | ||
| text = { Text(stringResource(R.string.msg_uninstall_template_confirm, item.displayName)) }, | ||
| confirmButton = { | ||
| TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_uninstall_template)) } | ||
| }, | ||
| dismissButton = { | ||
| TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** Shown when uninstalling [item] would silently overwrite an existing Downloads file of the same name. */ | ||
| @Composable | ||
| fun ReplaceTemplateInDownloadsDialog( | ||
| item: CgtFileItem, | ||
| onConfirm: () -> Unit, | ||
| onDismiss: () -> Unit, | ||
| ) { | ||
| AlertDialog( | ||
| onDismissRequest = onDismiss, | ||
| title = { Text(stringResource(R.string.title_replace_template)) }, | ||
| text = { Text(stringResource(R.string.msg_replace_template_confirm, item.displayName)) }, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jimturner-adfa IMPORTANT: this dialog names a file that doesn't exist.
This is the moment the user authorizes a destructive overwrite, so the name should be exact. |
||
| confirmButton = { | ||
| TextButton(onClick = onConfirm) { Text(stringResource(R.string.replace)) } | ||
| }, | ||
| dismissButton = { | ||
| TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** File-level details for a single-template .cgt (multi-template files use [TemplateListDialog]). */ | ||
| @Composable | ||
| fun TemplateFileDetailsDialog( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 11046
🏁 Script executed:
Repository: appdevforall/CodeOnTheGo
Length of output: 3244
Pass
overwriteat direct test calls.TemplateRepositoryImplTest.repositoryis statically typed asTemplateRepositoryImpl. Calls at lines 106 and 123 omitoverwrite, while the override declares no default. Kotlin does not inherit interface defaults onto overrides, so these calls fail compilation. Passoverwrite = falseat both calls, or add a one-argument forwarding overload.🤖 Prompt for AI Agents