From 7a25aafc23a8eb422ab917a22374ca7eb83dec4e Mon Sep 17 00:00:00 2001
From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com>
Date: Tue, 28 Jan 2025 10:20:15 -0500
Subject: [PATCH] PM-17650 Implement custom tool tip state to prevent tool tips
 from dismissing. (#4637)

---
 .../repository/DebugMenuRepository.kt         |   5 +
 .../repository/DebugMenuRepositoryImpl.kt     |   5 +
 .../coachmark/CoachMarkContainer.kt           |   6 +-
 .../coachmark/CoachMarkScopeInstance.kt       |   8 +-
 .../components/coachmark/CoachMarkState.kt    |  12 +-
 .../coachmark/LazyListCoachMarkState.kt       |   2 +
 .../model/CoachMarkHighlightState.kt          |   6 +-
 .../tooltip/BitwardenToolTipState.kt          |  17 +++
 .../tooltip/BitwardenToolTipStateImpl.kt      | 112 ++++++++++++++++++
 .../feature/debugmenu/DebugMenuScreen.kt      |  13 ++
 .../feature/debugmenu/DebugMenuViewModel.kt   |  10 ++
 .../main/res/values/strings_non_localized.xml |   1 +
 .../repository/DebugMenuRepositoryTest.kt     |  17 +++
 .../feature/debugmenu/DebugMenuScreenTest.kt  |  18 ++-
 .../debugmenu/DebugMenuViewModelTest.kt       |  17 ++-
 15 files changed, 224 insertions(+), 25 deletions(-)
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipState.kt
 create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipStateImpl.kt

diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt
index 0d44a9a41..9fd525d38 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt
@@ -38,6 +38,11 @@ interface DebugMenuRepository {
      */
     fun resetOnboardingStatusForCurrentUser()
 
+    /**
+     * Resets the value for the show coach mark statuses so their default values will be used.
+     */
+    fun resetCoachMarkTourStatuses()
+
     /**
      * Manipulates the state to force showing the onboarding carousel.
      *
diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt
index ea07d821f..5575957d5 100644
--- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt
@@ -56,6 +56,11 @@ class DebugMenuRepositoryImpl(
         )
     }
 
+    override fun resetCoachMarkTourStatuses() {
+        settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = null)
+        settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
+    }
+
     override fun modifyStateToShowOnboardingCarousel(
         userStateUpdateTrigger: () -> Unit,
     ) {
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt
index 199257ac1..e69a4a4a9 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkContainer.kt
@@ -60,7 +60,6 @@ fun <T : Enum<T>> CoachMarkContainer(
     modifier: Modifier = Modifier,
     content: @Composable CoachMarkScope<T>.() -> Unit,
 ) {
-    val scope = rememberCoroutineScope()
     Box(
         modifier = Modifier
             .fillMaxSize()
@@ -101,9 +100,8 @@ fun <T : Enum<T>> CoachMarkContainer(
                     .pointerInput(Unit) {
                         detectTapGestures(
                             onTap = {
-                                scope.launch {
-                                    state.showToolTipForCurrentCoachMark()
-                                }
+                                // NO-OP, this consumes any touch events
+                                // while the scrim is showing.
                             },
                         )
                     }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt
index f28a3e3a0..c7d3b9077 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkScopeInstance.kt
@@ -9,8 +9,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.TooltipBox
 import androidx.compose.material3.TooltipDefaults
-import androidx.compose.material3.TooltipState
-import androidx.compose.material3.rememberTooltipState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -32,6 +30,8 @@ import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
 import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
 import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
 import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTip
+import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
+import com.x8bit.bitwarden.ui.platform.components.tooltip.rememberBitwardenToolTipState
 import okhttp3.internal.toImmutableList
 import org.jetbrains.annotations.VisibleForTesting
 
@@ -55,7 +55,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
         rightAction: @Composable() (RowScope.() -> Unit)?,
         anchorContent: @Composable () -> Unit,
     ) {
-        val toolTipState = rememberTooltipState(
+        val toolTipState = rememberBitwardenToolTipState(
             initialIsVisible = false,
             isPersistent = true,
         )
@@ -192,7 +192,7 @@ class CoachMarkScopeInstance<T : Enum<T>>(
         leftAction: @Composable() (RowScope.() -> Unit)?,
         rightAction: @Composable() (RowScope.() -> Unit)?,
         modifier: Modifier = Modifier,
-        toolTipState: TooltipState = rememberTooltipState(
+        toolTipState: BitwardenToolTipState = rememberBitwardenToolTipState(
             initialIsVisible = false,
             isPersistent = true,
         ),
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt
index c833f8cbc..2fa1bf09e 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/CoachMarkState.kt
@@ -1,8 +1,7 @@
 package com.x8bit.bitwarden.ui.platform.components.coachmark
 
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.TooltipState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.Saver
@@ -11,6 +10,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.ui.geometry.Rect
 import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
 import com.x8bit.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightState
+import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
 import java.util.concurrent.ConcurrentHashMap
 import kotlin.math.max
 import kotlin.math.min
@@ -27,7 +27,7 @@ import kotlin.math.min
  * none should be highlighted at start.
  * @param isCoachMarkVisible is any coach mark currently visible.
  */
-@OptIn(ExperimentalMaterial3Api::class)
+@Stable
 open class CoachMarkState<T : Enum<T>>(
     val orderedList: List<T>,
     initialCoachMarkHighlight: T? = null,
@@ -58,7 +58,7 @@ open class CoachMarkState<T : Enum<T>>(
     fun updateHighlight(
         key: T,
         bounds: Rect?,
-        toolTipState: TooltipState,
+        toolTipState: BitwardenToolTipState,
         shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
     ) {
         highlights[key] = CoachMarkHighlightState(
@@ -193,9 +193,9 @@ open class CoachMarkState<T : Enum<T>>(
     /**
      * Cleans up the tooltip state by dismissing it if visible and calling onDispose.
      */
-    private fun TooltipState.cleanUp() {
+    private fun BitwardenToolTipState.cleanUp() {
         if (isVisible) {
-            dismiss()
+            dismissBitwardenToolTip()
         }
         onDispose()
     }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt
index 1973efe6b..7b89dee2b 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/LazyListCoachMarkState.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.scrollBy
 import androidx.compose.foundation.lazy.LazyListLayoutInfo
 import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.saveable.Saver
 import androidx.compose.runtime.saveable.listSaver
 import androidx.compose.runtime.saveable.rememberSaveable
@@ -13,6 +14,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
  * A [CoachMarkState] that depends on a [LazyListState] to automatically scroll to the current
  * Coach Mark if not on currently on the screen.
  */
+@Stable
 class LazyListCoachMarkState<T : Enum<T>>(
     private val lazyListState: LazyListState,
     orderedList: List<T>,
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt
index da0ec745f..4ea4fa882 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark/model/CoachMarkHighlightState.kt
@@ -1,8 +1,7 @@
 package com.x8bit.bitwarden.ui.platform.components.coachmark.model
 
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.TooltipState
 import androidx.compose.ui.geometry.Rect
+import com.x8bit.bitwarden.ui.platform.components.tooltip.BitwardenToolTipState
 
 /**
  * Represents a highlight within a coach mark sequence.
@@ -13,10 +12,9 @@ import androidx.compose.ui.geometry.Rect
  * @property toolTipState The state of the tooltip associated with this highlight.
  * @property shape The shape of the highlight (e.g., square, oval).
  */
-@OptIn(ExperimentalMaterial3Api::class)
 data class CoachMarkHighlightState<T : Enum<T>>(
     val key: T,
     val highlightBounds: Rect?,
-    val toolTipState: TooltipState,
+    val toolTipState: BitwardenToolTipState,
     val shape: CoachMarkHighlightShape,
 )
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipState.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipState.kt
new file mode 100644
index 000000000..3d877ede8
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipState.kt
@@ -0,0 +1,17 @@
+package com.x8bit.bitwarden.ui.platform.components.tooltip
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TooltipState
+
+/**
+ * A custom [TooltipState] to be used for the tool tips which should not be
+ * dismissed automatically by clicking outside of the pop-up area.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+interface BitwardenToolTipState : TooltipState {
+    /**
+     * Call to dismiss the tool tip from the screen, should be used in
+     * place of [TooltipState.dismiss]
+     */
+    fun dismissBitwardenToolTip()
+}
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipStateImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipStateImpl.kt
new file mode 100644
index 000000000..a2317e572
--- /dev/null
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/tooltip/BitwardenToolTipStateImpl.kt
@@ -0,0 +1,112 @@
+package com.x8bit.bitwarden.ui.platform.components.tooltip
+
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.foundation.MutatePriority
+import androidx.compose.foundation.MutatorMutex
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeout
+
+/**
+ * Default implementation of [BitwardenToolTipState]
+ */
+class BitwardenToolTipStateImpl(
+    initialIsVisible: Boolean,
+    override val isPersistent: Boolean,
+    private val mutatorMutex: MutatorMutex,
+) : BitwardenToolTipState {
+
+    override fun dismissBitwardenToolTip() {
+        transition.targetState = false
+    }
+
+    override val transition: MutableTransitionState<Boolean> =
+        MutableTransitionState(initialIsVisible)
+
+    override val isVisible: Boolean
+        get() = transition.currentState || transition.targetState
+
+    /** continuation used to clean up */
+    private var job: (CancellableContinuation<Unit>)? = null
+
+    /**
+     * Show the tooltip associated with the current [TooltipState]. When this method is called, all
+     * of the other tooltips associated with [mutatorMutex] will be dismissed.
+     *
+     * @param mutatePriority [MutatePriority] to be used with [mutatorMutex].
+     */
+    override suspend fun show(mutatePriority: MutatePriority) {
+        val cancellableShow: suspend () -> Unit = {
+            suspendCancellableCoroutine { continuation ->
+                transition.targetState = true
+                job = continuation
+            }
+        }
+
+        // Show associated tooltip for [TooltipDuration] amount of time
+        // or until tooltip is explicitly dismissed depending on [isPersistent].
+        mutatorMutex.mutate(mutatePriority) {
+            try {
+                if (isPersistent) {
+                    cancellableShow()
+                } else {
+                    withTimeout(BITWARDEN_TOOL_TIP_TIMEOUT) { cancellableShow() }
+                }
+            } finally {
+                if (mutatePriority != MutatePriority.PreventUserInput) {
+                    // timeout or cancellation has occurred and we close out the current tooltip.
+                    dismissBitwardenToolTip()
+                }
+            }
+        }
+    }
+
+    /**
+     * We are overriding this specifically to make it so it is a no-op this prevents the
+     * tooltip from being dismissed if the user taps anywhere out of it.
+     */
+    override fun dismiss() {
+        /**No-Op**/
+    }
+
+    /** Cleans up [mutatorMutex] when the tooltip associated with this state leaves Composition. */
+    override fun onDispose() {
+        job?.cancel()
+    }
+}
+
+/**
+ * Provides a [BitwardenToolTipState] in a composable scope remembered across compositions.
+ *
+ * @param mutatorMutex if providing your own, ensure that any tool tips that should be
+ * shown/hidden in the context of the one you are using this state for, make use of the same
+ * instance of the passed in value.
+ */
+@Composable
+@ExperimentalMaterial3Api
+fun rememberBitwardenToolTipState(
+    initialIsVisible: Boolean = false,
+    isPersistent: Boolean = false,
+    mutatorMutex: MutatorMutex = BitwardenToolTipStateDefaults.GlobalMutatorMutex,
+): BitwardenToolTipState =
+    remember(isPersistent, mutatorMutex) {
+        BitwardenToolTipStateImpl(
+            initialIsVisible = initialIsVisible,
+            isPersistent = isPersistent,
+            mutatorMutex = mutatorMutex,
+        )
+    }
+
+/**
+ * Provides a global [MutatorMutex] as a singleton to be used by default for each
+ * created [BitwardenToolTipState]
+ */
+private object BitwardenToolTipStateDefaults {
+    val GlobalMutatorMutex = MutatorMutex()
+}
+
+private const val BITWARDEN_TOOL_TIP_TIMEOUT = 1500L
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
index 8083d84b4..ba669c53b 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt
@@ -115,6 +115,19 @@ fun DebugMenuScreen(
                     }
                 },
             )
+            Spacer(Modifier.height(16.dp))
+            BitwardenFilledButton(
+                label = stringResource(R.string.reset_coach_mark_tour_status),
+                onClick = remember(viewModel) {
+                    {
+                        viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses)
+                    }
+                },
+                isEnabled = true,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .standardHorizontalMargin(),
+            )
             Spacer(modifier = Modifier.height(height = 16.dp))
             Spacer(modifier = Modifier.navigationBarsPadding())
         }
diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
index e79702f47..97580e23d 100644
--- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
+++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt
@@ -48,9 +48,14 @@ class DebugMenuViewModel @Inject constructor(
             DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues()
             DebugMenuAction.RestartOnboarding -> handleResetOnboardingStatus()
             DebugMenuAction.RestartOnboardingCarousel -> handleResetOnboardingCarousel()
+            DebugMenuAction.ResetCoachMarkTourStatuses -> handleResetCoachMarkTourStatuses()
         }
     }
 
+    private fun handleResetCoachMarkTourStatuses() {
+        debugMenuRepository.resetCoachMarkTourStatuses()
+    }
+
     private fun handleResetOnboardingCarousel() {
         debugMenuRepository.modifyStateToShowOnboardingCarousel(
             userStateUpdateTrigger = {
@@ -133,6 +138,11 @@ sealed class DebugMenuAction {
      */
     data object RestartOnboardingCarousel : DebugMenuAction()
 
+    /**
+     * User has clicked to reset coach mark values.
+     */
+    data object ResetCoachMarkTourStatuses : DebugMenuAction()
+
     /**
      * Internal actions not triggered from the UI.
      */
diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml
index d9aa5fd51..c2241ac67 100644
--- a/app/src/main/res/values/strings_non_localized.xml
+++ b/app/src/main/res/values/strings_non_localized.xml
@@ -25,5 +25,6 @@
     <string name="new_device_permanent_dismiss">New device notice permanent dismiss</string>">
     <string name="new_device_temporary_dismiss">New device notice temporary dismiss</string>">
     <string name="ignore_environment_check">Ignore environment check</string>">
+    <string name="reset_coach_mark_tour_status">Reset all coach mark tours</string>
     <!-- /Debug Menu -->
 </resources>
diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt
index 12bc28097..71a76d1d4 100644
--- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt
@@ -203,6 +203,23 @@ class DebugMenuRepositoryTest {
         }
         assertTrue(lambdaHasBeenCalled)
     }
+
+    @Test
+    fun `resetCoachMarkTourStatuses calls settings disk source setting values back to null`() {
+        every {
+            mockSettingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = any())
+        } just runs
+        every {
+            mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = any())
+        } just runs
+
+        debugMenuRepository.resetCoachMarkTourStatuses()
+
+        verify(exactly = 1) {
+            mockSettingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = null)
+            mockSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = null)
+        }
+    }
 }
 
 private const val TEST_STRING_VALUE = "test"
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
index 974b91872..1d54c58f6 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt
@@ -89,7 +89,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
             .onNodeWithText("Email Verification", ignoreCase = true)
             .performClick()
 
-        verify {
+        verify(exactly = 1) {
             viewModel.trySendAction(
                 DebugMenuAction.UpdateFeatureFlag(
                     FlagKey.EmailVerification,
@@ -106,7 +106,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
             .performScrollTo()
             .performClick()
 
-        verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
+        verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
     }
 
     @Test
@@ -124,7 +124,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
             .assertIsEnabled()
             .performClick()
 
-        verify { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
+        verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboarding) }
     }
 
     @Test
@@ -161,7 +161,7 @@ class DebugMenuScreenTest : BaseComposeTest() {
             .assertIsEnabled()
             .performClick()
 
-        verify { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
+        verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
     }
 
     @Test
@@ -182,4 +182,14 @@ class DebugMenuScreenTest : BaseComposeTest() {
 
         verify(exactly = 0) { viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel) }
     }
+
+    @Test
+    fun `reset all coach mark tours should send ResetCoachMarkTourStatuses action`() {
+        composeTestRule
+            .onNodeWithText("Reset all coach mark tours")
+            .performScrollTo()
+            .performClick()
+
+        verify(exactly = 1) { viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses) }
+    }
 }
diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
index 20dc42136..ce0e2481c 100644
--- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
+++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt
@@ -78,14 +78,16 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
         viewModel.trySendAction(
             DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false),
         )
-        verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false) }
+        verify(exactly = 1) {
+            mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false)
+        }
     }
 
     @Test
     fun `handleResetOnboardingStatus should reset the onboarding status`() {
         val viewModel = createViewModel()
         viewModel.trySendAction(DebugMenuAction.RestartOnboarding)
-        verify { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
+        verify(exactly = 1) { mockDebugMenuRepository.resetOnboardingStatusForCurrentUser() }
     }
 
     @Suppress("MaxLineLength")
@@ -93,12 +95,21 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
     fun `handleResetOnboardingCarousel should reset the onboarding carousel and update user state pending account action`() {
         val viewModel = createViewModel()
         viewModel.trySendAction(DebugMenuAction.RestartOnboardingCarousel)
-        verify {
+        verify(exactly = 1) {
             mockDebugMenuRepository.modifyStateToShowOnboardingCarousel(any())
             mockAuthRepository.hasPendingAccountAddition = true
         }
     }
 
+    @Test
+    fun `handleResetCoachMarkTourStatuses should call repository to reset values`() {
+        val viewModel = createViewModel()
+        viewModel.trySendAction(DebugMenuAction.ResetCoachMarkTourStatuses)
+        verify(exactly = 1) {
+            mockDebugMenuRepository.resetCoachMarkTourStatuses()
+        }
+    }
+
     private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
         featureFlagManager = mockFeatureFlagManager,
         debugMenuRepository = mockDebugMenuRepository,