From 8bfefb09243d2b926c5c85459502dbb5adcb49de Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 16:37:42 +0100 Subject: [PATCH 01/18] feat(ADFA-3760): Display watermark option --- .../fragments/git/GitBottomSheetFragment.kt | 7 ++ .../res/layout/fragment_git_bottom_sheet.xml | 65 +++++++++++++------ .../main/res/drawable/bg_commit_watermark.xml | 7 ++ resources/src/main/res/values/strings.xml | 2 + 4 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 resources/src/main/res/drawable/bg_commit_watermark.xml diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 01d43226b5..b528cc544b 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -9,6 +9,7 @@ import android.text.style.ClickableSpan import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -290,6 +291,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { recyclerView.visibility = View.GONE cbCheckAll.visibility = View.GONE commitSection.visibility = View.GONE + layoutWatermark.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.GONE btnAbortMerge.visibility = View.GONE @@ -306,6 +308,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isChecked = false cbCheckAll.text = getString(R.string.changed_files_count, 0) commitSection.visibility = View.GONE + layoutWatermark.visibility = View.GONE authorWarning.visibility = View.GONE commitHistoryButton.visibility = View.VISIBLE btnAbortMerge.visibility = View.GONE @@ -321,6 +324,10 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isEnabled = hasSelectable cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) commitSection.visibility = View.VISIBLE + layoutWatermark.visibility = View.VISIBLE + switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> + tvCommitWatermark.isVisible = isChecked + } authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 9a7fcf2eac..aad40b3ab6 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -99,13 +99,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_branch_name" /> - - @@ -158,7 +152,7 @@ android:hint="@string/commit_summary" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/authorAvatar" - app:layout_constraintTop_toBottomOf="@id/guideline_limit"> + app:layout_constraintTop_toBottomOf="@id/recyclerView"> + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/layout_watermark" /> @@ -227,15 +249,16 @@ app:strokeColor="?attr/colorError" /> + android:id="@+id/commitHistoryButton" + style="@style/Widget.Material3.Button.OutlinedButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" + android:text="@string/view_commit_history" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@id/btnAbortMerge" + app:layout_constraintVertical_bias="1.0" /> diff --git a/resources/src/main/res/drawable/bg_commit_watermark.xml b/resources/src/main/res/drawable/bg_commit_watermark.xml new file mode 100644 index 0000000000..541a59a50f --- /dev/null +++ b/resources/src/main/res/drawable/bg_commit_watermark.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index bab84b6648..28bd208236 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1522,6 +1522,8 @@ Cancel clone Initializing clone… Cancelling clone… + Add commit message watermark + Made with Code on the Go (appdevforall.org) Set and update Git configuration From d820278cae0d3a1b069c3fca3837e8882e466b57 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 18:30:28 +0100 Subject: [PATCH 02/18] feat(ADFA-3760): Add commit watermark preference --- .../androidide/preferences/gitPrefExts.kt | 19 ++++++++++++ .../res/layout/fragment_git_bottom_sheet.xml | 20 ++++++------ .../androidide/idetooltips/TooltipTag.kt | 1 + .../preferences/internal/GitPreferences.kt | 31 ++++++++++++------- .../src/main/res/drawable/ic_watermark.xml | 19 ++++++++++++ resources/src/main/res/values/strings.xml | 2 ++ 6 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 resources/src/main/res/drawable/ic_watermark.xml diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index 9e0d102823..d73e4880fc 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -7,6 +7,12 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_GIT_USEREMAIL import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_GIT_USERNAME import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.preferences.internal.EditorPreferences.USE_ICU +import com.itsaky.androidide.preferences.internal.EditorPreferences.useIcu +import com.itsaky.androidide.resources.R.drawable +import com.itsaky.androidide.resources.R.string import kotlinx.parcelize.Parcelize @Parcelize @@ -20,6 +26,7 @@ class GitPreferencesScreen( init { addPreference(GitAuthorConfig()) + addPreference(AddGitCommitWatermark()) } } @@ -110,3 +117,15 @@ class GitUserEmail( return true } } + +@Parcelize +private class AddGitCommitWatermark( + override val key: String = GitPreferences.ADD_GIT_COMMIT_WATERMARK, + override val title: Int = string.idepref_git_add_commit_watermark, + override val summary: Int? = string.idepref_git_add_commit_watermark_summary, + override val icon: Int? = drawable.ic_watermark, + override val tooltipTag: String = TooltipTag.PREFS_GIT_WATERMARK, +) : SwitchPreference( + setValue = GitPreferences::shouldAddCommitWatermark::set, + getValue = GitPreferences::shouldAddCommitWatermark::get +) diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index aad40b3ab6..1a328ddf82 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -249,16 +249,16 @@ app:strokeColor="?attr/colorError" /> + android:id="@+id/commitHistoryButton" + style="@style/Widget.Material3.Button.OutlinedButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" + android:text="@string/view_commit_history" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@id/btnAbortMerge" + app:layout_constraintVertical_bias="1.0" /> diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 7c4e23f0e3..a90ba51468 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -117,6 +117,7 @@ object TooltipTag { // Preferences - Git screen const val PREFS_GIT_USERNAME = "prefs.git.username" const val PREFS_GIT_USEREMAIL = "prefs.git.useremail" + const val PREFS_GIT_WATERMARK = "prefs.git.watermark" // Preferences - Developer options screen const val PREFS_DEVOPTIONS_DUMPLOGS = "prefs.devoptions.dumplogs" diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt index 8be1d1f33d..43092e51fa 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt @@ -4,18 +4,25 @@ package com.itsaky.androidide.preferences.internal * Preferences for Git configuration. */ object GitPreferences { - const val GIT_USER_NAME = "git_user_name" - const val GIT_USER_EMAIL = "git_user_email" + const val GIT_USER_NAME = "git_user_name" + const val GIT_USER_EMAIL = "git_user_email" + const val ADD_GIT_COMMIT_WATERMARK = "add_git_commit_watermark" - var userName: String? - get() = prefManager.getString(GIT_USER_NAME, null) - set(value) { - prefManager.putString(GIT_USER_NAME, value) - } + var userName: String? + get() = prefManager.getString(GIT_USER_NAME, null) + set(value) { + prefManager.putString(GIT_USER_NAME, value) + } - var userEmail: String? - get() = prefManager.getString(GIT_USER_EMAIL, null) - set(value) { - prefManager.putString(GIT_USER_EMAIL, value) - } + var userEmail: String? + get() = prefManager.getString(GIT_USER_EMAIL, null) + set(value) { + prefManager.putString(GIT_USER_EMAIL, value) + } + + var shouldAddCommitWatermark: Boolean + get() = prefManager.getBoolean(ADD_GIT_COMMIT_WATERMARK, true) + set(value) { + prefManager.putBoolean(ADD_GIT_COMMIT_WATERMARK, value) + } } diff --git a/resources/src/main/res/drawable/ic_watermark.xml b/resources/src/main/res/drawable/ic_watermark.xml new file mode 100644 index 0000000000..abe69be2ea --- /dev/null +++ b/resources/src/main/res/drawable/ic_watermark.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 28bd208236..ffbdf23cc2 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1534,6 +1534,8 @@ User email Used as author email in commits Configure Git author in Preferences to enable commits.\nTap the avatar for more info. + Add watermark to commits + Appends a Code on the Go credit to commits made from the Git tab. Each project can opt out separately. Committing as %1$s Email - %1$s You can update your Git configuration in Preferences. From f80459bf28bf58202b6ee7d239577021f94cd152 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 18:51:35 +0100 Subject: [PATCH 03/18] feat(ADFA-3760): Show watermark option based on preference --- .../fragments/git/GitBottomSheetFragment.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index b528cc544b..68d28de9c4 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -324,10 +324,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isEnabled = hasSelectable cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) commitSection.visibility = View.VISIBLE - layoutWatermark.visibility = View.VISIBLE - switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> - tvCommitWatermark.isVisible = isChecked - } + updateWatermarkUI() authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE @@ -358,6 +355,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { override fun onResume() { super.onResume() updateAuthorUI() + updateWatermarkUI() } private fun updateAuthorUI() { @@ -368,6 +366,12 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { validateCommitButton() } + private fun updateWatermarkUI() { + val hasChanges = viewModel.gitStatus.value.allChanges().isNotEmpty() + val isRepo = viewModel.isGitRepository.value + binding.layoutWatermark.isVisible = isRepo && hasChanges && GitPreferences.shouldAddCommitWatermark + } + private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() private fun setupCommitUI() { @@ -437,6 +441,9 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) } + binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> + binding.tvCommitWatermark.isVisible = isChecked + } } private fun showAuthorPopup() { From 29cf74d8fafbc47f0d206b0217d0efe0fc4882b2 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 19:37:14 +0100 Subject: [PATCH 04/18] feat(ADFA-3760): Format commit message with watermark --- .../fragments/git/GitBottomSheetFragment.kt | 52 ++++++++++++++----- .../viewmodel/GitBottomSheetViewModel.kt | 5 +- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 68d28de9c4..a7f96864d5 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -324,7 +324,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isEnabled = hasSelectable cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) commitSection.visibility = View.VISIBLE - updateWatermarkUI() + updateWatermarkUI() authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE @@ -355,7 +355,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { override fun onResume() { super.onResume() updateAuthorUI() - updateWatermarkUI() + updateWatermarkUI() } private fun updateAuthorUI() { @@ -366,11 +366,14 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { validateCommitButton() } - private fun updateWatermarkUI() { - val hasChanges = viewModel.gitStatus.value.allChanges().isNotEmpty() - val isRepo = viewModel.isGitRepository.value - binding.layoutWatermark.isVisible = isRepo && hasChanges && GitPreferences.shouldAddCommitWatermark - } + private fun updateWatermarkUI() { + val hasChanges = + viewModel.gitStatus.value + .allChanges() + .isNotEmpty() + val isRepo = viewModel.isGitRepository.value + binding.layoutWatermark.isVisible = isRepo && hasChanges && GitPreferences.shouldAddCommitWatermark + } private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() @@ -419,15 +422,29 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { binding.commitSummary.text ?.toString() ?.trim() ?: "" + val description = binding.commitDescription.text ?.toString() ?.trim() - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( + val watermark = + if (binding.layoutWatermark.isVisible && binding.switchCommitWatermark.isChecked) { + getString(R.string.made_with_code_on_the_go) + } else { + null + } + + val message = + formatCommitMessage( summary = summary, description = description, + watermark = watermark, + ) + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + message = message, selectedPaths = fileChangeAdapter.selectedFiles.toList(), ) { // Clear the inputs on successful commit @@ -441,11 +458,22 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) } - binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> - binding.tvCommitWatermark.isVisible = isChecked - } + binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> + binding.tvCommitWatermark.isVisible = isChecked + } } + fun formatCommitMessage( + summary: String, + description: String? = null, + watermark: String? = null, + ): String = + listOfNotNull( + summary.trim().takeIf(String::isNotEmpty), + description?.trim()?.takeIf(String::isNotEmpty), + watermark?.trim()?.takeIf(String::isNotEmpty), + ).joinToString("\n\n") + private fun showAuthorPopup() { val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } val email = diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index c36f9ab0d3..91a1edd05c 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -263,8 +263,7 @@ class GitBottomSheetViewModel( } fun commitChanges( - summary: String, - description: String? = null, + message: String, selectedPaths: List, onSuccess: () -> Unit, ) { @@ -279,8 +278,6 @@ class GitBottomSheetViewModel( repository.stageFiles(filesToStage) - val message = - if (!description.isNullOrBlank()) "$summary\n\n$description" else summary repository.commit( message = message, authorName = GitPreferences.userName, From 2d9057bbdcbe9f7225a983c5c530baca22a952f3 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 21:23:48 +0100 Subject: [PATCH 05/18] fix(ADFA-3760): sync selected files state --- .../fragments/git/GitBottomSheetFragment.kt | 2 +- .../git/adapter/GitFileChangeAdapter.kt | 297 +++++++++--------- .../main/res/layout/item_git_file_change.xml | 84 ++--- 3 files changed, 199 insertions(+), 184 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index a7f96864d5..0e969995af 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -450,7 +450,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { // Clear the inputs on successful commit binding.commitSummary.text?.clear() binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() + fileChangeAdapter.clearSelection() updateCheckAllButton() } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt index f18fa9a064..940f91fa95 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/adapter/GitFileChangeAdapter.kt @@ -12,147 +12,160 @@ import com.itsaky.androidide.git.core.models.ChangeType import com.itsaky.androidide.git.core.models.FileChange class GitFileChangeAdapter( - private val onFileClicked: (FileChange) -> Unit, - private val onSelectionChanged: (Int) -> Unit = {}, - private val onResolveConflict: (FileChange) -> Unit = {} + private val onFileClicked: (FileChange) -> Unit, + private val onSelectionChanged: (Int) -> Unit = {}, + private val onResolveConflict: (FileChange) -> Unit = {}, ) : ListAdapter(DiffCallback()) { - - // Keep track of which files are selected to be committed - val selectedFiles = mutableSetOf() - - // Conflicted files can't be staged, so they are excluded from "select all". - private val selectablePaths: List - get() = currentList.filter { it.type != ChangeType.CONFLICTED }.map { it.path } - - /** True when every selectable (non-conflicted) file is currently selected. */ - fun areAllSelected(): Boolean = - selectablePaths.isNotEmpty() && selectedFiles.containsAll(selectablePaths) - - /** Select every non-conflicted file. */ - fun selectAll() { - selectedFiles.addAll(selectablePaths) - notifyItemRangeChanged(0, itemCount) - onSelectionChanged(selectedFiles.size) - } - - /** Clear the entire selection. */ - fun clearSelection() { - selectedFiles.clear() - notifyItemRangeChanged(0, itemCount) - onSelectionChanged(selectedFiles.size) - } - - override fun onCurrentListChanged( - previousList: List, - currentList: List - ) { - super.onCurrentListChanged(previousList, currentList) - // Drop selections for files that are no longer in the change set so they - // aren't committed and don't skew areAllSelected()/the commit button. - val currentPaths = currentList.mapTo(HashSet()) { it.path } - if (selectedFiles.retainAll(currentPaths)) { - onSelectionChanged(selectedFiles.size) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = ItemGitFileChangeBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val change = getItem(position) - holder.bind(change) - } - - inner class ViewHolder(private val binding: ItemGitFileChangeBinding) : RecyclerView.ViewHolder(binding.root) { - - init { - itemView.setOnClickListener { - val pos = bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) { - onFileClicked(getItem(pos)) - } - } - - binding.btnMarkResolved.setOnClickListener { - val pos = bindingAdapterPosition - if (pos != RecyclerView.NO_POSITION) { - onResolveConflict(getItem(pos)) - } - } - } - - fun bind(change: FileChange) { - binding.filePath.text = change.path - - val isConflicted = change.type == ChangeType.CONFLICTED - - // Clear listener before setting state to avoid recursive/accidental calls during binding - binding.checkbox.setOnCheckedChangeListener(null) - - binding.checkbox.apply { - isEnabled = !isConflicted - visibility = if (isConflicted) View.INVISIBLE else View.VISIBLE - } - binding.btnMarkResolved.visibility = if (isConflicted) View.VISIBLE else View.GONE - - // Ensure conflicted files are never in selectedFiles - if (isConflicted && selectedFiles.remove(change.path)) { - onSelectionChanged(selectedFiles.size) - } - - binding.checkbox.isChecked = selectedFiles.contains(change.path) - - // Re-set the listener after the state is initialized - binding.checkbox.setOnCheckedChangeListener { _, isChecked -> - val pos = bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return@setOnCheckedChangeListener - - val changeAtPos = getItem(pos) - // Conflicted files should not be selectable - if (changeAtPos.type == ChangeType.CONFLICTED) { - binding.checkbox.isChecked = false - return@setOnCheckedChangeListener - } - - if (isChecked) { - selectedFiles.add(changeAtPos.path) - } else { - selectedFiles.remove(changeAtPos.path) - } - onSelectionChanged(selectedFiles.size) - } - - val contentAlpha = if (isConflicted) 0.5f else 1.0f - binding.filePath.alpha = contentAlpha - binding.statusIcon.alpha = contentAlpha - binding.root.alpha = 1.0f - - val (imageRes, descRes) = when (change.type) { - ChangeType.ADDED -> R.drawable.ic_file_added to R.string.desc_file_added - ChangeType.MODIFIED -> R.drawable.ic_file_modified to R.string.desc_file_modified - ChangeType.DELETED -> R.drawable.ic_file_deleted to R.string.desc_file_deleted - ChangeType.UNTRACKED -> R.drawable.ic_file_added to R.string.desc_file_untracked - ChangeType.RENAMED -> R.drawable.ic_file_renamed to R.string.desc_file_renamed - ChangeType.CONFLICTED -> R.drawable.ic_file_conflicted to R.string.desc_file_conflicted - } - binding.statusIcon.apply { - setImageResource(imageRes) - contentDescription = binding.root.context.getString(descRes) - } - } - } - - class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: FileChange, newItem: FileChange): Boolean { - return oldItem.path == newItem.path - } - - override fun areContentsTheSame(oldItem: FileChange, newItem: FileChange): Boolean { - return oldItem == newItem - } - } + // Keep track of which files are selected to be committed + private val _selectedFiles = mutableSetOf() + val selectedFiles: Set get() = _selectedFiles + + // Conflicted files can't be staged, so they are excluded from "select all". + private val selectablePaths: List + get() = currentList.filter { it.type != ChangeType.CONFLICTED }.map { it.path } + + /** True when every selectable (non-conflicted) file is currently selected. */ + fun areAllSelected(): Boolean = selectablePaths.isNotEmpty() && _selectedFiles.containsAll(selectablePaths) + + /** Select every non-conflicted file. */ + fun selectAll() { + _selectedFiles.addAll(selectablePaths) + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + + /** Clear the entire selection. */ + fun clearSelection() { + _selectedFiles.clear() + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + + override fun onCurrentListChanged( + previousList: List, + currentList: List, + ) { + super.onCurrentListChanged(previousList, currentList) + // Drop selections for files that are no longer in the change set so they + // aren't committed and don't skew areAllSelected()/the commit button. + val currentPaths = currentList.mapTo(HashSet()) { it.path } + if (_selectedFiles.retainAll(currentPaths)) { + notifyItemRangeChanged(0, itemCount) + onSelectionChanged(_selectedFiles.size) + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ViewHolder { + val binding = + ItemGitFileChangeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int, + ) { + val change = getItem(position) + holder.bind(change) + } + + inner class ViewHolder( + private val binding: ItemGitFileChangeBinding, + ) : RecyclerView.ViewHolder(binding.root) { + init { + itemView.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onFileClicked(getItem(pos)) + } + } + + binding.btnMarkResolved.setOnClickListener { + val pos = bindingAdapterPosition + if (pos != RecyclerView.NO_POSITION) { + onResolveConflict(getItem(pos)) + } + } + } + + fun bind(change: FileChange) { + binding.filePath.text = change.path + + val isConflicted = change.type == ChangeType.CONFLICTED + + // Clear listener before setting state to avoid recursive/accidental calls during binding + binding.checkbox.setOnCheckedChangeListener(null) + + binding.checkbox.apply { + isEnabled = !isConflicted + visibility = if (isConflicted) View.INVISIBLE else View.VISIBLE + } + binding.btnMarkResolved.visibility = if (isConflicted) View.VISIBLE else View.GONE + + // Ensure conflicted files are never in selectedFiles + if (isConflicted && _selectedFiles.remove(change.path)) { + onSelectionChanged(_selectedFiles.size) + } + + binding.checkbox.isChecked = _selectedFiles.contains(change.path) + + // Re-set the listener after the state is initialized + binding.checkbox.setOnCheckedChangeListener { _, isChecked -> + val pos = bindingAdapterPosition + if (pos == RecyclerView.NO_POSITION) return@setOnCheckedChangeListener + + val changeAtPos = getItem(pos) + // Conflicted files should not be selectable + if (changeAtPos.type == ChangeType.CONFLICTED) { + binding.checkbox.isChecked = false + return@setOnCheckedChangeListener + } + + if (isChecked) { + _selectedFiles.add(changeAtPos.path) + } else { + _selectedFiles.remove(changeAtPos.path) + } + onSelectionChanged(_selectedFiles.size) + } + + val contentAlpha = if (isConflicted) 0.5f else 1.0f + binding.filePath.alpha = contentAlpha + binding.statusIcon.alpha = contentAlpha + binding.root.alpha = 1.0f + + val (imageRes, descRes) = + when (change.type) { + ChangeType.ADDED -> R.drawable.ic_file_added to R.string.desc_file_added + ChangeType.MODIFIED -> R.drawable.ic_file_modified to R.string.desc_file_modified + ChangeType.DELETED -> R.drawable.ic_file_deleted to R.string.desc_file_deleted + ChangeType.UNTRACKED -> R.drawable.ic_file_added to R.string.desc_file_untracked + ChangeType.RENAMED -> R.drawable.ic_file_renamed to R.string.desc_file_renamed + ChangeType.CONFLICTED -> R.drawable.ic_file_conflicted to R.string.desc_file_conflicted + } + binding.statusIcon.apply { + setImageResource(imageRes) + contentDescription = binding.root.context.getString(descRes) + } + } + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: FileChange, + newItem: FileChange, + ): Boolean = oldItem.path == newItem.path + + override fun areContentsTheSame( + oldItem: FileChange, + newItem: FileChange, + ): Boolean = oldItem == newItem + } } diff --git a/app/src/main/res/layout/item_git_file_change.xml b/app/src/main/res/layout/item_git_file_change.xml index deaf345361..dcc9fd3cfe 100644 --- a/app/src/main/res/layout/item_git_file_change.xml +++ b/app/src/main/res/layout/item_git_file_change.xml @@ -1,48 +1,50 @@ - + - + - + - + - + From a2c09709003117c553b74dab36bf5b869c12b88d Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Mon, 7 Sep 2026 22:06:30 +0100 Subject: [PATCH 06/18] fix(ADFA-3760): Address PR comments --- .../androidide/fragments/git/GitBottomSheetFragment.kt | 2 ++ .../java/com/itsaky/androidide/preferences/gitPrefExts.kt | 3 --- app/src/main/res/layout/fragment_git_bottom_sheet.xml | 5 ++--- resources/src/main/res/drawable/bg_commit_watermark.xml | 2 +- resources/src/main/res/values/strings.xml | 4 ++-- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 0e969995af..f27d0f7fff 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -458,6 +458,8 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) } + binding.switchCommitWatermark.isChecked = GitPreferences.shouldAddCommitWatermark + binding.tvCommitWatermark.isVisible = binding.switchCommitWatermark.isChecked binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> binding.tvCommitWatermark.isVisible = isChecked } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index d73e4880fc..de2896c035 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -8,9 +8,6 @@ import com.itsaky.androidide.idetooltips.TooltipTag.PREFS_GIT_USERNAME import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.preferences.internal.EditorPreferences -import com.itsaky.androidide.preferences.internal.EditorPreferences.USE_ICU -import com.itsaky.androidide.preferences.internal.EditorPreferences.useIcu import com.itsaky.androidide.resources.R.drawable import com.itsaky.androidide.resources.R.string import kotlinx.parcelize.Parcelize diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 1a328ddf82..29e2c2fbfa 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -196,7 +196,7 @@ android:id="@+id/switch_commit_watermark" android:layout_width="match_parent" android:layout_height="wrap_content" - android:checked="false" + android:checked="true" android:text="@string/add_commit_watermark" /> + android:visibility="visible" /> diff --git a/resources/src/main/res/drawable/bg_commit_watermark.xml b/resources/src/main/res/drawable/bg_commit_watermark.xml index 541a59a50f..481688565a 100644 --- a/resources/src/main/res/drawable/bg_commit_watermark.xml +++ b/resources/src/main/res/drawable/bg_commit_watermark.xml @@ -2,6 +2,6 @@ - + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ffbdf23cc2..c83454110d 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1523,7 +1523,7 @@ Initializing clone… Cancelling clone… Add commit message watermark - Made with Code on the Go (appdevforall.org) + Made with Code on the Go (appdevforall.org) Set and update Git configuration @@ -1535,7 +1535,7 @@ Used as author email in commits Configure Git author in Preferences to enable commits.\nTap the avatar for more info. Add watermark to commits - Appends a Code on the Go credit to commits made from the Git tab. Each project can opt out separately. + Appends a Code on the Go credit to commits made from the Git tab. You can opt out within the Git tab. Committing as %1$s Email - %1$s You can update your Git configuration in Preferences. From fd3c14e9aac28c31971560b0d01de0d2a966b3ed Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Tue, 8 Sep 2026 12:46:32 +0100 Subject: [PATCH 07/18] feat(ADFA-3760): Disable watermark per project --- .../fragments/git/GitBottomSheetFragment.kt | 107 +++++++++++++++--- .../androidide/preferences/gitPrefExts.kt | 6 +- .../res/layout/fragment_git_bottom_sheet.xml | 9 ++ .../preferences/internal/GitPreferences.kt | 37 +++++- resources/src/main/res/values/strings.xml | 3 +- 5 files changed, 136 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index f27d0f7fff..deeeef7eb4 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -9,6 +9,7 @@ import android.text.style.ClickableSpan import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment @@ -34,6 +35,7 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.preferences.internal.GitPreferences +import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress @@ -56,6 +58,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { private lateinit var branchPopupWindow: GitBranchPopupWindow private var _binding: FragmentGitBottomSheetBinding? = null + private var isUpdatingWatermarkUI = false val binding: FragmentGitBottomSheetBinding get() = checkNotNull(_binding) { "Fragment binding is null or view has been destroyed" } @@ -324,7 +327,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { cbCheckAll.isEnabled = hasSelectable cbCheckAll.text = getString(R.string.changed_files_count, allChanges.size) commitSection.visibility = View.VISIBLE - updateWatermarkUI() + updateWatermarkUI(isRepo = isRepo, hasChanges = allChanges.isNotEmpty()) authorWarning.visibility = if (hasAuthorInfo()) View.GONE else View.VISIBLE commitHistoryButton.visibility = View.VISIBLE @@ -366,13 +369,58 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { validateCommitButton() } - private fun updateWatermarkUI() { - val hasChanges = + private fun getProjectPath(): String? { + val rawPath = + viewModel.currentRepository?.rootDir?.path + ?: runCatching { IProjectManager.getInstance().projectDirPath }.getOrNull()?.takeIf { it.isNotBlank() } + ?: return null + return GitPreferences.getCanonicalProjectPath(rawPath) + } + + private fun updateWatermarkUI( + isRepo: Boolean = viewModel.isGitRepository.value, + hasChanges: Boolean = viewModel.gitStatus.value .allChanges() - .isNotEmpty() - val isRepo = viewModel.isGitRepository.value - binding.layoutWatermark.isVisible = isRepo && hasChanges && GitPreferences.shouldAddCommitWatermark + .isNotEmpty(), + ) { + if (!isRepo || !hasChanges) { + binding.layoutWatermark.isGone = true + return + } + + binding.layoutWatermark.isVisible = true + isUpdatingWatermarkUI = true + + try { + if (!GitPreferences.shouldAddGlobalCommitWatermark) { + showGlobalWatermarkDisabled() + } else { + showProjectWatermarkState() + } + } finally { + isUpdatingWatermarkUI = false + } + } + + private fun showGlobalWatermarkDisabled() { + binding.apply { + switchCommitWatermark.isEnabled = false + switchCommitWatermark.isChecked = false + tvCommitWatermark.isGone = true + tvWatermarkGlobalDisabled.isVisible = true + } + } + + private fun showProjectWatermarkState() { + val projectEnabled = GitPreferences.isProjectWatermarkEnabled(getProjectPath()) + + binding.apply { + switchCommitWatermark.isEnabled = true + switchCommitWatermark.isChecked = projectEnabled + tvCommitWatermark.isVisible = projectEnabled + tvWatermarkGlobalDisabled.isGone = true + } } private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() @@ -428,12 +476,14 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { ?.toString() ?.trim() + val projectPath = getProjectPath() val watermark = - if (binding.layoutWatermark.isVisible && binding.switchCommitWatermark.isChecked) { - getString(R.string.made_with_code_on_the_go) - } else { - null - } + getString(R.string.made_with_code_on_the_go) + .takeIf { + binding.layoutWatermark.isVisible && + binding.switchCommitWatermark.isChecked && + GitPreferences.isWatermarkEnabled(projectPath) + } val message = formatCommitMessage( @@ -458,23 +508,44 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) } - binding.switchCommitWatermark.isChecked = GitPreferences.shouldAddCommitWatermark - binding.tvCommitWatermark.isVisible = binding.switchCommitWatermark.isChecked binding.switchCommitWatermark.setOnCheckedChangeListener { _, isChecked -> + if (isUpdatingWatermarkUI) { + return@setOnCheckedChangeListener + } + val projectPath = getProjectPath() + GitPreferences.enableProjectWatermark(projectPath, isChecked) binding.tvCommitWatermark.isVisible = isChecked } + updateWatermarkUI() } fun formatCommitMessage( summary: String, description: String? = null, watermark: String? = null, - ): String = - listOfNotNull( - summary.trim().takeIf(String::isNotEmpty), - description?.trim()?.takeIf(String::isNotEmpty), - watermark?.trim()?.takeIf(String::isNotEmpty), + ): String { + val trimmedSummary = summary.trim() + val trimmedDescription = description?.trim()?.takeIf(String::isNotEmpty) + val trimmedWatermark = watermark?.trim()?.takeIf(String::isNotEmpty) + + val effectiveWatermark = + trimmedWatermark + ?.takeUnless { containsWatermark(trimmedSummary, trimmedDescription, it) } + + return listOfNotNull( + trimmedSummary.takeIf(String::isNotEmpty), + trimmedDescription, + effectiveWatermark, ).joinToString("\n\n") + } + + internal fun containsWatermark( + summary: String, + description: String?, + watermark: String, + ): Boolean = + summary.contains(watermark, ignoreCase = true) || + (description?.contains(watermark, ignoreCase = true) == true) private fun showAuthorPopup() { val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index de2896c035..915275acf0 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -117,12 +117,12 @@ class GitUserEmail( @Parcelize private class AddGitCommitWatermark( - override val key: String = GitPreferences.ADD_GIT_COMMIT_WATERMARK, + override val key: String = GitPreferences.ADD_GLOBAL_COMMIT_WATERMARK, override val title: Int = string.idepref_git_add_commit_watermark, override val summary: Int? = string.idepref_git_add_commit_watermark_summary, override val icon: Int? = drawable.ic_watermark, override val tooltipTag: String = TooltipTag.PREFS_GIT_WATERMARK, ) : SwitchPreference( - setValue = GitPreferences::shouldAddCommitWatermark::set, - getValue = GitPreferences::shouldAddCommitWatermark::get + setValue = GitPreferences::shouldAddGlobalCommitWatermark::set, + getValue = GitPreferences::shouldAddGlobalCommitWatermark::get ) diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 29e2c2fbfa..45ef0d7fad 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -206,6 +206,15 @@ android:text="@string/made_with_code_on_the_go" android:visibility="visible" /> + + Cancelling clone… Add commit message watermark Made with Code on the Go (appdevforall.org) + Disabled globally in Preferences Set and update Git configuration @@ -1535,7 +1536,7 @@ Used as author email in commits Configure Git author in Preferences to enable commits.\nTap the avatar for more info. Add watermark to commits - Appends a Code on the Go credit to commits made from the Git tab. You can opt out within the Git tab. + Automatically adds a Code on the Go credit to Git commits. You can disable it for individual projects in the Git tab. Committing as %1$s Email - %1$s You can update your Git configuration in Preferences. From ac2d9ef6648f29ec4530ef94279230e6871a76d4 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Tue, 8 Sep 2026 15:15:26 +0100 Subject: [PATCH 08/18] test(ADFA-3760): Test git watermark --- .../fragments/git/GitCommitWatermarkTest.kt | 80 ++++++++++++ .../preferences/GitPreferencesTest.kt | 121 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt diff --git a/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt new file mode 100644 index 0000000000..427e22f7f4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/git/GitCommitWatermarkTest.kt @@ -0,0 +1,80 @@ +package com.itsaky.androidide.fragments.git + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class GitCommitWatermarkTest { + private val fragment = GitBottomSheetFragment() + private val watermark = "Made with Code on the Go (appdevforall.org)" + + @Test + fun `formatCommitMessage appends watermark to summary and description`() { + val summary = "feat: add user profile" + val description = "Detailed description of user profile implementation." + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description\n\n$watermark") + } + + @Test + fun `formatCommitMessage appends watermark when description is null or empty`() { + val summary = "fix: resolve crash on startup" + + val resultNullDesc = fragment.formatCommitMessage(summary, null, watermark) + assertThat(resultNullDesc).isEqualTo("$summary\n\n$watermark") + + val resultEmptyDesc = fragment.formatCommitMessage(summary, "", watermark) + assertThat(resultEmptyDesc).isEqualTo("$summary\n\n$watermark") + + val resultWhitespaceDesc = fragment.formatCommitMessage(summary, " \n\n ", watermark) + assertThat(resultWhitespaceDesc).isEqualTo("$summary\n\n$watermark") + } + + @Test + fun `formatCommitMessage omits watermark when watermark is null or empty`() { + val summary = "chore: update dependencies" + val description = "Bump gradle plugins" + + val resultNull = fragment.formatCommitMessage(summary, description, null) + assertThat(resultNull).isEqualTo("$summary\n\n$description") + + val resultEmpty = fragment.formatCommitMessage(summary, description, "") + assertThat(resultEmpty).isEqualTo("$summary\n\n$description") + + val resultBlank = fragment.formatCommitMessage(summary, description, " ") + assertThat(resultBlank).isEqualTo("$summary\n\n$description") + } + + @Test + fun `formatCommitMessage deduplicates when summary already contains watermark`() { + val summary = "docs: Made with Code on the Go (appdevforall.org)" + val description = "Updated readme with attribution." + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + assertThat(result.split(watermark).size - 1).isEqualTo(1) + } + + @Test + fun `formatCommitMessage deduplicates when description already contains watermark`() { + val summary = "feat: new feature" + val description = "Some notes\nMade with Code on the Go (appdevforall.org)" + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + assertThat(result.split(watermark).size - 1).isEqualTo(1) + } + + @Test + fun `formatCommitMessage deduplicates case-insensitively`() { + val summary = "feat: new feature" + val description = "notes\nmade with code on the go (appdevforall.org)" + + val result = fragment.formatCommitMessage(summary, description, watermark) + + assertThat(result).isEqualTo("$summary\n\n$description") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt new file mode 100644 index 0000000000..bae6eba9db --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt @@ -0,0 +1,121 @@ +package com.itsaky.androidide.preferences + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.preferences.internal.GitPreferences +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GitPreferencesTest { + private val projectA = "/storage/emulated/0/CodeOnTheGoProjects/ProjectA" + private val projectB = "/storage/emulated/0/CodeOnTheGoProjects/ProjectB" + + @Before + fun setUp() { + System.setProperty("androidide.test.mode", "true") + GitPreferences.shouldAddGlobalCommitWatermark = true + GitPreferences.enableProjectWatermark(projectA, true) + GitPreferences.enableProjectWatermark(projectB, true) + } + + @After + fun tearDown() { + GitPreferences.shouldAddGlobalCommitWatermark = true + GitPreferences.enableProjectWatermark(projectA, true) + GitPreferences.enableProjectWatermark(projectB, true) + } + + @Test + fun `project watermark defaults to true`() { + val newProject = "/storage/emulated/0/CodeOnTheGoProjects/NewProject" + assertThat(GitPreferences.isProjectWatermarkEnabled(newProject)).isTrue() + } + + @Test + fun `project watermark persists false when opted out`() { + GitPreferences.enableProjectWatermark(projectA, false) + assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() + } + + @Test + fun `project watermark persists true when re-enabled`() { + GitPreferences.enableProjectWatermark(projectA, false) + assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() + + GitPreferences.enableProjectWatermark(projectA, true) + assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isTrue() + } + + @Test + fun `project watermark settings are isolated per project`() { + GitPreferences.enableProjectWatermark(projectA, false) + GitPreferences.enableProjectWatermark(projectB, true) + + assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() + assertThat(GitPreferences.isProjectWatermarkEnabled(projectB)).isTrue() + } + + @Test + fun `project watermark handles null or blank project path gracefully`() { + assertThat(GitPreferences.isProjectWatermarkEnabled(null)).isTrue() + assertThat(GitPreferences.isProjectWatermarkEnabled("")).isTrue() + assertThat(GitPreferences.isProjectWatermarkEnabled(" ")).isTrue() + + // Setting with null or blank should not throw or corrupt + GitPreferences.enableProjectWatermark(null, false) + GitPreferences.enableProjectWatermark("", false) + GitPreferences.enableProjectWatermark(" ", false) + + assertThat(GitPreferences.isProjectWatermarkEnabled(null)).isTrue() + } + + @Test + fun `isWatermarkEnabled returns true only when both global and project are enabled`() { + // Global: true, Project: true -> true + GitPreferences.shouldAddGlobalCommitWatermark = true + GitPreferences.enableProjectWatermark(projectA, true) + assertThat(GitPreferences.isWatermarkEnabled(projectA)).isTrue() + + // Global: true, Project: false -> false (Scenario: Global setting respects project opt-out) + GitPreferences.enableProjectWatermark(projectA, false) + assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() + + // Global: false, Project: true -> false (Scenario: Global opt-out) + GitPreferences.shouldAddGlobalCommitWatermark = false + GitPreferences.enableProjectWatermark(projectA, true) + assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() + + // Global: false, Project: false -> false + GitPreferences.enableProjectWatermark(projectA, false) + assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() + } + + @Test + fun `getProjectWatermarkKey formats correctly`() { + val key = GitPreferences.getProjectWatermarkKey(" /path/to/repo ") + assertThat(key).isEqualTo("${GitPreferences.PROJECT_WATERMARK_PREFIX}/path/to/repo") + } + + @Test + fun `equivalent project paths resolve to the same preference key`() { + val canonicalPath = "/storage/emulated/0/CodeOnTheGoProjects/ProjectA" + val redundantPath = "/storage/emulated/0/./CodeOnTheGoProjects/ProjectA" + val traversalPath = "/storage/emulated/0/CodeOnTheGoProjects/../CodeOnTheGoProjects/ProjectA" + + assertThat(GitPreferences.getProjectWatermarkKey(redundantPath)) + .isEqualTo(GitPreferences.getProjectWatermarkKey(canonicalPath)) + assertThat(GitPreferences.getProjectWatermarkKey(traversalPath)) + .isEqualTo(GitPreferences.getProjectWatermarkKey(canonicalPath)) + + // Opt out via redundant path, verify read via canonical path + GitPreferences.enableProjectWatermark(redundantPath, false) + assertThat(GitPreferences.isProjectWatermarkEnabled(canonicalPath)).isFalse() + + // Re-enable via traversal path, verify read via canonical path + GitPreferences.enableProjectWatermark(traversalPath, true) + assertThat(GitPreferences.isProjectWatermarkEnabled(canonicalPath)).isTrue() + } +} From d8a6ba071ead3c9718929d1b55febdb371bdd4a2 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 12:36:49 +0100 Subject: [PATCH 09/18] fix(ADFA-3760): Sync switch checked state --- .../androidide/fragments/git/GitBottomSheetFragment.kt | 3 ++- app/src/main/res/layout/fragment_git_bottom_sheet.xml | 7 ++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index deeeef7eb4..acf793b05f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -404,9 +404,10 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } private fun showGlobalWatermarkDisabled() { + val projectEnabled = GitPreferences.isProjectWatermarkEnabled(getProjectPath()) binding.apply { switchCommitWatermark.isEnabled = false - switchCommitWatermark.isChecked = false + switchCommitWatermark.isChecked = projectEnabled tvCommitWatermark.isGone = true tvWatermarkGlobalDisabled.isVisible = true } diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 45ef0d7fad..0c11ad164d 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -196,15 +196,13 @@ android:id="@+id/switch_commit_watermark" android:layout_width="match_parent" android:layout_height="wrap_content" - android:checked="true" android:text="@string/add_commit_watermark" /> + android:text="@string/made_with_code_on_the_go" /> + android:textColor="?attr/colorOnSurfaceVariant" /> From 6dd165e710bb78f4e3a45bf35d9f506fd7f5db49 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 14:21:22 +0100 Subject: [PATCH 10/18] feat(ADFA-3760): Save watermark preference in Git config --- .../fragments/git/GitBottomSheetFragment.kt | 27 +++++++-------- .../viewmodel/GitBottomSheetViewModel.kt | 15 +++++++++ .../res/layout/fragment_git_bottom_sheet.xml | 7 ++-- .../androidide/git/core/GitRepository.kt | 5 +++ .../androidide/git/core/JGitRepository.kt | 33 +++++++++++++++++++ 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index acf793b05f..bec48b2551 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -280,6 +280,12 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.isProjectWatermarkEnabled.collectLatest { + updateWatermarkUI() + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus, @@ -369,14 +375,6 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { validateCommitButton() } - private fun getProjectPath(): String? { - val rawPath = - viewModel.currentRepository?.rootDir?.path - ?: runCatching { IProjectManager.getInstance().projectDirPath }.getOrNull()?.takeIf { it.isNotBlank() } - ?: return null - return GitPreferences.getCanonicalProjectPath(rawPath) - } - private fun updateWatermarkUI( isRepo: Boolean = viewModel.isGitRepository.value, hasChanges: Boolean = @@ -404,7 +402,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } private fun showGlobalWatermarkDisabled() { - val projectEnabled = GitPreferences.isProjectWatermarkEnabled(getProjectPath()) + val projectEnabled = viewModel.isProjectWatermarkEnabled.value binding.apply { switchCommitWatermark.isEnabled = false switchCommitWatermark.isChecked = projectEnabled @@ -414,7 +412,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } private fun showProjectWatermarkState() { - val projectEnabled = GitPreferences.isProjectWatermarkEnabled(getProjectPath()) + val projectEnabled = viewModel.isProjectWatermarkEnabled.value binding.apply { switchCommitWatermark.isEnabled = true @@ -477,13 +475,11 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { ?.toString() ?.trim() - val projectPath = getProjectPath() val watermark = getString(R.string.made_with_code_on_the_go) .takeIf { binding.layoutWatermark.isVisible && - binding.switchCommitWatermark.isChecked && - GitPreferences.isWatermarkEnabled(projectPath) + binding.switchCommitWatermark.isChecked } val message = @@ -513,9 +509,10 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { if (isUpdatingWatermarkUI) { return@setOnCheckedChangeListener } - val projectPath = getProjectPath() - GitPreferences.enableProjectWatermark(projectPath, isChecked) binding.tvCommitWatermark.isVisible = isChecked + if (viewModel.isProjectWatermarkEnabled.value != isChecked) { + viewModel.setProjectWatermarkEnabled(isChecked) + } } updateWatermarkUI() } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 91a1edd05c..50a83ab110 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -77,6 +77,9 @@ class GitBottomSheetViewModel( private val _mergeState = MutableStateFlow(MergeUiState.Idle) val mergeState: StateFlow = _mergeState.asStateFlow() + private val _isProjectWatermarkEnabled = MutableStateFlow(true) + val isProjectWatermarkEnabled: StateFlow = _isProjectWatermarkEnabled.asStateFlow() + private var initJob: Job? = null private var pullResetJob: Job? = null private var pushResetJob: Job? = null @@ -125,6 +128,7 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true return@launch } val projectDir = File(projectDirPath) @@ -136,6 +140,7 @@ class GitBottomSheetViewModel( currentRepository = GitRepositoryManager.openRepository(projectDir) _isGitRepository.value = currentRepository != null } + _isProjectWatermarkEnabled.value = currentRepository?.isCommitWatermarkEnabled() ?: true refreshStatus() } catch (e: CancellationException) { throw e @@ -147,6 +152,7 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true } } } @@ -163,10 +169,12 @@ class GitBottomSheetViewModel( _currentBranch.value = null _branches.value = BranchesUiState.None _localCommitsCount.value = 0 + _isProjectWatermarkEnabled.value = true return@launch } try { + _isProjectWatermarkEnabled.value = repo.isCommitWatermarkEnabled() val status = repo.getStatus() _gitStatus.value = status _currentBranch.value = repo.getCurrentBranch()?.name @@ -726,4 +734,11 @@ class GitBottomSheetViewModel( } } } + + fun setProjectWatermarkEnabled(enabled: Boolean) { + _isProjectWatermarkEnabled.value = enabled + viewModelScope.launch { + currentRepository?.setCommitWatermarkEnabled(enabled) + } + } } diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index 0c11ad164d..d7a5f66f53 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -2,6 +2,7 @@ @@ -119,7 +120,6 @@ android:clipToPadding="false" android:fadeScrollbars="false" android:paddingVertical="4dp" - android:paddingEnd="8dp" android:scrollbarStyle="outsideOverlay" android:scrollbars="vertical" app:layout_constrainedHeight="true" @@ -189,13 +189,13 @@ android:background="@drawable/bg_commit_watermark" android:orientation="vertical" android:padding="8dp" - android:visibility="gone" app:layout_constraintTop_toBottomOf="@id/commitDescriptionLayout"> + android:textColor="?attr/colorOnSurfaceVariant" + android:visibility="gone" /> diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt index 39ee4c1968..5fc87f92f0 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/GitRepository.kt @@ -30,6 +30,11 @@ interface GitRepository : Closeable { // Commit Operations suspend fun stageFiles(files: List) + // Git commit watermark configurations + suspend fun isCommitWatermarkEnabled(): Boolean + + suspend fun setCommitWatermarkEnabled(enabled: Boolean) + suspend fun commit( message: String, authorName: String? = null, diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 23a5c05c6c..02e00ffd32 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -42,6 +42,11 @@ import java.io.File class JGitRepository( override val rootDir: File, ) : GitRepository { + companion object { + private const val CONFIG_SECTION_COTG = "cotg" + private const val CONFIG_KEY_WATERMARK = "commit-watermark" + } + private val log = LoggerFactory.getLogger(JGitRepository::class.java) private val repository: Repository = @@ -215,6 +220,34 @@ class JGitRepository( Unit } + override suspend fun isCommitWatermarkEnabled(): Boolean = + withContext(Dispatchers.IO) { + try { + repository.config.load() + repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) + } catch (e: Exception) { + log.error("Error reading commit watermark config", e) + true + } + } + + override suspend fun setCommitWatermarkEnabled(enabled: Boolean) { + try { + withContext(Dispatchers.IO) { + repository.config.load() + repository.config.setBoolean( + CONFIG_SECTION_COTG, + null, + CONFIG_KEY_WATERMARK, + enabled, + ) + repository.config.save() + } + } catch (e: Exception) { + log.error("Error saving commit watermark config", e) + } + } + override suspend fun commit( message: String, authorName: String?, From 77bcfee27d0b88de1bef07796c063967f708f3ad Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 14:39:19 +0100 Subject: [PATCH 11/18] test(ADFA-3760): Write unit tests --- .../preferences/GitPreferencesTest.kt | 96 ++----------------- .../viewmodel/GitBottomSheetViewModelTest.kt | 28 ++++++ .../androidide/git/core/JGitRepositoryTest.kt | 39 ++++++++ .../preferences/internal/GitPreferences.kt | 29 ------ 4 files changed, 76 insertions(+), 116 deletions(-) diff --git a/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt index bae6eba9db..c8e0826c00 100644 --- a/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/preferences/GitPreferencesTest.kt @@ -10,112 +10,34 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class GitPreferencesTest { - private val projectA = "/storage/emulated/0/CodeOnTheGoProjects/ProjectA" - private val projectB = "/storage/emulated/0/CodeOnTheGoProjects/ProjectB" - @Before fun setUp() { System.setProperty("androidide.test.mode", "true") GitPreferences.shouldAddGlobalCommitWatermark = true - GitPreferences.enableProjectWatermark(projectA, true) - GitPreferences.enableProjectWatermark(projectB, true) } @After fun tearDown() { GitPreferences.shouldAddGlobalCommitWatermark = true - GitPreferences.enableProjectWatermark(projectA, true) - GitPreferences.enableProjectWatermark(projectB, true) - } - - @Test - fun `project watermark defaults to true`() { - val newProject = "/storage/emulated/0/CodeOnTheGoProjects/NewProject" - assertThat(GitPreferences.isProjectWatermarkEnabled(newProject)).isTrue() - } - - @Test - fun `project watermark persists false when opted out`() { - GitPreferences.enableProjectWatermark(projectA, false) - assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() - } - - @Test - fun `project watermark persists true when re-enabled`() { - GitPreferences.enableProjectWatermark(projectA, false) - assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() - - GitPreferences.enableProjectWatermark(projectA, true) - assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isTrue() - } - - @Test - fun `project watermark settings are isolated per project`() { - GitPreferences.enableProjectWatermark(projectA, false) - GitPreferences.enableProjectWatermark(projectB, true) - - assertThat(GitPreferences.isProjectWatermarkEnabled(projectA)).isFalse() - assertThat(GitPreferences.isProjectWatermarkEnabled(projectB)).isTrue() } @Test - fun `project watermark handles null or blank project path gracefully`() { - assertThat(GitPreferences.isProjectWatermarkEnabled(null)).isTrue() - assertThat(GitPreferences.isProjectWatermarkEnabled("")).isTrue() - assertThat(GitPreferences.isProjectWatermarkEnabled(" ")).isTrue() - - // Setting with null or blank should not throw or corrupt - GitPreferences.enableProjectWatermark(null, false) - GitPreferences.enableProjectWatermark("", false) - GitPreferences.enableProjectWatermark(" ", false) - - assertThat(GitPreferences.isProjectWatermarkEnabled(null)).isTrue() + fun `global commit watermark defaults to true`() { + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isTrue() } @Test - fun `isWatermarkEnabled returns true only when both global and project are enabled`() { - // Global: true, Project: true -> true - GitPreferences.shouldAddGlobalCommitWatermark = true - GitPreferences.enableProjectWatermark(projectA, true) - assertThat(GitPreferences.isWatermarkEnabled(projectA)).isTrue() - - // Global: true, Project: false -> false (Scenario: Global setting respects project opt-out) - GitPreferences.enableProjectWatermark(projectA, false) - assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() - - // Global: false, Project: true -> false (Scenario: Global opt-out) + fun `global commit watermark persists false when disabled`() { GitPreferences.shouldAddGlobalCommitWatermark = false - GitPreferences.enableProjectWatermark(projectA, true) - assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() - - // Global: false, Project: false -> false - GitPreferences.enableProjectWatermark(projectA, false) - assertThat(GitPreferences.isWatermarkEnabled(projectA)).isFalse() + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isFalse() } @Test - fun `getProjectWatermarkKey formats correctly`() { - val key = GitPreferences.getProjectWatermarkKey(" /path/to/repo ") - assertThat(key).isEqualTo("${GitPreferences.PROJECT_WATERMARK_PREFIX}/path/to/repo") - } - - @Test - fun `equivalent project paths resolve to the same preference key`() { - val canonicalPath = "/storage/emulated/0/CodeOnTheGoProjects/ProjectA" - val redundantPath = "/storage/emulated/0/./CodeOnTheGoProjects/ProjectA" - val traversalPath = "/storage/emulated/0/CodeOnTheGoProjects/../CodeOnTheGoProjects/ProjectA" - - assertThat(GitPreferences.getProjectWatermarkKey(redundantPath)) - .isEqualTo(GitPreferences.getProjectWatermarkKey(canonicalPath)) - assertThat(GitPreferences.getProjectWatermarkKey(traversalPath)) - .isEqualTo(GitPreferences.getProjectWatermarkKey(canonicalPath)) - - // Opt out via redundant path, verify read via canonical path - GitPreferences.enableProjectWatermark(redundantPath, false) - assertThat(GitPreferences.isProjectWatermarkEnabled(canonicalPath)).isFalse() + fun `global commit watermark persists true when re-enabled`() { + GitPreferences.shouldAddGlobalCommitWatermark = false + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isFalse() - // Re-enable via traversal path, verify read via canonical path - GitPreferences.enableProjectWatermark(traversalPath, true) - assertThat(GitPreferences.isProjectWatermarkEnabled(canonicalPath)).isTrue() + GitPreferences.shouldAddGlobalCommitWatermark = true + assertThat(GitPreferences.shouldAddGlobalCommitWatermark).isTrue() } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index bf398f28a4..a3d1de28a7 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -245,4 +245,32 @@ class GitBottomSheetViewModelTest { assertEquals(null, viewModel.currentBranch.value) assertEquals(GitBottomSheetViewModel.BranchesUiState.None, viewModel.branches.value) } + + @Test + fun `isProjectWatermarkEnabled reflects repository setting on refreshStatus`() = + runTest { + coEvery { repository.isCommitWatermarkEnabled() } returns false + coEvery { repository.getStatus() } returns mockk(relaxed = true) + + viewModel.refreshStatus() + advanceUntilIdle() + + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + } + + @Test + fun `setProjectWatermarkEnabled updates state flow and delegates to repository`() = + runTest { + viewModel.setProjectWatermarkEnabled(false) + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + coVerify { repository.setCommitWatermarkEnabled(false) } + + viewModel.setProjectWatermarkEnabled(true) + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + coVerify { repository.setCommitWatermarkEnabled(true) } + } } diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index 4d7345fe3b..c7e4db0c67 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -240,4 +240,43 @@ class JGitRepositoryTest { jgitRepo.getBranches().any { it.name == "origin-release" }, ) } + + @Test + fun testCommitWatermarkDefaultsToTrue() = + runBlocking { + assertTrue(jgitRepo.isCommitWatermarkEnabled()) + } + + @Test + fun testSetCommitWatermarkPersistsInGitConfig() = + runBlocking { + jgitRepo.setCommitWatermarkEnabled(false) + assertFalse(jgitRepo.isCommitWatermarkEnabled()) + + val configFile = File(repoDir, ".git/config") + assertTrue("config file must exist", configFile.exists()) + val configContent = configFile.readText() + assertTrue("config must contain cotg section", configContent.contains("[cotg]")) + assertTrue("config must set commit-watermark to false", configContent.contains("commit-watermark = false")) + + // Verify a new repository instance reading from disk also sees false + JGitRepository(repoDir).use { freshRepo -> + assertFalse(freshRepo.isCommitWatermarkEnabled()) + } + } + + @Test + fun testToggleCommitWatermarkBackToTrue() = + runBlocking { + jgitRepo.setCommitWatermarkEnabled(false) + assertFalse(jgitRepo.isCommitWatermarkEnabled()) + + jgitRepo.setCommitWatermarkEnabled(true) + assertTrue(jgitRepo.isCommitWatermarkEnabled()) + + // Verify a new repository instance reading from disk also sees true + JGitRepository(repoDir).use { freshRepo -> + assertTrue(freshRepo.isCommitWatermarkEnabled()) + } + } } diff --git a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt index 78a9f0e8fe..89121b3d76 100644 --- a/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt +++ b/preferences/src/main/java/com/itsaky/androidide/preferences/internal/GitPreferences.kt @@ -1,7 +1,5 @@ package com.itsaky.androidide.preferences.internal -import java.io.File - /** * Preferences for Git configuration. */ @@ -9,7 +7,6 @@ object GitPreferences { const val GIT_USER_NAME = "git_user_name" const val GIT_USER_EMAIL = "git_user_email" const val ADD_GLOBAL_COMMIT_WATERMARK = "add_git_commit_watermark" - const val PROJECT_WATERMARK_PREFIX = "git_commit_watermark_project" var userName: String? get() = prefManager.getString(GIT_USER_NAME, null) @@ -28,30 +25,4 @@ object GitPreferences { set(value) { prefManager.putBoolean(ADD_GLOBAL_COMMIT_WATERMARK, value) } - - fun isProjectWatermarkEnabled(projectPath: String?): Boolean { - if (projectPath.isNullOrBlank()) { - return true - } - return prefManager.getBoolean(getProjectWatermarkKey(projectPath), true) - } - - fun enableProjectWatermark( - projectPath: String?, - enabled: Boolean, - ) { - if (projectPath.isNullOrBlank()) { - return - } - prefManager.putBoolean(getProjectWatermarkKey(projectPath), enabled) - } - - fun isWatermarkEnabled(projectPath: String?): Boolean = shouldAddGlobalCommitWatermark && isProjectWatermarkEnabled(projectPath) - - fun getCanonicalProjectPath(projectPath: String): String { - val file = File(projectPath.trim()) - return runCatching { file.canonicalPath }.getOrDefault(file.absolutePath) - } - - fun getProjectWatermarkKey(projectPath: String): String = "$PROJECT_WATERMARK_PREFIX${getCanonicalProjectPath(projectPath)}" } From 8374f89a25ff9154883a1075475e117d3605cd22 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 14:56:51 +0100 Subject: [PATCH 12/18] feat(ADFA-3760): Add watermark preference group --- .../itsaky/androidide/preferences/gitPrefExts.kt | 15 +++++++++++++-- resources/src/main/res/values/strings.xml | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index 915275acf0..1e007117a1 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -23,7 +23,7 @@ class GitPreferencesScreen( init { addPreference(GitAuthorConfig()) - addPreference(AddGitCommitWatermark()) + addPreference(CommitWatermarkConfig()) } } @@ -31,7 +31,6 @@ class GitPreferencesScreen( class GitAuthorConfig( override val key: String = "idepref_git_author", override val title: Int = R.string.idepref_git_author_title, - override val summary: Int? = R.string.idepref_git_author_summary, override val children: List = mutableListOf(), ) : IPreferenceGroup() { @@ -41,6 +40,18 @@ class GitAuthorConfig( } } +@Parcelize +class CommitWatermarkConfig( + override val key: String = "idepref_git_commit_watermark", + override val title: Int = R.string.idepref_git_commit_watermark_title, + override val children: List = mutableListOf(), +) : IPreferenceGroup() { + + init { + addPreference(AddGitCommitWatermark()) + } +} + @Parcelize class GitUserName( override val key: String = GitPreferences.GIT_USER_NAME, diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index a401eda52c..eb1669e6cd 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1529,7 +1529,6 @@ Set and update Git configuration Author identity - Set the name and email used for commits User name Used as author name in commits User email @@ -1537,6 +1536,7 @@ Configure Git author in Preferences to enable commits.\nTap the avatar for more info. Add watermark to commits Automatically adds a Code on the Go credit to Git commits. You can disable it for individual projects in the Git tab. + Commit watermark Committing as %1$s Email - %1$s You can update your Git configuration in Preferences. From b7c39059f2a6c7a0feacbf68467de30577601e71 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 15:05:16 +0100 Subject: [PATCH 13/18] format(ADFA-3760): Apply spotless formatting --- .../com/itsaky/androidide/preferences/gitPrefExts.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt index 1e007117a1..1912f7824f 100644 --- a/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt +++ b/app/src/main/java/com/itsaky/androidide/preferences/gitPrefExts.kt @@ -42,14 +42,14 @@ class GitAuthorConfig( @Parcelize class CommitWatermarkConfig( - override val key: String = "idepref_git_commit_watermark", - override val title: Int = R.string.idepref_git_commit_watermark_title, - override val children: List = mutableListOf(), + override val key: String = "idepref_git_commit_watermark", + override val title: Int = R.string.idepref_git_commit_watermark_title, + override val children: List = mutableListOf(), ) : IPreferenceGroup() { - init { - addPreference(AddGitCommitWatermark()) - } + init { + addPreference(AddGitCommitWatermark()) + } } @Parcelize From 78959f88cbcc6e2dbda4ab1cb52ba94118c01170 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 15:29:18 +0100 Subject: [PATCH 14/18] feat(ADFA-3760): Sync switch checked state --- app/src/main/res/layout/fragment_git_bottom_sheet.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/layout/fragment_git_bottom_sheet.xml b/app/src/main/res/layout/fragment_git_bottom_sheet.xml index d7a5f66f53..ec39da37c3 100644 --- a/app/src/main/res/layout/fragment_git_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_git_bottom_sheet.xml @@ -196,6 +196,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:checked="true" + android:saveEnabled="false" android:text="@string/add_commit_watermark" /> Date: Wed, 9 Sep 2026 15:38:36 +0100 Subject: [PATCH 15/18] fix(ADFA-3760): Prioritize global opt out --- .../itsaky/androidide/fragments/git/GitBottomSheetFragment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index bec48b2551..e15ee6f0da 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -478,8 +478,7 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { val watermark = getString(R.string.made_with_code_on_the_go) .takeIf { - binding.layoutWatermark.isVisible && - binding.switchCommitWatermark.isChecked + GitPreferences.shouldAddGlobalCommitWatermark && binding.switchCommitWatermark.isChecked } val message = From 64afb271502f716a4ddc9ffea6edcabbb025fd79 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Wed, 9 Sep 2026 15:49:28 +0100 Subject: [PATCH 16/18] feat(ADFA-3760): Handle watermark config errors --- .../fragments/git/GitBottomSheetFragment.kt | 6 +++ .../viewmodel/GitBottomSheetViewModel.kt | 30 +++++++++++++-- .../viewmodel/GitBottomSheetViewModelTest.kt | 38 +++++++++++++++++++ .../androidide/git/core/JGitRepository.kt | 26 +++++++------ .../androidide/git/core/JGitRepositoryTest.kt | 26 +++++++++++++ resources/src/main/res/values/strings.xml | 1 + 6 files changed, 111 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index e15ee6f0da..df98d31f35 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -286,6 +286,12 @@ class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { } } + launch { + viewModel.watermarkError.collectLatest { + flashError(getString(R.string.git_watermark_save_failed)) + } + } + combine( viewModel.isGitRepository, viewModel.gitStatus, diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index 50a83ab110..d4f07a9920 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -21,8 +21,11 @@ import com.itsaky.androidide.utils.isNetworkConnected import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.eclipse.jgit.api.MergeResult.MergeStatus @@ -80,9 +83,13 @@ class GitBottomSheetViewModel( private val _isProjectWatermarkEnabled = MutableStateFlow(true) val isProjectWatermarkEnabled: StateFlow = _isProjectWatermarkEnabled.asStateFlow() + private val _watermarkError = MutableSharedFlow(extraBufferCapacity = 1) + val watermarkError: SharedFlow = _watermarkError.asSharedFlow() + private var initJob: Job? = null private var pullResetJob: Job? = null private var pushResetJob: Job? = null + private var watermarkWriteJob: Job? = null var currentRepository: GitRepository? = repository private set @@ -735,10 +742,25 @@ class GitBottomSheetViewModel( } } - fun setProjectWatermarkEnabled(enabled: Boolean) { + fun setProjectWatermarkEnabled( + enabled: Boolean, + onError: ((Throwable) -> Unit)? = null, + ) { + val previous = _isProjectWatermarkEnabled.value _isProjectWatermarkEnabled.value = enabled - viewModelScope.launch { - currentRepository?.setCommitWatermarkEnabled(enabled) - } + watermarkWriteJob?.cancel() + watermarkWriteJob = + viewModelScope.launch { + try { + currentRepository?.setCommitWatermarkEnabled(enabled) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error("Failed to save commit watermark preference to git config", e) + _isProjectWatermarkEnabled.value = previous + _watermarkError.tryEmit(e) + onError?.invoke(e) + } + } } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index a3d1de28a7..b9bbf4d214 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -10,18 +10,21 @@ import io.mockk.every import io.mockk.mockk import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.eclipse.jgit.api.MergeResult.MergeStatus import org.eclipse.jgit.api.errors.CheckoutConflictException import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 +import java.io.IOException @RunWith(JUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -273,4 +276,39 @@ class GitBottomSheetViewModelTest { advanceUntilIdle() coVerify { repository.setCommitWatermarkEnabled(true) } } + + @Test + fun `setProjectWatermarkEnabled rolls back state flow and invokes onError when repository write fails`() = + runTest { + coEvery { repository.setCommitWatermarkEnabled(false) } throws IOException("Disk write failed") + + var errorInvoked: Throwable? = null + viewModel.setProjectWatermarkEnabled(false) { error -> + errorInvoked = error + } + assertEquals(false, viewModel.isProjectWatermarkEnabled.value) + + advanceUntilIdle() + + // State flow should have rolled back to previous value (true) + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + assertNotNull(errorInvoked) + assertEquals("Disk write failed", errorInvoked?.message) + } + + @Test + fun `rapid setProjectWatermarkEnabled calls cancel prior in-flight write and commit latest value`() = + runTest { + coEvery { repository.setCommitWatermarkEnabled(any()) } coAnswers { + delay(100) + } + + viewModel.setProjectWatermarkEnabled(false) + viewModel.setProjectWatermarkEnabled(true) + + advanceUntilIdle() + + assertEquals(true, viewModel.isProjectWatermarkEnabled.value) + coVerify(exactly = 1) { repository.setCommitWatermarkEnabled(true) } + } } diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 02e00ffd32..329068b5ee 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -6,6 +6,8 @@ import com.itsaky.androidide.git.core.models.GitBranch import com.itsaky.androidide.git.core.models.GitCommit import com.itsaky.androidide.git.core.models.GitStatus import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.eclipse.jgit.api.CheckoutCommand import org.eclipse.jgit.api.CreateBranchCommand @@ -48,6 +50,7 @@ class JGitRepository( } private val log = LoggerFactory.getLogger(JGitRepository::class.java) + private val configMutex = Mutex() private val repository: Repository = FileRepositoryBuilder() @@ -222,18 +225,20 @@ class JGitRepository( override suspend fun isCommitWatermarkEnabled(): Boolean = withContext(Dispatchers.IO) { - try { - repository.config.load() - repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) - } catch (e: Exception) { - log.error("Error reading commit watermark config", e) - true + configMutex.withLock { + try { + repository.config.load() + repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) + } catch (e: Exception) { + log.error("Error reading commit watermark config", e) + true + } } } - override suspend fun setCommitWatermarkEnabled(enabled: Boolean) { - try { - withContext(Dispatchers.IO) { + override suspend fun setCommitWatermarkEnabled(enabled: Boolean) = + withContext(Dispatchers.IO) { + configMutex.withLock { repository.config.load() repository.config.setBoolean( CONFIG_SECTION_COTG, @@ -243,10 +248,7 @@ class JGitRepository( ) repository.config.save() } - } catch (e: Exception) { - log.error("Error saving commit watermark config", e) } - } override suspend fun commit( message: String, diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index c7e4db0c67..f1daf14cfe 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -1,5 +1,8 @@ package com.itsaky.androidide.git.core +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import org.eclipse.jgit.api.Git import org.junit.After @@ -279,4 +282,27 @@ class JGitRepositoryTest { assertTrue(freshRepo.isCommitWatermarkEnabled()) } } + + @Test + fun testConcurrentWatermarkWritesSerializeWithoutLockCollision() = + runBlocking { + val jobs = + List(10) { index -> + async(Dispatchers.IO) { + jgitRepo.setCommitWatermarkEnabled(index % 2 == 0) + } + } + jobs.awaitAll() + // Should complete without throwing LockFailedException and return a valid boolean + val isEnabled = jgitRepo.isCommitWatermarkEnabled() + assertTrue(isEnabled || !isEnabled) + } + + @Test(expected = Exception::class) + fun testSetCommitWatermarkPropagatesExceptionOnFailure() = + runBlocking { + val lockFile = File(repoDir, ".git/config.lock") + lockFile.mkdir() // Making the lock path a directory causes LockFile creation to fail + jgitRepo.setCommitWatermarkEnabled(false) + } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index eb1669e6cd..2ba0c50021 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1525,6 +1525,7 @@ Add commit message watermark Made with Code on the Go (appdevforall.org) Disabled globally in Preferences + Failed to update watermark setting Set and update Git configuration From ca837df95640f37e695c32b8cea2379c2d0fadab Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Thu, 10 Sep 2026 15:44:14 +0100 Subject: [PATCH 17/18] test(ADFA-3670): Fix broken test --- .../androidide/viewmodel/GitBottomSheetViewModel.kt | 1 - .../androidide/viewmodel/GitBottomSheetViewModelTest.kt | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt index d4f07a9920..111fcc71a8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModel.kt @@ -181,7 +181,6 @@ class GitBottomSheetViewModel( } try { - _isProjectWatermarkEnabled.value = repo.isCommitWatermarkEnabled() val status = repo.getStatus() _gitStatus.value = status _currentBranch.value = repo.getCurrentBranch()?.name diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt index b9bbf4d214..de4d44e530 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/GitBottomSheetViewModelTest.kt @@ -25,6 +25,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds @RunWith(JUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -280,7 +281,10 @@ class GitBottomSheetViewModelTest { @Test fun `setProjectWatermarkEnabled rolls back state flow and invokes onError when repository write fails`() = runTest { - coEvery { repository.setCommitWatermarkEnabled(false) } throws IOException("Disk write failed") + coEvery { repository.setCommitWatermarkEnabled(false) } coAnswers { + delay(50.milliseconds) + throw IOException("Disk write failed") + } var errorInvoked: Throwable? = null viewModel.setProjectWatermarkEnabled(false) { error -> @@ -300,7 +304,7 @@ class GitBottomSheetViewModelTest { fun `rapid setProjectWatermarkEnabled calls cancel prior in-flight write and commit latest value`() = runTest { coEvery { repository.setCommitWatermarkEnabled(any()) } coAnswers { - delay(100) + delay(100.milliseconds) } viewModel.setProjectWatermarkEnabled(false) From 21c258f3a00160b3ee77cccc476b9b2be395aca7 Mon Sep 17 00:00:00 2001 From: Dara Abijo Date: Thu, 10 Sep 2026 16:48:39 +0100 Subject: [PATCH 18/18] fix(ADFA-3760): Propagare read failures --- .../com/itsaky/androidide/git/core/JGitRepository.kt | 9 ++------- .../com/itsaky/androidide/git/core/JGitRepositoryTest.kt | 9 +++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt index 329068b5ee..e517605f9c 100644 --- a/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt +++ b/git-core/src/main/java/com/itsaky/androidide/git/core/JGitRepository.kt @@ -226,13 +226,8 @@ class JGitRepository( override suspend fun isCommitWatermarkEnabled(): Boolean = withContext(Dispatchers.IO) { configMutex.withLock { - try { - repository.config.load() - repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) - } catch (e: Exception) { - log.error("Error reading commit watermark config", e) - true - } + repository.config.load() + repository.config.getBoolean(CONFIG_SECTION_COTG, null, CONFIG_KEY_WATERMARK, true) } } diff --git a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt index f1daf14cfe..7f62367ca6 100644 --- a/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt +++ b/git-core/src/test/java/com/itsaky/androidide/git/core/JGitRepositoryTest.kt @@ -305,4 +305,13 @@ class JGitRepositoryTest { lockFile.mkdir() // Making the lock path a directory causes LockFile creation to fail jgitRepo.setCommitWatermarkEnabled(false) } + + @Test(expected = Exception::class) + fun testIsCommitWatermarkPropagatesExceptionOnFailure() = + runBlocking { + val configFile = File(repoDir, ".git/config") + configFile.writeText("[unclosed_section\nkey = value") + jgitRepo.isCommitWatermarkEnabled() + Unit + } }