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..33a75387a3 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 @@ -226,12 +227,13 @@ 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 newName = binding.textinputEdittext.text.toString().trim() + 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) @@ -261,13 +263,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().trim(), oldName, dialog) } }) validateProjectName( binding.textinputLayout, - binding.textinputEdittext.text.toString(), + binding.textinputEdittext.text.toString().trim(), + oldName, dialog ) } @@ -275,6 +278,7 @@ class RecentProjectsAdapter( private fun validateProjectName( inputLayout: TextInputLayout, newName: String, + oldName: String, dialog: AlertDialog ) { val positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE) @@ -284,6 +288,21 @@ 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) + positiveButton.isEnabled = false + } + else -> { inputLayout.error = null positiveButton.isEnabled = true @@ -291,5 +310,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/fragments/RecentProjectsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt index e1ca0433e3..c06377545b 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() { @@ -333,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 { @@ -428,4 +430,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..31131b13b0 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) @@ -127,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 @@ -190,21 +196,43 @@ 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) { - 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 = newLocation + ) + recentProjectDao.updateLastModified( + projectName = newName, + 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)) + 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) + } } fun updateProjectModifiedDate(name: String) =