mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Guard BLE-only autoplay and clarify one-pod mode
This commit is contained in:
@@ -312,25 +312,31 @@ fun DeviceSettingsScreen(
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (features.hasDualPods) {
|
||||
val earDetectionActive = reactions.autoPlay || reactions.autoPause
|
||||
val onePodModeActive = reactions.autoPlay ||
|
||||
reactions.autoPause ||
|
||||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_onepod_mode_label),
|
||||
subtitle = stringResource(R.string.settings_onepod_mode_description),
|
||||
icon = Icons.TwoTone.LooksOne,
|
||||
onClick = { if (earDetectionActive) onOnePodModeChange(!reactions.onePodMode) },
|
||||
enabled = earDetectionActive,
|
||||
onClick = { if (onePodModeActive) onOnePodModeChange(!reactions.onePodMode) },
|
||||
enabled = onePodModeActive,
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = reactions.onePodMode,
|
||||
onCheckedChange = onOnePodModeChange,
|
||||
enabled = earDetectionActive,
|
||||
enabled = onePodModeActive,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val earDetectionWarningVisible =
|
||||
(reactions.autoPlay || reactions.autoPause) && !device.isAapConnected
|
||||
(
|
||||
reactions.autoPlay ||
|
||||
reactions.autoPause ||
|
||||
(reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR)
|
||||
) && !device.isAapConnected
|
||||
if (earDetectionWarningVisible) {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.settings_eardetection_info_description),
|
||||
|
||||
@@ -26,112 +26,154 @@ class PlayPause @Inject constructor(
|
||||
private val mediaControl: MediaControl,
|
||||
) {
|
||||
|
||||
fun monitor() = deviceMonitor.primaryDevice()
|
||||
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { shouldMonitor ->
|
||||
if (shouldMonitor) bluetoothManager.connectedDevices else emptyFlow()
|
||||
}
|
||||
.flatMapLatest { connected ->
|
||||
if (connected.isEmpty()) {
|
||||
log(TAG) { "No known devices connected." }
|
||||
emptyFlow()
|
||||
} else {
|
||||
log(TAG) { "Known devices connected: $connected" }
|
||||
deviceMonitor.primaryDevice()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.withPrevious()
|
||||
.filter { (previous, current) ->
|
||||
if (previous == null || current == null) return@filter false
|
||||
// Use profileId (stable across BLE address rotations) rather than BLE identifier.
|
||||
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
|
||||
val match = previous.profileId != null && previous.profileId == current.profileId
|
||||
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
match
|
||||
}
|
||||
.onEach { (previous, current) ->
|
||||
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
|
||||
fun monitor() = run {
|
||||
var pendingPlayConfirmation: PendingPlayConfirmation? = null
|
||||
|
||||
val reactions = current?.reactions
|
||||
if (reactions == null) {
|
||||
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
// Convert to EarDetectionState based on device capabilities
|
||||
val prevState: EarDetectionState
|
||||
val currState: EarDetectionState
|
||||
|
||||
when {
|
||||
previous!!.hasEarDetection && previous.hasDualPods &&
|
||||
current.hasEarDetection && current.hasDualPods -> {
|
||||
// Dual pod devices (AirPods, AirPods Pro, etc.)
|
||||
log(TAG, VERBOSE) {
|
||||
"Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}"
|
||||
}
|
||||
prevState = EarDetectionState.fromDualPod(
|
||||
left = previous.isLeftInEar ?: false,
|
||||
right = previous.isRightInEar ?: false,
|
||||
)
|
||||
currState = EarDetectionState.fromDualPod(
|
||||
left = current.isLeftInEar ?: false,
|
||||
right = current.isRightInEar ?: false,
|
||||
)
|
||||
deviceMonitor.primaryDevice()
|
||||
.map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { shouldMonitor ->
|
||||
if (shouldMonitor) {
|
||||
bluetoothManager.connectedDevices
|
||||
} else {
|
||||
pendingPlayConfirmation = null
|
||||
emptyFlow()
|
||||
}
|
||||
|
||||
previous.hasEarDetection && current.hasEarDetection -> {
|
||||
// Single pod devices (AirPods Max, etc.)
|
||||
log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" }
|
||||
prevState = EarDetectionState.fromSinglePod(worn = previous.isBeingWorn ?: false)
|
||||
currState = EarDetectionState.fromSinglePod(worn = current.isBeingWorn ?: false)
|
||||
}
|
||||
.flatMapLatest { connected ->
|
||||
if (connected.isEmpty()) {
|
||||
pendingPlayConfirmation = null
|
||||
log(TAG) { "No known devices connected." }
|
||||
emptyFlow()
|
||||
} else {
|
||||
log(TAG) { "Known devices connected: $connected" }
|
||||
deviceMonitor.primaryDevice()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.withPrevious()
|
||||
.filter { (previous, current) ->
|
||||
if (previous == null || current == null) return@filter false
|
||||
// Use profileId (stable across BLE address rotations) rather than BLE identifier.
|
||||
// Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause).
|
||||
val match = previous.profileId != null && previous.profileId == current.profileId
|
||||
if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." }
|
||||
match
|
||||
}
|
||||
.onEach { (previous, current) ->
|
||||
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
|
||||
|
||||
else -> {
|
||||
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
|
||||
val reactions = current?.reactions
|
||||
if (reactions == null) {
|
||||
log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" }
|
||||
pendingPlayConfirmation = null
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate what action to take
|
||||
val decision = evaluatePlayPauseAction(
|
||||
previous = prevState,
|
||||
current = currState,
|
||||
onePodMode = reactions.onePodMode,
|
||||
isCurrentlyPlaying = mediaControl.isPlaying,
|
||||
wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap,
|
||||
)
|
||||
// Convert to EarDetectionState based on device capabilities
|
||||
val prevState: EarDetectionState
|
||||
val currState: EarDetectionState
|
||||
|
||||
if (decision.usedRecentCapPauseOverride) {
|
||||
log(TAG, VERBOSE) {
|
||||
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
|
||||
when {
|
||||
previous!!.hasEarDetection && previous.hasDualPods &&
|
||||
current.hasEarDetection && current.hasDualPods -> {
|
||||
// Dual pod devices (AirPods, AirPods Pro, etc.)
|
||||
log(TAG, VERBOSE) {
|
||||
"Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}"
|
||||
}
|
||||
prevState = EarDetectionState.fromDualPod(
|
||||
left = previous.isLeftInEar ?: false,
|
||||
right = previous.isRightInEar ?: false,
|
||||
)
|
||||
currState = EarDetectionState.fromDualPod(
|
||||
left = current.isLeftInEar ?: false,
|
||||
right = current.isRightInEar ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
previous.hasEarDetection && current.hasEarDetection -> {
|
||||
// Single pod devices (AirPods Max, etc.)
|
||||
log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" }
|
||||
prevState = EarDetectionState.fromSinglePod(worn = previous.isBeingWorn ?: false)
|
||||
currState = EarDetectionState.fromSinglePod(worn = current.isBeingWorn ?: false)
|
||||
}
|
||||
|
||||
else -> {
|
||||
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
|
||||
pendingPlayConfirmation = null
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
|
||||
val isCurrentlyPlaying = mediaControl.isPlaying
|
||||
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
|
||||
|
||||
// Evaluate what action to take
|
||||
val rawDecision = evaluatePlayPauseAction(
|
||||
previous = prevState,
|
||||
current = currState,
|
||||
onePodMode = reactions.onePodMode,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
)
|
||||
|
||||
val shouldStageBleOnlyPlay = rawDecision.shouldPlay &&
|
||||
reactions.autoPlay &&
|
||||
!reactions.onePodMode &&
|
||||
current.hasDualPods &&
|
||||
current.aap?.aapEarDetection == null
|
||||
|
||||
val confirmation = applyBleOnlyPlayConfirmation(
|
||||
pending = pendingPlayConfirmation,
|
||||
profileId = current.profileId,
|
||||
onePodMode = reactions.onePodMode,
|
||||
autoPlayEnabled = reactions.autoPlay,
|
||||
rawDecision = rawDecision,
|
||||
currentState = currState,
|
||||
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
)
|
||||
pendingPlayConfirmation = confirmation.pending
|
||||
|
||||
if (confirmation.stagedConfirmation) {
|
||||
log(TAG, VERBOSE) { "Staging BLE-only autoplay confirmation for ${current.profileId}" }
|
||||
}
|
||||
if (confirmation.confirmedPendingPlay) {
|
||||
log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" }
|
||||
}
|
||||
|
||||
val decision = confirmation.decision
|
||||
if (decision.usedRecentCapPauseOverride) {
|
||||
log(TAG, VERBOSE) {
|
||||
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
|
||||
}
|
||||
}
|
||||
log(TAG, VERBOSE) { "Decision: ${decision.reason}" }
|
||||
|
||||
// Execute the decision
|
||||
when {
|
||||
decision.shouldPlay && reactions.autoPlay -> {
|
||||
log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" }
|
||||
mediaControl.sendPlay()
|
||||
}
|
||||
|
||||
decision.shouldPlay && !reactions.autoPlay -> {
|
||||
log(TAG, VERBOSE) { "autoPlay is disabled" }
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
|
||||
mediaControl.sendPause()
|
||||
}
|
||||
|
||||
decision.shouldPause && !reactions.autoPause -> {
|
||||
log(TAG, VERBOSE) { "autoPause is disabled" }
|
||||
}
|
||||
}
|
||||
}
|
||||
log(TAG, VERBOSE) { "Decision: ${decision.reason}" }
|
||||
|
||||
// Execute the decision
|
||||
when {
|
||||
decision.shouldPlay && reactions.autoPlay -> {
|
||||
log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" }
|
||||
mediaControl.sendPlay()
|
||||
}
|
||||
|
||||
decision.shouldPlay && !reactions.autoPlay -> {
|
||||
log(TAG, VERBOSE) { "autoPlay is disabled" }
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
|
||||
mediaControl.sendPause()
|
||||
}
|
||||
|
||||
decision.shouldPause && !reactions.autoPause -> {
|
||||
log(TAG, VERBOSE) { "autoPause is disabled" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||
}
|
||||
|
||||
internal fun evaluatePlayPauseAction(
|
||||
previous: EarDetectionState,
|
||||
@@ -212,6 +254,63 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun applyBleOnlyPlayConfirmation(
|
||||
pending: PendingPlayConfirmation?,
|
||||
profileId: String?,
|
||||
onePodMode: Boolean,
|
||||
autoPlayEnabled: Boolean,
|
||||
rawDecision: PlayPauseDecision,
|
||||
currentState: EarDetectionState,
|
||||
shouldStageBleOnlyPlay: Boolean,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
): PlayConfirmationResult {
|
||||
val activePending = pending?.takeIf {
|
||||
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
|
||||
}
|
||||
|
||||
if (activePending != null &&
|
||||
currentState == activePending.targetState &&
|
||||
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
|
||||
) {
|
||||
return PlayConfirmationResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "${activePending.reason} (confirmed by a second state update)",
|
||||
usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByUs,
|
||||
),
|
||||
pending = null,
|
||||
confirmedPendingPlay = true,
|
||||
stagedConfirmation = false,
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldStageBleOnlyPlay && profileId != null) {
|
||||
return PlayConfirmationResult(
|
||||
decision = rawDecision.copy(
|
||||
shouldPlay = false,
|
||||
reason = "${rawDecision.reason} (waiting for BLE confirmation)",
|
||||
),
|
||||
pending = PendingPlayConfirmation(
|
||||
profileId = profileId,
|
||||
onePodMode = onePodMode,
|
||||
targetState = currentState,
|
||||
reason = rawDecision.reason,
|
||||
),
|
||||
confirmedPendingPlay = false,
|
||||
stagedConfirmation = true,
|
||||
)
|
||||
}
|
||||
|
||||
return PlayConfirmationResult(
|
||||
decision = rawDecision,
|
||||
pending = null,
|
||||
confirmedPendingPlay = false,
|
||||
stagedConfirmation = false,
|
||||
)
|
||||
}
|
||||
|
||||
data class EarDetectionState(
|
||||
val leftInEar: Boolean?, // null for single pod devices
|
||||
val rightInEar: Boolean?, // null for single pod devices
|
||||
@@ -250,6 +349,20 @@ class PlayPause @Inject constructor(
|
||||
val usedRecentCapPauseOverride: Boolean = false,
|
||||
)
|
||||
|
||||
data class PendingPlayConfirmation(
|
||||
val profileId: String,
|
||||
val onePodMode: Boolean,
|
||||
val targetState: EarDetectionState,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class PlayConfirmationResult(
|
||||
val decision: PlayPauseDecision,
|
||||
val pending: PendingPlayConfirmation?,
|
||||
val confirmedPendingPlay: Boolean,
|
||||
val stagedConfirmation: Boolean,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "PlayPause")
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<string name="settings_compat_offloaded_batching_disabled_summary">Don\'t let the system group collected BLE data before forwarding it to us.</string>
|
||||
|
||||
<string name="settings_onepod_mode_label">One pod mode</string>
|
||||
<string name="settings_onepod_mode_description">Wearing both pods is not required, wearing a single pod is sufficient to trigger reactions.</string>
|
||||
<string name="settings_onepod_mode_description">Treat a single in-ear pod as worn. Auto play/pause and "In ear" auto-connect react to either pod instead of requiring both.</string>
|
||||
<string name="settings_popup_caseopen_label">Show case popup</string>
|
||||
<string name="settings_popup_caseopen_description">Show a popup when the device case is opened (experimental).</string>
|
||||
<string name="settings_popup_connected_label">Show connection popup</string>
|
||||
@@ -528,4 +528,4 @@
|
||||
<string name="stem_actions_reset_label">Reset to defaults</string>
|
||||
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
|
||||
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -477,6 +477,139 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class BleConfirmationTests {
|
||||
|
||||
@Test
|
||||
fun `ble-only normal mode autoplay waits for confirmation`() {
|
||||
val rawDecision = playPause.evaluatePlayPauseAction(
|
||||
previous = EarDetectionState.fromDualPod(left = true, right = false),
|
||||
current = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
)
|
||||
|
||||
val result = playPause.applyBleOnlyPlayConfirmation(
|
||||
pending = null,
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
autoPlayEnabled = true,
|
||||
rawDecision = rawDecision,
|
||||
currentState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
shouldStageBleOnlyPlay = true,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
)
|
||||
|
||||
result.decision.shouldPlay shouldBe false
|
||||
result.decision.reason shouldBe "Normal mode: both pods in ear (waiting for BLE confirmation)"
|
||||
result.pending shouldBe PlayPause.PendingPlayConfirmation(
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
targetState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
reason = "Normal mode: both pods in ear",
|
||||
)
|
||||
result.stagedConfirmation shouldBe true
|
||||
result.confirmedPendingPlay shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ble-only normal mode autoplay confirms on stable follow-up state`() {
|
||||
val pending = PlayPause.PendingPlayConfirmation(
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
targetState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
reason = "Normal mode: both pods in ear",
|
||||
)
|
||||
|
||||
val rawDecision = playPause.evaluatePlayPauseAction(
|
||||
previous = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
current = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
)
|
||||
|
||||
val result = playPause.applyBleOnlyPlayConfirmation(
|
||||
pending = pending,
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
autoPlayEnabled = true,
|
||||
rawDecision = rawDecision,
|
||||
currentState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
shouldStageBleOnlyPlay = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
)
|
||||
|
||||
result.decision.shouldPlay shouldBe true
|
||||
result.decision.reason shouldBe "Normal mode: both pods in ear (confirmed by a second state update)"
|
||||
result.pending shouldBe null
|
||||
result.stagedConfirmation shouldBe false
|
||||
result.confirmedPendingPlay shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aap-backed autoplay bypasses confirmation`() {
|
||||
val rawDecision = playPause.evaluatePlayPauseAction(
|
||||
previous = EarDetectionState.fromDualPod(left = true, right = false),
|
||||
current = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
)
|
||||
|
||||
val result = playPause.applyBleOnlyPlayConfirmation(
|
||||
pending = null,
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
autoPlayEnabled = true,
|
||||
rawDecision = rawDecision,
|
||||
currentState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
shouldStageBleOnlyPlay = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
)
|
||||
|
||||
result.decision.shouldPlay shouldBe true
|
||||
result.pending shouldBe null
|
||||
result.stagedConfirmation shouldBe false
|
||||
result.confirmedPendingPlay shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending ble-only autoplay is dropped when state reverts`() {
|
||||
val pending = PlayPause.PendingPlayConfirmation(
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
targetState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
reason = "Normal mode: both pods in ear",
|
||||
)
|
||||
|
||||
val rawDecision = playPause.evaluatePlayPauseAction(
|
||||
previous = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
current = EarDetectionState.fromDualPod(left = true, right = false),
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
)
|
||||
|
||||
val result = playPause.applyBleOnlyPlayConfirmation(
|
||||
pending = pending,
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
autoPlayEnabled = true,
|
||||
rawDecision = rawDecision,
|
||||
currentState = EarDetectionState.fromDualPod(left = true, right = false),
|
||||
shouldStageBleOnlyPlay = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
)
|
||||
|
||||
result.decision.shouldPlay shouldBe false
|
||||
result.pending shouldBe null
|
||||
result.stagedConfirmation shouldBe false
|
||||
result.confirmedPendingPlay shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class EarDetectionStateTests {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user