Voice init-race fix + velocity-aware Surface lock
Voice (final P1 from audit): the policy is no longer consumed until TTS is ready, so a phrase can't be queued and then spoken stale after init. When ready, the policy evaluates the CURRENT reading (silent if moving, correct instruction if settled). VoiceSpeaker drops its pending-text queue and just exposes isReady; the policy is remember(speaker) so each VOICE session starts fresh. Covers all of Codex's cancellation cases (movement, unlock, invalid placement, foreground loss, mode change, shutdown). Velocity-aware Surface lock (Jay's observation + Codex): the fixed 400 ms dwell made a slowly-arriving bubble wait, delaying the level sound, while a fast crossing could still lock. LockDetector now takes a movement rate: - error <=0.2 deg AND rate <=0.3 deg/s -> lock after a short 175 ms confirm; - faster than that -> dwell never accumulates (a center fly-through never locks); slowing to a stop inside the zone starts a fresh confirmation; - 0.35 deg exit hysteresis and the single shared lock (sound/lime/haptic) unchanged. LevelPipeline feeds SettlingDetector.movementRateDegreesPerSecond (was discarded); Edge passes null -> classic fixed 400 ms dwell. Simple gated machine, no adaptive-dwell formula. Tests: slow-entry-locks-promptly, fast-traversal-never-locks, intermediate- speed-no-accumulate, slowing-inside-zone-fresh-dwell, exit hysteresis, feedback debounce, Edge fixed-dwell fallback. 82 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,25 +4,38 @@ import kotlin.math.abs
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
|
* Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
|
||||||
* §Sensor and measurement architecture). Operates ONLY on the stable calibrated
|
* §Sensor and measurement architecture). Operates on the stable calibrated measurement —
|
||||||
* measurement — the same value the numeric readout shows. The lock must never
|
* the same value the numeric readout shows, so the lock never disagrees with the number.
|
||||||
* disagree with the number on screen.
|
|
||||||
*
|
*
|
||||||
* Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
|
* Acquisition is VELOCITY-AWARE when a movement rate is supplied (Surface): a bubble eased
|
||||||
|
* gently into center (≤ [slowRateThresholdDegPerSec]) locks after a short [dwellMillis]
|
||||||
|
* confirmation, but a bubble flying across center never accumulates lock time — the dwell
|
||||||
|
* timer only runs while inside the zone AND moving slowly, and resets otherwise (so slowing
|
||||||
|
* to a stop inside the zone starts a fresh confirmation). This replaces a fixed long dwell,
|
||||||
|
* which forced even a slow, deliberate arrival to wait (and delayed the "level" sound).
|
||||||
|
*
|
||||||
|
* When no rate is supplied (`rateDegPerSec == null`, e.g. Edge), it falls back to the
|
||||||
|
* classic fixed [settledDwellMillis] with no velocity gate.
|
||||||
|
*
|
||||||
|
* The single lock drives sound, the lime visual, and the haptic together — there is no
|
||||||
|
* separate audio threshold. Time is injected (callers pass `nowMillis`) for testability.
|
||||||
*/
|
*/
|
||||||
class LockDetector(
|
class LockDetector(
|
||||||
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
|
private val enterThresholdDegrees: Double = DEFAULT_ENTER_DEGREES,
|
||||||
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
|
private val exitThresholdDegrees: Double = DEFAULT_EXIT_DEGREES,
|
||||||
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
|
private val dwellMillis: Long = DEFAULT_DWELL_MILLIS,
|
||||||
|
private val settledDwellMillis: Long = DEFAULT_SETTLED_DWELL_MILLIS,
|
||||||
|
private val slowRateThresholdDegPerSec: Double = DEFAULT_SLOW_RATE_DEG_PER_SEC,
|
||||||
private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS,
|
private val feedbackDebounceMillis: Long = DEFAULT_FEEDBACK_DEBOUNCE_MILLIS,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit
|
// BRIEF.md: enter at no more than 0.2°, exit at at least 0.35°. The exit threshold
|
||||||
// threshold doubles as the tolerance stated next to the locked label, so the
|
// doubles as the tolerance stated next to the locked label (Codex review).
|
||||||
// rounded readout and "level" claim can never contradict (Codex review).
|
|
||||||
const val DEFAULT_ENTER_DEGREES = 0.2
|
const val DEFAULT_ENTER_DEGREES = 0.2
|
||||||
const val DEFAULT_EXIT_DEGREES = 0.35
|
const val DEFAULT_EXIT_DEGREES = 0.35
|
||||||
const val DEFAULT_DWELL_MILLIS = 400L
|
const val DEFAULT_DWELL_MILLIS = 175L // velocity-gated: short, because a fast crossing can't accumulate
|
||||||
|
const val DEFAULT_SETTLED_DWELL_MILLIS = 400L // no-velocity fallback (Edge)
|
||||||
|
const val DEFAULT_SLOW_RATE_DEG_PER_SEC = 0.3
|
||||||
const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L
|
const val DEFAULT_FEEDBACK_DEBOUNCE_MILLIS = 3_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +55,19 @@ class LockDetector(
|
|||||||
private var withinEnterSinceMillis: Long? = null
|
private var withinEnterSinceMillis: Long? = null
|
||||||
private var lastFeedbackAtMillis: Long? = null
|
private var lastFeedbackAtMillis: Long? = null
|
||||||
|
|
||||||
fun update(stableTiltDegrees: Double, nowMillis: Long): Result {
|
fun update(stableTiltDegrees: Double, rateDegPerSec: Double?, nowMillis: Long): Result {
|
||||||
val magnitude = abs(stableTiltDegrees)
|
val magnitude = abs(stableTiltDegrees)
|
||||||
var fire = false
|
var fire = false
|
||||||
|
|
||||||
if (!locked) {
|
if (!locked) {
|
||||||
if (magnitude <= enterThresholdDegrees) {
|
val inZone = magnitude <= enterThresholdDegrees
|
||||||
|
// With a velocity signal, require slow motion to accumulate; a fast center
|
||||||
|
// crossing (rate above the threshold) never builds lock time.
|
||||||
|
val slowEnough = rateDegPerSec == null || rateDegPerSec <= slowRateThresholdDegPerSec
|
||||||
|
val requiredDwell = if (rateDegPerSec == null) settledDwellMillis else dwellMillis
|
||||||
|
if (inZone && slowEnough) {
|
||||||
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
|
val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
|
||||||
if (nowMillis - since >= dwellMillis) {
|
if (nowMillis - since >= requiredDwell) {
|
||||||
locked = true
|
locked = true
|
||||||
val last = lastFeedbackAtMillis
|
val last = lastFeedbackAtMillis
|
||||||
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
|
if (last == null || nowMillis - last >= feedbackDebounceMillis) {
|
||||||
@@ -58,6 +76,8 @@ class LockDetector(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Out of zone or moving too fast: reset, so a fresh slow-and-centered spell
|
||||||
|
// starts the confirmation dwell over.
|
||||||
withinEnterSinceMillis = null
|
withinEnterSinceMillis = null
|
||||||
}
|
}
|
||||||
} else if (magnitude >= exitThresholdDegrees) {
|
} else if (magnitude >= exitThresholdDegrees) {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import java.util.Locale
|
|||||||
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
||||||
val isForeground = rememberIsResumed()
|
val isForeground = rememberIsResumed()
|
||||||
val tickPolicy = remember { SurfaceTickPolicy() }
|
val tickPolicy = remember { SurfaceTickPolicy() }
|
||||||
val voicePolicy = remember { VoiceGuidancePolicy() }
|
|
||||||
val context = LocalContext.current.applicationContext
|
val context = LocalContext.current.applicationContext
|
||||||
|
|
||||||
val player = remember(mode, isForeground, context) {
|
val player = remember(mode, isForeground, context) {
|
||||||
@@ -47,6 +46,10 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
|||||||
val speaker = remember(mode, isForeground, context) {
|
val speaker = remember(mode, isForeground, context) {
|
||||||
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
|
if (mode == AudioAssistMode.VOICE && isForeground) VoiceSpeaker(context) else null
|
||||||
}
|
}
|
||||||
|
// Fresh per speaker so each VOICE session re-evaluates from the current reading — and
|
||||||
|
// the policy is only consumed once the speaker is ready, so a phrase can't be queued
|
||||||
|
// and then go stale during TTS init (Codex).
|
||||||
|
val voicePolicy = remember(speaker) { VoiceGuidancePolicy() }
|
||||||
DisposableEffect(player) { onDispose { player?.release() } }
|
DisposableEffect(player) { onDispose { player?.release() } }
|
||||||
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
|
DisposableEffect(speaker) { onDispose { speaker?.shutdown() } }
|
||||||
|
|
||||||
@@ -74,20 +77,25 @@ fun AudioLevelAssist(reading: LevelReading?, mode: AudioAssistMode) {
|
|||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
val phrase = voicePolicy.update(
|
// Only run the voice policy once the speaker is ready — never consume its state
|
||||||
VoiceGuidancePolicy.Input(
|
// for a phrase we can't speak yet. When ready, it evaluates the current reading,
|
||||||
isEnabled = mode == AudioAssistMode.VOICE,
|
// so nothing stale gets spoken after init finishes.
|
||||||
isAppForeground = isForeground,
|
if (speaker?.isReady == true) {
|
||||||
placementOk = current.placementOk,
|
val phrase = voicePolicy.update(
|
||||||
presentation = current.surfacePresentation,
|
VoiceGuidancePolicy.Input(
|
||||||
isSettling = current.isSettling,
|
isEnabled = true,
|
||||||
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
|
isAppForeground = isForeground,
|
||||||
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
|
placementOk = current.placementOk,
|
||||||
isLocked = current.isLocked,
|
presentation = current.surfacePresentation,
|
||||||
nowMillis = System.currentTimeMillis(),
|
isSettling = current.isSettling,
|
||||||
),
|
pitchDegrees = current.stableSurfacePitchDegrees ?: 0.0,
|
||||||
)
|
rollDegrees = current.stableSurfaceRollDegrees ?: 0.0,
|
||||||
if (phrase != null) speaker?.speak(phrase)
|
isLocked = current.isLocked,
|
||||||
|
nowMillis = System.currentTimeMillis(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (phrase != null) speaker.speak(phrase)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +184,9 @@ private class SonarSoundPool(context: Context) {
|
|||||||
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
|
* TODO(voice): prefer bundled clips (res/raw) when present, fall back to TTS.
|
||||||
*/
|
*/
|
||||||
private class VoiceSpeaker(context: Context) {
|
private class VoiceSpeaker(context: Context) {
|
||||||
private var ready = false
|
/** The caller waits on this before consuming the voice policy, so nothing goes stale. */
|
||||||
private var pendingText: String? = null // latest phrase asked for before TTS finished init
|
var isReady = false
|
||||||
|
private set
|
||||||
private lateinit var tts: TextToSpeech
|
private lateinit var tts: TextToSpeech
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -190,18 +199,13 @@ private class VoiceSpeaker(context: Context) {
|
|||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
ready = true
|
isReady = true
|
||||||
// The first phrase often arrives before init finishes; the policy won't
|
|
||||||
// re-issue it, so speak the latest queued phrase now.
|
|
||||||
pendingText?.let { tts.speak(it, TextToSpeech.QUEUE_FLUSH, null, "level-voice") }
|
|
||||||
pendingText = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
|
fun speak(phrase: VoiceGuidancePolicy.Phrase) {
|
||||||
val text = textFor(phrase)
|
if (isReady) tts.speak(textFor(phrase), TextToSpeech.QUEUE_FLUSH, null, "level-voice")
|
||||||
if (ready) tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "level-voice") else pendingText = text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
|
private fun textFor(phrase: VoiceGuidancePolicy.Phrase): String = when (phrase) {
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ class LevelPipeline(
|
|||||||
val displayMagnitude = primaryDeadband.update(magnitude)
|
val displayMagnitude = primaryDeadband.update(magnitude)
|
||||||
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
||||||
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
|
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
|
||||||
val lock = lockDetector.update(magnitude, nowMillis)
|
// Velocity-aware lock: ease into center → quick confirm; fly across → nothing.
|
||||||
|
val lock = lockDetector.update(magnitude, settling.movementRateDegreesPerSecond, nowMillis)
|
||||||
LevelReading(
|
LevelReading(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
displayPrimaryDegrees = displayMagnitude,
|
displayPrimaryDegrees = displayMagnitude,
|
||||||
@@ -123,8 +124,10 @@ class LevelPipeline(
|
|||||||
OrientationMath.edgePlumbLeanDegrees(corrected),
|
OrientationMath.edgePlumbLeanDegrees(corrected),
|
||||||
dtSeconds,
|
dtSeconds,
|
||||||
)
|
)
|
||||||
|
// Edge has no movement-rate signal yet: null → classic fixed-dwell lock.
|
||||||
val lock = lockDetector.update(
|
val lock = lockDetector.update(
|
||||||
if (placementOk) level else Double.MAX_VALUE,
|
if (placementOk) level else Double.MAX_VALUE,
|
||||||
|
null,
|
||||||
nowMillis,
|
nowMillis,
|
||||||
)
|
)
|
||||||
val displayLevel = primaryDeadband.update(level)
|
val displayLevel = primaryDeadband.update(level)
|
||||||
|
|||||||
@@ -9,65 +9,105 @@ class LockDetectorTest {
|
|||||||
private fun detector() = LockDetector(
|
private fun detector() = LockDetector(
|
||||||
enterThresholdDegrees = 0.2,
|
enterThresholdDegrees = 0.2,
|
||||||
exitThresholdDegrees = 0.35,
|
exitThresholdDegrees = 0.35,
|
||||||
dwellMillis = 400,
|
dwellMillis = 175,
|
||||||
|
settledDwellMillis = 400,
|
||||||
|
slowRateThresholdDegPerSec = 0.3,
|
||||||
feedbackDebounceMillis = 3_000,
|
feedbackDebounceMillis = 3_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val slow = 0.1 // °/s, below the slow-rate threshold
|
||||||
|
private val fast = 2.0 // °/s, a real center crossing
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `lock requires dwell time inside the enter threshold`() {
|
fun `easing slowly into center locks after the short dwell`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
assertFalse(d.update(0.1, 0).isLocked)
|
assertFalse(d.update(0.1, slow, 0).isLocked)
|
||||||
assertFalse(d.update(0.1, 200).isLocked)
|
assertFalse(d.update(0.1, slow, 174).isLocked)
|
||||||
val result = d.update(0.1, 450)
|
val r = d.update(0.1, slow, 175)
|
||||||
assertTrue(result.isLocked)
|
assertTrue(r.isLocked)
|
||||||
assertTrue(result.fireFeedback)
|
assertTrue(r.fireFeedback)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `leaving the threshold before dwell completes resets the timer`() {
|
fun `flying across center never locks, however long it stays in the zone`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
// In the zone the whole time, but moving fast: the dwell never accumulates.
|
||||||
d.update(0.5, 200) // bounced out
|
for (t in 0..2_000 step 20) assertFalse(d.update(0.05, fast, t.toLong()).isLocked)
|
||||||
d.update(0.1, 300) // back in — dwell restarts
|
}
|
||||||
assertFalse(d.update(0.1, 600).isLocked) // only 300ms since re-entry
|
|
||||||
assertTrue(d.update(0.1, 750).isLocked)
|
@Test
|
||||||
|
fun `intermediate speed does not accumulate lock time`() {
|
||||||
|
val d = detector()
|
||||||
|
val intermediate = 0.5 // between slow (0.3) and a crossing
|
||||||
|
for (t in 0..2_000 step 20) assertFalse(d.update(0.1, intermediate, t.toLong()).isLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `slowing to a stop inside the zone starts a fresh confirmation dwell`() {
|
||||||
|
val d = detector()
|
||||||
|
// In the zone but moving fast until t=300: no accumulation yet.
|
||||||
|
d.update(0.1, fast, 0)
|
||||||
|
d.update(0.1, fast, 300)
|
||||||
|
// Now slow down: dwell starts fresh here, so it must NOT be locked at +100ms...
|
||||||
|
assertFalse(d.update(0.1, slow, 320).isLocked)
|
||||||
|
assertFalse(d.update(0.1, slow, 420).isLocked)
|
||||||
|
// ...but locks 175ms after slowing.
|
||||||
|
assertTrue(d.update(0.1, slow, 495).isLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `leaving the zone before dwell completes resets the timer`() {
|
||||||
|
val d = detector()
|
||||||
|
d.update(0.1, slow, 0)
|
||||||
|
d.update(0.5, slow, 100) // bounced out of the zone
|
||||||
|
d.update(0.1, slow, 150) // back in — dwell restarts
|
||||||
|
assertFalse(d.update(0.1, slow, 300).isLocked) // only 150ms since re-entry
|
||||||
|
assertTrue(d.update(0.1, slow, 325).isLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hysteresis holds the lock between enter and exit thresholds`() {
|
fun `hysteresis holds the lock between enter and exit thresholds`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
d.update(0.1, slow, 0)
|
||||||
assertTrue(d.update(0.1, 500).isLocked)
|
assertTrue(d.update(0.1, slow, 200).isLocked)
|
||||||
// 0.3° is above enter (0.2) but below exit (0.35): still locked.
|
// 0.3° is above enter (0.2) but below exit (0.35): still locked (velocity irrelevant once locked).
|
||||||
assertTrue(d.update(0.3, 600).isLocked)
|
assertTrue(d.update(0.3, fast, 300).isLocked)
|
||||||
// 0.4° exceeds the exit threshold: unlocked.
|
// 0.4° exceeds the exit threshold: unlocked.
|
||||||
assertFalse(d.update(0.4, 700).isLocked)
|
assertFalse(d.update(0.4, slow, 400).isLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `feedback fires once per lock and respects the debounce window`() {
|
fun `feedback fires once per lock and respects the debounce window`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(0.1, 0)
|
d.update(0.1, slow, 0)
|
||||||
assertTrue(d.update(0.1, 500).fireFeedback)
|
assertTrue(d.update(0.1, slow, 200).fireFeedback)
|
||||||
assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
|
assertFalse(d.update(0.1, slow, 300).fireFeedback) // still locked, no re-fire
|
||||||
|
|
||||||
// Rock out and back in quickly: re-lock at ~1500ms is inside the 3s debounce.
|
// Rock out and back quickly: re-lock at ~1500ms is inside the 3s debounce.
|
||||||
d.update(0.5, 900)
|
d.update(0.5, fast, 900)
|
||||||
d.update(0.1, 1000)
|
d.update(0.1, slow, 1000)
|
||||||
val relock = d.update(0.1, 1500)
|
val relock = d.update(0.1, slow, 1500)
|
||||||
assertTrue(relock.isLocked)
|
assertTrue(relock.isLocked)
|
||||||
assertFalse(relock.fireFeedback)
|
assertFalse(relock.fireFeedback)
|
||||||
|
|
||||||
// A re-lock after the debounce window fires again.
|
// A re-lock after the debounce window fires again.
|
||||||
d.update(0.5, 2000)
|
d.update(0.5, fast, 4000)
|
||||||
d.update(0.1, 4000)
|
d.update(0.1, slow, 4100)
|
||||||
assertTrue(d.update(0.1, 4500).fireFeedback)
|
assertTrue(d.update(0.1, slow, 4400).fireFeedback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `without a velocity signal it uses the fixed settled dwell (Edge)`() {
|
||||||
|
val d = detector()
|
||||||
|
assertFalse(d.update(0.1, null, 0).isLocked)
|
||||||
|
assertFalse(d.update(0.1, null, 399).isLocked) // 175 has passed, but the no-rate path needs 400
|
||||||
|
assertTrue(d.update(0.1, null, 400).isLocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `negative readings lock on magnitude`() {
|
fun `negative readings lock on magnitude`() {
|
||||||
val d = detector()
|
val d = detector()
|
||||||
d.update(-0.1, 0)
|
d.update(-0.1, slow, 0)
|
||||||
assertTrue(d.update(-0.1, 500).isLocked)
|
assertTrue(d.update(-0.1, slow, 200).isLocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user