From 14779a3310cc293441571698db753a64a51a2c9e Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 19 Jun 2026 12:30:58 -0400 Subject: [PATCH 1/2] fix: Clear file modified indicator when changes are undone to initial state --- .../editor/EditorHandlerActivity.kt | 12 +++++-- .../itsaky/androidide/editor/ui/IDEEditor.kt | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index da9527798e..7bdceb4be5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1091,18 +1091,24 @@ open class EditorHandlerActivity : @Subscribe(threadMode = ThreadMode.MAIN) fun onDocumentChange(event: DocumentChangeEvent) { if (contentOrNull == null) return - editorViewModel.areFilesModified = true val fileIndex = findIndexOfEditorByFile(event.file.toFile()) if (fileIndex == -1) return + // The editor recomputes its modified flag on every content change, so a change may + // have returned the file to its saved state (e.g. the user undid all their edits). + val isModified = getEditorAtIndex(fileIndex)?.isModified ?: false + editorViewModel.areFilesModified = hasUnsavedFiles() + val tabPosition = getTabPositionForFileIndex(fileIndex) if (tabPosition < 0) return val tab = content.tabs.getTabAt(tabPosition) ?: return - if (tab.text?.startsWith('*') == true) return + val hasIndicator = tab.text?.startsWith('*') == true + if (isModified == hasIndicator) return - tab.text = "*${tab.text}" + val baseName = tab.text?.removePrefix("*") ?: return + tab.text = if (isModified) "*$baseName" else baseName } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 89d4be990f..859d435748 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -142,6 +142,13 @@ constructor( private var fileVersion = 0 internal var isModified = false + // Length and content hash of the content the last time the file was loaded or saved. + // Used to detect when edits (e.g. undoing every change) return the content to its + // saved state, so the modified indicator can be cleared. The length is compared first + // as an O(1) guard so the content hash is only computed when the lengths match. + private var savedContentLength = 0 + private var savedContentHash = 0 + private val selectionChangeHandler = Handler(Looper.getMainLooper()) private var selectionChangeRunner: Runnable? = Runnable { @@ -661,9 +668,13 @@ constructor( /** * Mark this editor as NOT modified. + * + * Snapshots the current content so that later edits which return the content to this + * state (for example, undoing every change) can clear the modified flag again. */ open fun markUnmodified() { this.isModified = false + snapshotSavedContent() } /** @@ -673,6 +684,24 @@ constructor( this.isModified = true } + /** + * Recomputes [isModified] by comparing the current content against the snapshot captured + * the last time the file was loaded or saved. The content length is + * checked first as a cheap guard so the full content hash is only computed when the lengths + * match - i.e. when the edits may have restored the saved state. + */ + private fun refreshModifiedState() { + val content = text + isModified = content.length != savedContentLength || + content.toString().hashCode() != savedContentHash + } + + private fun snapshotSavedContent() { + val content = text.toString() + savedContentLength = content.length + savedContentHash = content.hashCode() + } + /** * Notify the language server that the file in this editor is about to be closed. */ @@ -890,7 +919,7 @@ constructor( return@subscribeEvent } - markModified() + refreshModifiedState() file ?: return@subscribeEvent editorScope.launch { From 9ab143927fc144ceeaa5bc9437af3cd9c095622d Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Fri, 19 Jun 2026 15:49:28 -0400 Subject: [PATCH 2/2] fix(ADFA-2324): Use 64-bit FNV-1a hash for file modification tracking Replaces the 32-bit `String.hashCode()` implementation with a custom 64-bit FNV-1a hash algorithm. This addresses a potential hash collision vulnerability where a file could be incorrectly marked as unmodified, leading to data loss. Additionally, this resolves a performance bottleneck by computing the hash directly on the `CharSequence`, removing the heavy string allocation (`text.toString()`) that was previously happening every time the file modification state refreshed. --- .../itsaky/androidide/editor/ui/IDEEditor.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 859d435748..bb2f369f1a 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -147,7 +147,7 @@ constructor( // saved state, so the modified indicator can be cleared. The length is compared first // as an O(1) guard so the content hash is only computed when the lengths match. private var savedContentLength = 0 - private var savedContentHash = 0 + private var savedContentHash = 0L private val selectionChangeHandler = Handler(Looper.getMainLooper()) private var selectionChangeRunner: Runnable? = @@ -693,13 +693,22 @@ constructor( private fun refreshModifiedState() { val content = text isModified = content.length != savedContentLength || - content.toString().hashCode() != savedContentHash + computeContentHash(content) != savedContentHash } private fun snapshotSavedContent() { - val content = text.toString() + val content = text savedContentLength = content.length - savedContentHash = content.hashCode() + savedContentHash = computeContentHash(content) + } + + private fun computeContentHash(content: CharSequence): Long { + var hash = -3750763034362895579L // FNV_offset_basis + for (i in 0 until content.length) { + hash = hash xor content[i].code.toLong() + hash *= 1099511628211L // FNV_prime + } + return hash } /**