Skip to content

Commit 5c22b48

Browse files
added debouncing to prevent frequent triggering
1 parent 9d3a70f commit 5c22b48

3 files changed

Lines changed: 113 additions & 13 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import android.content.SharedPreferences
2929
import android.content.res.Configuration
3030
import android.graphics.Bitmap
3131
import android.graphics.Color
32-
import android.hardware.SensorManager
3332
import android.media.MediaPlayer
3433
import android.net.Uri
3534
import android.os.Build
@@ -87,6 +86,7 @@ import com.ichi2.anim.ActivityTransitionAnimation
8786
import com.ichi2.anki.AbstractFlashcardViewer.Signal.Companion.toSignal
8887
import com.ichi2.anki.CollectionManager.TR
8988
import com.ichi2.anki.CollectionManager.withCol
89+
import com.ichi2.anki.android.AnkiShakeDetector
9090
import com.ichi2.anki.android.back.exitViaDoubleTapBackCallback
9191
import com.ichi2.anki.backend.stripHTMLAndSpecialFields
9292
import com.ichi2.anki.cardviewer.AndroidCardRenderContext
@@ -2170,7 +2170,7 @@ abstract class AbstractFlashcardViewer :
21702170
internal inner class LinkDetectingGestureDetector :
21712171
MyGestureDetector(),
21722172
ShakeDetector.Listener {
2173-
private var shakeDetector: ShakeDetector? = null
2173+
private var shakeDetector: AnkiShakeDetector? = null
21742174

21752175
init {
21762176
initShakeDetector()
@@ -2179,11 +2179,14 @@ abstract class AbstractFlashcardViewer :
21792179
private fun initShakeDetector() {
21802180
Timber.d("Initializing shake detector")
21812181
if (gestureProcessor.isBound(Gesture.SHAKE)) {
2182-
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
21832182
shakeDetector =
2184-
ShakeDetector(this).apply {
2185-
start(sensorManager, SensorManager.SENSOR_DELAY_UI)
2186-
}
2183+
AnkiShakeDetector
2184+
.createInstance(
2185+
context = this@AbstractFlashcardViewer,
2186+
listener = this@LinkDetectingGestureDetector,
2187+
)?.apply {
2188+
start()
2189+
}
21872190
}
21882191
}
21892192

@@ -2205,7 +2208,6 @@ abstract class AbstractFlashcardViewer :
22052208
private val dispatchedTouchEvents = hashSetInit<MotionEvent>(2)
22062209

22072210
override fun hearShake() {
2208-
Timber.d("Shake detected!")
22092211
gestureProcessor.onShake()
22102212
}
22112213

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
Copyright (c) 2026 Jatin Kumar <jnkr2409@gmail.com>
3+
* This program is free software; you can redistribute it and/or modify it under *
4+
* the terms of the GNU General Public License as published by the Free Software *
5+
* Foundation; either version 3 of the License, or (at your option) any later *
6+
* version. *
7+
* *
8+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY *
9+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
10+
* PARTICULAR PURPOSE. See the GNU General Public License for more details. *
11+
* *
12+
* You should have received a copy of the GNU General Public License along with *
13+
* this program. If not, see <http://www.gnu.org/licenses/>.
14+
*/
15+
16+
package com.ichi2.anki.android
17+
import android.content.Context
18+
import android.hardware.SensorManager
19+
import android.os.SystemClock
20+
import androidx.core.content.ContextCompat
21+
import androidx.core.content.getSystemService
22+
import com.squareup.seismic.ShakeDetector
23+
import timber.log.Timber
24+
import kotlin.time.Duration
25+
import kotlin.time.Duration.Companion.milliseconds
26+
27+
/*
28+
* Wrapper for a [ShakeDetector] to provide a cooldown mechanism.
29+
* This prevents the "Undo" action or other gestures from triggering multiple times
30+
* in rapid succession during a single physical shake event.
31+
*/
32+
class AnkiShakeDetector(
33+
private val sensorManager: SensorManager,
34+
/*
35+
* Sensor Delay tells how often the app should check for phone movement.
36+
*
37+
* We use [SensorManager.SENSOR_DELAY_UI] because:
38+
* - Enough Speed : It is fast enough to catch a normal shake.
39+
* - Battery Friendly: It uses less power than "Game" or "Fastest" settings.
40+
*/
41+
private val sensorDelay: Int = SensorManager.SENSOR_DELAY_UI,
42+
private val listener: ShakeDetector.Listener,
43+
/*
44+
* The minimum time between shake events to prevent accidental double-triggers.
45+
*
46+
* Through trial and error, 800ms was determined to be the optimal 'sweet spot':
47+
* - 500ms : A single physical shake often registered as two distinct events,
48+
* causing the flag to toggle on and immediately off again.
49+
*
50+
* - 1000ms+ : Felt unresponsive when the user wanted to quickly flag/unflag
51+
* multiple cards in a row.
52+
*
53+
* - 800ms : Consistently filters out the "rebound" of a single shake while
54+
* remaining responsive for intentional back-to-back actions.
55+
*/
56+
private val cooldown: Duration = 800.milliseconds,
57+
) : ShakeDetector.Listener {
58+
private val shakeDetector = ShakeDetector(this)
59+
private var lastShakeTime = 0L
60+
61+
fun start() {
62+
sensorManager.let {
63+
shakeDetector.start(it, sensorDelay)
64+
}
65+
}
66+
67+
fun stop() {
68+
shakeDetector.stop()
69+
}
70+
71+
override fun hearShake() {
72+
Timber.d("The time since the last shake was: ${SystemClock.elapsedRealtime() - lastShakeTime}")
73+
val currentTime = SystemClock.elapsedRealtime()
74+
if (currentTime - lastShakeTime < cooldown.inWholeMilliseconds) {
75+
return
76+
}
77+
try {
78+
listener.hearShake()
79+
} finally {
80+
lastShakeTime = SystemClock.elapsedRealtime()
81+
}
82+
}
83+
84+
companion object {
85+
fun createInstance(
86+
context: Context,
87+
listener: ShakeDetector.Listener,
88+
): AnkiShakeDetector? {
89+
val sensorManager = context.getSystemService<SensorManager>()
90+
return sensorManager?.let {
91+
AnkiShakeDetector(
92+
sensorManager = sensorManager,
93+
listener = listener,
94+
)
95+
}
96+
}
97+
}
98+
}

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package com.ichi2.anki.ui.windows.reviewer
1717

1818
import android.content.Context
1919
import android.content.Intent
20-
import android.hardware.SensorManager
2120
import android.net.Uri
2221
import android.os.Bundle
2322
import android.view.KeyEvent
@@ -53,6 +52,8 @@ import com.ichi2.anki.CollectionManager
5352
import com.ichi2.anki.DispatchKeyEventListener
5453
import com.ichi2.anki.Flag
5554
import com.ichi2.anki.R
55+
import com.ichi2.anki.android.AnkiShakeDetector
56+
import com.ichi2.anki.android.back.doubleBackPressCallback
5657
import com.ichi2.anki.cardviewer.Gesture
5758
import com.ichi2.anki.common.annotations.NeedsTest
5859
import com.ichi2.anki.common.utils.android.isRobolectric
@@ -116,8 +117,7 @@ class ReviewerFragment :
116117

117118
override val webViewLayout: SafeWebViewLayout get() = binding.webViewLayout
118119
private lateinit var bindingMap: BindingMap<ReviewerBinding, ViewerAction>
119-
private var shakeDetector: ShakeDetector? = null
120-
private val sensorManager get() = ContextCompat.getSystemService(requireContext(), SensorManager::class.java)
120+
private var shakeDetector: AnkiShakeDetector? = null
121121
private val whiteboardFragment get() = childFragmentManager.findFragmentByTag(WhiteboardFragment::class.jvmName) as? WhiteboardFragment
122122
private val isBigScreen: Boolean get() = resources.configuration.smallestScreenWidthDp >= 720
123123

@@ -143,7 +143,7 @@ class ReviewerFragment :
143143
override fun onStart() {
144144
super.onStart()
145145
if (!requireActivity().isChangingConfigurations) {
146-
shakeDetector?.start(sensorManager, SensorManager.SENSOR_DELAY_UI)
146+
shakeDetector?.start()
147147
}
148148
}
149149

@@ -354,8 +354,8 @@ class ReviewerFragment :
354354
bindingMap.onGenericMotionEvent(event)
355355
}
356356
if (bindingMap.isBound(Gesture.SHAKE)) {
357-
shakeDetector = ShakeDetector(this)
358-
shakeDetector?.start(sensorManager, SensorManager.SENSOR_DELAY_UI)
357+
shakeDetector = AnkiShakeDetector.createInstance(requireContext(), this)
358+
shakeDetector?.start()
359359
}
360360
}
361361

0 commit comments

Comments
 (0)