Skip to content

Commit be22fef

Browse files
criticalAYjatinkumar2409
authored andcommitted
refactor: extract 'applicationScope' from AnkiDroidApp to :common:android
1 parent ebcf8e0 commit be22fef

14 files changed

Lines changed: 171 additions & 45 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

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

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import com.ichi2.anki.analytics.UsageAnalytics
3939
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
4040
import com.ichi2.anki.common.annotations.LegacyNotifications
4141
import com.ichi2.anki.common.annotations.NeedsTest
42+
import com.ichi2.anki.common.coroutines.applicationScope
4243
import com.ichi2.anki.common.crashreporting.CrashReportService.sendExceptionReport
4344
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
4445
import com.ichi2.anki.compat.CompatHelper
@@ -69,9 +70,6 @@ import com.ichi2.widget.DayRolloverAlarm
6970
import com.ichi2.widget.cardanalysis.CardAnalysisWidget
7071
import com.ichi2.widget.deckpicker.DeckPickerWidget
7172
import com.ichi2.widget.restoreRecurringAlarms
72-
import kotlinx.coroutines.CoroutineScope
73-
import kotlinx.coroutines.Dispatchers
74-
import kotlinx.coroutines.SupervisorJob
7573
import kotlinx.coroutines.launch
7674
import timber.log.Timber
7775
import timber.log.Timber.DebugTree
@@ -377,24 +375,6 @@ open class AnkiDroidApp :
377375
}
378376

