From 3cba73b2410386e8abe981281f7d4fea48b309f8 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Mon, 4 May 2026 18:18:41 -0700 Subject: [PATCH 1/7] Protect against project name collisions during project rename action --- .../adapters/RecentProjectsAdapter.kt | 10 ++++++- .../fragments/RecentProjectsFragment.kt | 12 ++++++++ .../viewmodel/RecentProjectsViewModel.kt | 30 ++++++++++++------- .../codeonthego/layouteditor/ProjectFile.kt | 8 ++++- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index 23474e0f37..54cd2159d5 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -261,13 +261,14 @@ class RecentProjectsAdapter( override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { - validateProjectName(binding.textinputLayout, s.toString(), dialog) + validateProjectName(binding.textinputLayout, s.toString(), oldName, dialog) } }) validateProjectName( binding.textinputLayout, binding.textinputEdittext.text.toString(), + oldName, dialog ) } @@ -275,6 +276,7 @@ class RecentProjectsAdapter( private fun validateProjectName( inputLayout: TextInputLayout, newName: String, + oldName: String, dialog: AlertDialog ) { val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) @@ -284,6 +286,12 @@ class RecentProjectsAdapter( positiveButton.isEnabled = false } + newName != oldName && projects.any { it.name == newName } -> { + inputLayout.error = + dialog.context.getString(R.string.msg_current_name_unavailable) + positiveButton.isEnabled = false + } + else -> { inputLayout.error = null positiveButton.isEnabled = true diff --git a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt index e1ca0433e3..94eb7e5db7 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt @@ -82,6 +82,7 @@ class RecentProjectsFragment : BaseFragment() { setupClickListeners() bootstrapFromFixedFolderIfNeeded() observeDeletionStatus() + observeRenameStatus() } private fun setupRecyclerView() { @@ -428,4 +429,15 @@ class RecentProjectsFragment : BaseFragment() { } } + private fun observeRenameStatus() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.renameStatus.collect { status -> + if (!status) { + flashError(R.string.rename_failed) + viewModel.loadProjects() + } + } + } + } + } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 02831bcd18..23fed4f953 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -55,6 +55,9 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli private val _deletionStatus = MutableSharedFlow(replay = 1) val deletionStatus = _deletionStatus.asSharedFlow() + private val _renameStatus = MutableSharedFlow() + val renameStatus = _renameStatus.asSharedFlow() + // Get the database and DAO instance private val recentProjectDatabase: RecentProjectRoomDatabase = RecentProjectRoomDatabase.getDatabase(application, viewModelScope) @@ -194,17 +197,22 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli fun updateProject(oldName: String, newName: String, location: String) = viewModelScope.launch(Dispatchers.IO) { - val modifiedAt = System.currentTimeMillis().toString() - recentProjectDao.updateNameAndLocation( - oldName = oldName, - newName = newName, - newLocation = location - ) - recentProjectDao.updateLastModified( - projectName = newName, - lastModified = modifiedAt - ) - loadProjects() + try { + val modifiedAt = System.currentTimeMillis().toString() + recentProjectDao.updateNameAndLocation( + oldName = oldName, + newName = newName, + newLocation = location + ) + recentProjectDao.updateLastModified( + projectName = newName, + lastModified = modifiedAt + ) + loadProjects() + } catch (e: SQLException) { + logger.error("Failed to update project after rename ($oldName -> $newName)", e) + _renameStatus.emit(false) + } } fun updateProjectModifiedDate(name: String) = diff --git a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt index a6992def75..307198a734 100644 --- a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt +++ b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt @@ -10,6 +10,7 @@ import org.appdevforall.codeonthego.layouteditor.utils.FileUtil import com.itsaky.androidide.utils.formatDate import org.jetbrains.annotations.Contract import java.io.File +import java.io.IOException import java.nio.file.Files import java.nio.file.Paths @@ -44,7 +45,12 @@ class ProjectFile : Parcelable { fun rename(newPath: String) { val newFile = File(newPath) val oldFile = File(path) - oldFile.renameTo(newFile) + if (newFile.exists()) { + throw IOException("Destination already exists: $newPath") + } + if (!oldFile.renameTo(newFile)) { + throw IOException("Failed to rename $path to $newPath") + } path = newPath name = FileUtil.getLastSegmentFromPath(path) From ba74d3f9b889862995877550d2e8c64ecc2b56fe Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Mon, 4 May 2026 18:22:41 -0700 Subject: [PATCH 2/7] Revert change to Layout Editor --- .../appdevforall/codeonthego/layouteditor/ProjectFile.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt index 307198a734..a6992def75 100644 --- a/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt +++ b/layouteditor/src/main/java/org/appdevforall/codeonthego/layouteditor/ProjectFile.kt @@ -10,7 +10,6 @@ import org.appdevforall.codeonthego.layouteditor.utils.FileUtil import com.itsaky.androidide.utils.formatDate import org.jetbrains.annotations.Contract import java.io.File -import java.io.IOException import java.nio.file.Files import java.nio.file.Paths @@ -45,12 +44,7 @@ class ProjectFile : Parcelable { fun rename(newPath: String) { val newFile = File(newPath) val oldFile = File(path) - if (newFile.exists()) { - throw IOException("Destination already exists: $newPath") - } - if (!oldFile.renameTo(newFile)) { - throw IOException("Failed to rename $path to $newPath") - } + oldFile.renameTo(newFile) path = newPath name = FileUtil.getLastSegmentFromPath(path) From 87a609438e12b74af755df4431628f6561afd56c Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 5 May 2026 14:57:19 -0700 Subject: [PATCH 3/7] Handle collisions hidden by filters, too --- .../com/itsaky/androidide/adapters/RecentProjectsAdapter.kt | 3 ++- .../com/itsaky/androidide/fragments/RecentProjectsFragment.kt | 3 ++- .../com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index 54cd2159d5..c8b4f98b3c 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -39,6 +39,7 @@ class RecentProjectsAdapter( private val onRemoveProjectClick: (ProjectFile) -> Unit, private val onFileRenamed: (RenamedFile) -> Unit, private val onInfoClick: (ProjectFile) -> Unit, + private val nameExists: (String) -> Boolean, ) : RecyclerView.Adapter() { private var projectOptionsPopup: PopupWindow? = null @@ -286,7 +287,7 @@ class RecentProjectsAdapter( positiveButton.isEnabled = false } - newName != oldName && projects.any { it.name == newName } -> { + newName != oldName && nameExists(newName) -> { inputLayout.error = dialog.context.getString(R.string.msg_current_name_unavailable) positiveButton.isEnabled = false diff --git a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt index 94eb7e5db7..c06377545b 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt @@ -334,7 +334,8 @@ class RecentProjectsFragment : BaseFragment() { onProjectClick = ::openProject, onRemoveProjectClick = viewModel::deleteProject, onFileRenamed = viewModel::updateProject, - onInfoClick = { project -> openProjectInfo(project) } + onInfoClick = { project -> openProjectInfo(project) }, + nameExists = viewModel::projectNameExists ) binding.listProjects.adapter = adapter } else { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 23fed4f953..9bd30376b6 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -130,6 +130,9 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli } } + fun projectNameExists(name: String): Boolean = + allProjects.any { it.name == name } + fun insertProjectFromFolder(name: String, location: String) = viewModelScope.launch(Dispatchers.IO) { // Check if the project already exists From 15790f9ae97548a238fc3b2d9f3009decbc8f92d Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 5 May 2026 15:59:08 -0700 Subject: [PATCH 4/7] Work harder to handle DAO update failures --- .../adapters/RecentProjectsAdapter.kt | 12 +++++++--- .../viewmodel/RecentProjectsViewModel.kt | 24 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index c8b4f98b3c..e36e08e164 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -228,11 +228,12 @@ class RecentProjectsAdapter( builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } builder.setPositiveButton(R.string.rename) { _, _ -> val newName = binding.textinputEdittext.text.toString() - val newPath = project.path.substringBeforeLast("/") + "/" + newName + val oldPath = project.path + val newPath = oldPath.substringBeforeLast("/") + "/" + newName try { project.rename(newPath) flashSuccess(R.string.renamed) - onFileRenamed(RenamedFile(oldName, newName, newPath)) + onFileRenamed(RenamedFile(oldName, newName, oldPath, newPath)) notifyItemChanged(position) } catch (e: Exception) { logger.error("Failed to rename project", e) @@ -300,5 +301,10 @@ class RecentProjectsAdapter( } } - data class RenamedFile(val oldName: String, val newName: String, val newPath: String) + data class RenamedFile( + val oldName: String, + val newName: String, + val oldPath: String, + val newPath: String + ) } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 9bd30376b6..113ccf6e48 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -196,16 +196,26 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli } fun updateProject(renamedFile: RecentProjectsAdapter.RenamedFile) = - updateProject(renamedFile.oldName, renamedFile.newName, renamedFile.newPath) - - fun updateProject(oldName: String, newName: String, location: String) = + updateProject( + renamedFile.oldName, + renamedFile.newName, + renamedFile.oldPath, + renamedFile.newPath + ) + + fun updateProject( + oldName: String, + newName: String, + oldLocation: String, + newLocation: String + ) = viewModelScope.launch(Dispatchers.IO) { try { val modifiedAt = System.currentTimeMillis().toString() recentProjectDao.updateNameAndLocation( oldName = oldName, newName = newName, - newLocation = location + newLocation = newLocation ) recentProjectDao.updateLastModified( projectName = newName, @@ -214,6 +224,12 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli loadProjects() } catch (e: SQLException) { logger.error("Failed to update project after rename ($oldName -> $newName)", e) + val rolledBack = File(newLocation).renameTo(File(oldLocation)) + if (rolledBack) { + logger.info("Rolled back filesystem rename: $newLocation -> $oldLocation") + } else { + logger.error("Rollback failed; filesystem and DB are out of sync (disk=$newLocation, db=$oldLocation)") + } _renameStatus.emit(false) } } From 15775e47ced662e53d04170f120c5edb3f284690 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 5 May 2026 16:09:04 -0700 Subject: [PATCH 5/7] Emit rename success on _renameStatus after DAO writes complete Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 113ccf6e48..31131b13b0 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -222,6 +222,7 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli lastModified = modifiedAt ) loadProjects() + _renameStatus.emit(true) } catch (e: SQLException) { logger.error("Failed to update project after rename ($oldName -> $newName)", e) val rolledBack = File(newLocation).renameTo(File(oldLocation)) From 157ad89ddeb7076e210d2ed5e6f44717f834ee8e Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 5 May 2026 18:02:38 -0700 Subject: [PATCH 6/7] Trim whitespace when renaming --- .../com/itsaky/androidide/adapters/RecentProjectsAdapter.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index e36e08e164..e4ba70bf9f 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -227,7 +227,7 @@ class RecentProjectsAdapter( builder.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } builder.setPositiveButton(R.string.rename) { _, _ -> - val newName = binding.textinputEdittext.text.toString() + val newName = binding.textinputEdittext.text.toString().trim() val oldPath = project.path val newPath = oldPath.substringBeforeLast("/") + "/" + newName try { @@ -263,13 +263,13 @@ class RecentProjectsAdapter( override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { - validateProjectName(binding.textinputLayout, s.toString(), oldName, dialog) + validateProjectName(binding.textinputLayout, s.toString().trim(), oldName, dialog) } }) validateProjectName( binding.textinputLayout, - binding.textinputEdittext.text.toString(), + binding.textinputEdittext.text.toString().trim(), oldName, dialog ) From 760170223ec02827e56e778d5115e40df65a372d Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Tue, 5 May 2026 18:57:49 -0700 Subject: [PATCH 7/7] Reject path separators and traversal in project rename Co-Authored-By: Claude Opus 4.7 (1M context) --- .../itsaky/androidide/adapters/RecentProjectsAdapter.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index e4ba70bf9f..33a75387a3 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -288,6 +288,15 @@ class RecentProjectsAdapter( positiveButton.isEnabled = false } + newName.contains('/') || + newName.contains('\\') || + newName.contains(File.separatorChar) || + newName == "." || + newName == ".." -> { + inputLayout.error = dialog.context.getString(R.string.msg_invalid_name) + positiveButton.isEnabled = false + } + newName != oldName && nameExists(newName) -> { inputLayout.error = dialog.context.getString(R.string.msg_current_name_unavailable)