Add autopause and autoplay

This commit is contained in:
darken
2022-01-04 23:49:36 +01:00
parent 2b965ea098
commit 6e0a000eb3
20 changed files with 164 additions and 92 deletions
@@ -0,0 +1,58 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.os.SystemClock
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.delay
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaControl @Inject constructor(
private val audioManager: AudioManager,
) {
val isPlaying: Boolean
get() = audioManager.isMusicActive
suspend fun sendPlay() {
log(TAG) { "sendPlay()" }
if (audioManager.isMusicActive) {
log(TAG) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
}
suspend fun sendPause() {
log(TAG) { "sendPause()" }
if (!audioManager.isMusicActive) {
log(TAG) { "Music is not playing, not sending pause" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (audioManager.isMusicActive) {
sendPause()
} else {
sendPlay()
}
}
private suspend fun sendKey(keyCode: Int) {
log(TAG) { "Sending up+down KeyEvent: $keyCode" }
val eventTime = SystemClock.uptimeMillis()
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0))
delay(100)
audioManager.dispatchMediaKeyEvent(KeyEvent(eventTime + 200, eventTime + 200, KeyEvent.ACTION_UP, keyCode, 0))
}
companion object {
private val TAG = logTag("MediaControl")
}
}
@@ -27,7 +27,7 @@ class BleScanner @Inject constructor(
fun scan(
filter: Set<ScanFilter> = ProximityPairing.getBleScanFilter(),
mode: Int = ScanSettings.SCAN_MODE_LOW_POWER,
delay: Long = 1,
delay: Long = 100,
): Flow<List<ScanResult>> = callbackFlow {
val scanner = bluetoothManager.adapter.bluetoothLeScanner
@@ -4,6 +4,7 @@ import android.app.Application
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.Context
import android.media.AudioManager
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
@@ -33,4 +34,9 @@ class AndroidModule {
@Singleton
fun workerManager(context: Context): WorkManager =
WorkManager.getInstance(context)
@Provides
@Singleton
fun audioManager(context: Context): AudioManager =
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
@@ -39,6 +39,11 @@ class GeneralSettings @Inject constructor(
false
)
val autoPlay = preferences.createFlowPreference(
"core.eardetection.autoplay.enabled",
false
)
val showAll = preferences.createFlowPreference(
"core.showall.enabled",
false
@@ -48,6 +53,7 @@ class GeneralSettings @Inject constructor(
monitorMode,
scannerMode,
autoPause,
autoPlay,
showAll,
debugSettings.isDebugModeEnabled,
debugSettings.isAutoReportEnabled
@@ -14,6 +14,7 @@ import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isGrantedOrNotRequired
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.pods.BasicSingleApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.pods.DualApplePodsCardVH
@@ -50,6 +51,11 @@ class OverviewFragmentVM @Inject constructor(
delay(1000)
}
}
val showManualRefresh = generalSettings.monitorMode.flow
.map { it == MonitorMode.MANUAL }
.asLiveData2()
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
private val requiredPermissions: Flow<List<Permission>> = permissionCheckTrigger
.map {
@@ -66,17 +72,16 @@ class OverviewFragmentVM @Inject constructor(
private val pods: Flow<List<PodDevice>> = requiredPermissions
.flatMapLatest { permissions ->
if (permissions.isEmpty()) {
combine(podMonitor.pods, generalSettings.showAll.flow) { pods, showAll ->
if (showAll) {
pods
.sortedByDescending { it.rssi }
} else {
pods
.maxByOrNull { it.rssi }
?.let { listOf(it) }
?: emptyList()
generalSettings.showAll.flow
.flatMapLatest { showAll ->
if (showAll) {
podMonitor.devices
} else {
podMonitor.mainDevice.map { mainDevice ->
mainDevice?.let { listOf(it) } ?: emptyList()
}
}
}
}
} else {
channelFlow {
send(emptyList())
@@ -41,7 +41,7 @@ class SingleApplePodsCardVH(parent: ViewGroup) :
sb.append("\n").append(device.getBatteryLevelHeadset(context))
when {
device.isHeadsetBeingCharged -> sb.append("\n").append("Charging")
device.isHeadsetBeingWorn -> sb.append("\n").append("In ear")
device.isHeadphonesBeingWorn -> sb.append("\n").append("In ear")
else -> {}
}
text = sb
@@ -23,7 +23,7 @@ class PodMonitor @Inject constructor(
private val generalSettings: GeneralSettings,
) {
val pods: Flow<List<PodDevice>> = generalSettings.scannerMode.flow
val devices: Flow<List<PodDevice>> = generalSettings.scannerMode.flow
.flatMapLatest {
bleScanner.scan(
mode = when (it) {
@@ -80,6 +80,13 @@ class PodMonitor @Inject constructor(
pods
}
val mainDevice: Flow<PodDevice?>
get() = devices.map { devices ->
devices.maxByOrNull { it.rssi }?.let {
if (it.rssi > -85) it else null
}
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
}
@@ -8,12 +8,14 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.MediaControl
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.flow.withPrevious
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isGrantedOrNotRequired
import eu.darken.capod.main.core.GeneralSettings
@@ -21,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
@@ -36,6 +39,7 @@ class MonitorWorker @AssistedInject constructor(
private val monitorNotifications: MonitorNotifications,
private val notificationManager: NotificationManager,
private val generalSettings: GeneralSettings,
private val mediaControl: MediaControl,
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
@@ -105,18 +109,31 @@ class MonitorWorker @AssistedInject constructor(
}
.launchIn(workerScope)
val monitorJob = podMonitor.pods
val monitorJob = podMonitor.mainDevice
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart {
setForeground(monitorNotifications.getForegroundInfo(null))
}
.onEach {
.onEach { currentDevice ->
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
monitorNotifications.getNotification(it.firstOrNull())
monitorNotifications.getNotification(currentDevice)
)
}
.withPrevious()
.onEach { (previous, current) ->
if (previous is HasEarDetection && current is HasEarDetection) {
log(TAG) { "previous=${previous.isBeingWorn}, current=${current.isBeingWorn}" }
log(TAG) { "previous-id=${previous.identifier}, current-id=${current.identifier}" }
if (previous.identifier == current.identifier && previous.isBeingWorn != current.isBeingWorn) {
if (generalSettings.autoPlay.value && !mediaControl.isPlaying) {
mediaControl.sendPlay()
} else if (generalSettings.autoPause.value && mediaControl.isPlaying) {
mediaControl.sendPause()
}
}
}
}
.launchIn(workerScope)
log(TAG, VERBOSE) { "Monitor job is active" }
@@ -60,7 +60,7 @@ class MonitorNotifications @Inject constructor(
fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
if (device == null) {
builder.setContentTitle(context.getString(R.string.device_status_loading_message))
builder.setContentTitle(context.getString(R.string.device_none_label))
builder.setSmallIcon(R.drawable.ic_device_generic_earbuds)
return builder
}
@@ -12,14 +12,4 @@ interface DualPodDevice : PodDevice {
val batteryRightPodPercent: Float?
val batteryCasePercent: Float?
val isLeftPodInEar: Boolean
val isRightPodInEar: Boolean
val isCaseCharging: Boolean
val isLeftPodCharging: Boolean
val isRightPodCharging: Boolean
}
@@ -0,0 +1,7 @@
package eu.darken.capod.pods.core
interface HasEarDetection : PodDevice {
val isBeingWorn: Boolean
}
@@ -9,8 +9,9 @@ import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.HasEarDetection
interface DualApplePods : ApplePods, DualPodDevice {
interface DualApplePods : ApplePods, DualPodDevice, HasEarDetection {
val microPhonePod: Pod
get() = when (rawStatus.isBitSet(5)) {
@@ -63,28 +64,31 @@ interface DualApplePods : ApplePods, DualPodDevice {
}
}
override val isLeftPodInEar: Boolean
val isLeftPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3)
}
override val isRightPodInEar: Boolean
val isRightPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(3)
Pod.RIGHT -> rawStatus.isBitSet(1)
}
override val isCaseCharging: Boolean
override val isBeingWorn: Boolean
get() = isLeftPodInEar && isRightPodInEar
val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
override val isLeftPodCharging: Boolean
val isLeftPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
}
override val isRightPodCharging: Boolean
val isRightPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
@@ -4,8 +4,9 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasEarDetection
interface SingleApplePods : BasicSingleApplePods {
interface SingleApplePods : BasicSingleApplePods, HasEarDetection {
val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
@@ -18,7 +19,7 @@ interface SingleApplePods : BasicSingleApplePods {
}
}
val isHeadsetBeingWorn: Boolean
val isHeadphonesBeingWorn: Boolean
get() = rawStatus.isBitSet(1)
val isCaseCharging: Boolean
@@ -26,4 +27,7 @@ interface SingleApplePods : BasicSingleApplePods {
val isHeadsetBeingCharged: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(0)
override val isBeingWorn: Boolean
get() = isHeadphonesBeingWorn
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM9.5,16.5v-9l7,4.5L9.5,16.5z" />
</vector>
+1 -10
View File
@@ -22,17 +22,8 @@
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:listitem="@layout/main_toggle_item"
tools:listitem="@layout/overview_pods_apple_dual_item"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@drawable/ic_baseline_refresh_24" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/card"
style="@style/MyCardView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".main.ui.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/primary_info"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:text="Enable or disable the functionality of this app."
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/toggle_action"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:text="Enable"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/primary_info" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
+4 -2
View File
@@ -2,7 +2,6 @@
<string name="app_name">CAPod</string>
<string name="app_name_pro">CAPod Pro</string>
<string name="device_status_loading_message">Loading device status</string>
<string name="notification_channel_device_status_label">Device status</string>
<string name="general_error_label">Error</string>
@@ -82,6 +81,7 @@
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
<string name="settings_debug_autoreports_description">Automatically reports issues, e.g. details on an app crash so I can figure out how to fix it.</string>
<string name="device_unknown_label">Unknown device</string>
<string name="device_none_label">No device</string>
<string name="pods_single_headphones_label">Headphones</string>
<string name="settings_debug_mode_label">Debug mode</string>
<string name="settings_debug_mode_description">Show additional information to troubleshoot issues.</string>
@@ -96,7 +96,9 @@
<string name="settings_scanner_mode_balanced_label">Balanced</string>
<string name="settings_scanner_mode_lowlatency_label">Low latency</string>
<string name="settings_autopause_label">Auto pause</string>
<string name="settings_autopause_description">Pause music when removing the device from your ear (if your device supports it).</string>
<string name="settings_autopause_description">Pause music when removing the device from your ear (if supported).</string>
<string name="settings_showall_label">Show all devices</string>
<string name="settings_showall_description">Show other people\'s devices that are near you.</string>
<string name="settings_autopplay_label">Auto play</string>
<string name="settings_autoplay_description">Start music playback music when wearing the device (if supported).</string>
</resources>
@@ -13,6 +13,12 @@
android:summary="@string/settings_scanner_mode_description"
android:title="@string/settings_scanner_mode_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_play_circle_24"
android:key="core.eardetection.autoplay.enabled"
android:summary="@string/settings_autoplay_description"
android:title="@string/settings_autopplay_label" />
<CheckBoxPreference
android:icon="@drawable/ic_baseline_pause_circle_24"
android:key="core.eardetection.autopause.enabled"
@@ -29,7 +29,7 @@ class SingleApplePodsTest : BaseAirPodsTest() {
isCaseCharging shouldBe false
isHeadsetBeingCharged shouldBe false
isHeadsetBeingWorn shouldBe true
isHeadphonesBeingWorn shouldBe true
batteryCasePercent shouldBe 0.0f
}
}
@@ -25,7 +25,7 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isCaseCharging shouldBe false
isHeadsetBeingCharged shouldBe false
isHeadsetBeingWorn shouldBe true
isHeadphonesBeingWorn shouldBe true
batteryCasePercent shouldBe 0.0f
}
}
@@ -48,7 +48,7 @@ class AirPodsMaxTest : BaseAirPodsTest() {
isCaseCharging shouldBe false
isHeadsetBeingCharged shouldBe false
isHeadsetBeingWorn shouldBe true
isHeadphonesBeingWorn shouldBe true
batteryCasePercent shouldBe 0.0f
}
}