379377
companion object {
380-
/**
381-
* [CoroutineScope] tied to the [Application], allowing executing of tasks which should
382-
* execute as long as the app is running
383-
*
384-
* This scope is bound by default to [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate].
385-
* Use an alternate dispatcher if the main thread is not required: [Dispatchers.Default] or [Dispatchers.IO]
386-
*
387-
* This scope will not be cancelled; exceptions are handled by [SupervisorJob]
388-
*
389-
* See: [Operations that shouldn't be cancelled in Coroutines](https://medium.com/androiddevelopers/coroutines-patterns-for-work-that-shouldnt-be-cancelled-e26c40f142ad#d425)
390-
*
391-
* This replicates the manner which `lifecycleScope`/`viewModelScope` is exposed in Android
392-
*/
393-
// lazy init required due to kotlinx-coroutines-test 1.10.0:
394-
// Main was accessed when the platform dispatcher was absent and the test dispatcher
395-
// was unset
396-
val applicationScope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
397-
398378
/**
399379
* A [SharedPreferencesProvider] which does not require [onCreate] when run from tests
400380
*

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink
4242
import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions
4343
import com.ichi2.anki.android.AnkiBroadcastReceiver
4444
import com.ichi2.anki.common.annotations.UseContextParameter
45+
import com.ichi2.anki.common.coroutines.applicationScope
4546
import com.ichi2.anki.common.crashreporting.CrashReportService
4647
import com.ichi2.anki.dialogs.DatabaseErrorDialog
4748
import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
@@ -648,7 +649,7 @@ fun AnkiBroadcastReceiver.runGloballyWithTimeout(
648649
return
649650
}
650651

651-
AnkiDroidApp.applicationScope.launch {
652+
applicationScope.launch {
652653
try {
653654
withTimeout(minOf(timeout, 8.seconds)) {
654655
block()

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import anki.collection.OpChanges
3535
import anki.collection.opChanges
3636
import com.ichi2.anki.CollectionManager.withOpenColOrNull
3737
import com.ichi2.anki.android.AnkiBroadcastReceiver
38+
import com.ichi2.anki.common.coroutines.applicationScope
3839
import com.ichi2.anki.common.crashreporting.CrashReportService
3940
import com.ichi2.anki.exception.ManuallyReportedException
4041
import com.ichi2.anki.libanki.EpochSeconds
@@ -82,7 +83,7 @@ object DayRolloverHandler : AnkiBroadcastReceiver() {
8283
// the outcome would be two calls to notifySubscribers, which is acceptable
8384
Timber.v("received ${intent.action}")
8485
// launch coroutine as we need access to `col.sched`
85-
AnkiDroidApp.applicationScope.launchCatching(Dispatchers.IO, errorMessageHandler = { msg ->
86+
applicationScope.launchCatching(Dispatchers.IO, errorMessageHandler = { msg ->
8687
CrashReportService.sendExceptionReport(
8788
e = ManuallyReportedException(msg),
8889
origin = "DayRolloverHandler::onReceive",

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.core.content.FileProvider
2828
import androidx.core.content.IntentCompat
2929
import androidx.work.WorkManager
3030
import com.ichi2.anki.common.annotations.NeedsTest
31+
import com.ichi2.anki.common.coroutines.applicationScope
3132
import com.ichi2.anki.common.utils.trimToLength
3233
import com.ichi2.anki.dialogs.DialogHandler.Companion.storeMessage
3334
import com.ichi2.anki.dialogs.DialogHandlerMessage
@@ -235,7 +236,7 @@ class IntentHandler : AbstractIntentHandler() {
235236
// TODO improve the handling of the imported temporary files
236237
// Launching this scope without tying it to a lifecycle since ,
237238
// IntentHandler finishes quickly, but deletion may still be in progress
238-
AnkiDroidApp.applicationScope.launch(Dispatchers.IO) {
239+
applicationScope.launch(Dispatchers.IO) {
239240
try {
240241
val file = File(path!!)
241242
val fileUri =
@@ -295,7 +296,7 @@ class IntentHandler : AbstractIntentHandler() {
295296
// TODO improve the handling of the imported temporary files
296297
// Launching this scope without tying it to a lifecycle since ,
297298
// IntentHandler finishes quickly, but deletion may still be in progress
298-
AnkiDroidApp.applicationScope.launch(Dispatchers.IO) {
299+
applicationScope.launch(Dispatchers.IO) {
299300
try {
300301
contentResolver.delete(sharedDeckUri, null, null)
301302
Timber.i("onCreate: downloaded shared deck deleted")

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ package com.ichi2.anki
2424

2525
import android.speech.tts.TextToSpeech
2626
import android.speech.tts.Voice
27+
import com.ichi2.anki.common.coroutines.applicationScope
2728
import com.ichi2.anki.i18n.normalize
2829
import com.ichi2.anki.i18n.toAnkiTwoLetterCode
2930
import com.ichi2.anki.libanki.TemplateManager
@@ -146,7 +147,7 @@ object TtsVoices {
146147
// This is intended to be a global singleton outside the lifecycle of a specific activity
147148
// Most of the time of execution is waiting for the TTS Engine to initialize
148149
buildLocalesJob =
149-
AnkiDroidApp.applicationScope.launch(Dispatchers.IO) {
150+
applicationScope.launch(Dispatchers.IO) {
150151
Timber.d("executing job")
151152
loadTtsVoicesData()
152153
buildLocalesJob = null
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

AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.ichi2.anki.AnkiActivity
3131
import com.ichi2.anki.AnkiDroidApp
3232
import com.ichi2.anki.R
3333
import com.ichi2.anki.common.annotations.NeedsTest
34+
import com.ichi2.anki.common.coroutines.applicationScope
3435
import com.ichi2.anki.common.crashreporting.CrashReportService
3536
import com.ichi2.anki.common.time.TimeManager
3637
import com.ichi2.anki.compat.CompatHelper
@@ -326,7 +327,7 @@ object ImportUtils {
326327
) {
327328
// Use applicationScope: IntentHandler calls this and does not have a lifecycleScope
328329
fun copyDebugInfo(debugInfo: String) =
329-
AnkiDroidApp.applicationScope.launch {
330+
applicationScope.launch {
330331
Timber.i("copying debug info to clipboard")
331332
val stringToCopy =
332333
buildString {

AnkiDroid/src/main/java/com/ichi2/widget/DayRolloverAlarm.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ import androidx.annotation.VisibleForTesting
2626
import androidx.core.app.PendingIntentCompat
2727
import androidx.core.content.getSystemService
2828
import anki.collection.opChanges
29-
import com.ichi2.anki.AnkiDroidApp.Companion.applicationScope
3029
import com.ichi2.anki.CollectionManager.withOpenColOrNull
3130
import com.ichi2.anki.android.AnkiBroadcastReceiver
31+
import com.ichi2.anki.common.coroutines.applicationScope
3232
import com.ichi2.anki.common.crashreporting.CrashReportService
3333
import com.ichi2.anki.common.time.TimeManager
3434
import com.ichi2.anki.exception.ManuallyReportedException

0 commit comments

Comments
 (0)