Pod status cards, mvp

This commit is contained in:
darken
2021-12-31 17:35:08 +01:00
parent deb513f998
commit 022e0c7405
55 changed files with 1132 additions and 333 deletions
+32 -10
View File
@@ -3,15 +3,33 @@
xmlns:tools="http://schemas.android.com/tools"
package="eu.darken.capod">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<!-- <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />-->
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:name="eu.darken.capod.App"
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -20,7 +38,7 @@
android:theme="@style/AppTheme">
<activity
android:name="eu.darken.capod.main.ui.MainActivity"
android:name=".main.ui.MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
@@ -31,7 +49,7 @@
</activity>
<receiver
android:name="eu.darken.capod.monitor.core.receiver.BluetoothEventReceiver"
android:name=".monitor.core.receiver.BluetoothEventReceiver"
android:enabled="true"
android:exported="true"
android:label="Service trigger">
@@ -55,10 +73,10 @@
<!-- Debug stuff-->
<activity
android:name="eu.darken.capod.common.debug.recording.ui.RecorderActivity"
android:name=".common.debug.recording.ui.RecorderActivity"
android:theme="@style/AppThemeFloating" />
<service android:name="eu.darken.capod.common.debug.recording.core.RecorderService" />
<service android:name=".common.debug.recording.core.RecorderService" />
<!-- Worker stuff-->
<service
@@ -70,6 +88,10 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<meta-data
android:name="com.bugsnag.android.API_KEY"
android:value="${bugsnagApiKey}" />
</application>
</manifest>
+3 -3
View File
@@ -6,7 +6,7 @@ import androidx.work.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.bugreporting.BugReporter
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.logging.*
import eu.darken.capod.monitor.core.worker.MonitorControl
import kotlinx.coroutines.CoroutineScope
@@ -17,7 +17,7 @@ import javax.inject.Inject
open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var bugReporter: BugReporter
@Inject lateinit var autoReporting: AutoReporting
@Inject lateinit var monitorControl: MonitorControl
@Inject @AppScope lateinit var appScope: CoroutineScope
@@ -29,7 +29,7 @@ open class App : Application(), Configuration.Provider {
.log { message -> log(TAG) { "ReLinker: $message" } }
.loadLibrary(this, "bugsnag-plugin-android-anr")
bugReporter.setup()
autoReporting.setup()
log(TAG) { "onCreate() done! ${Exception().asLog()}" }
@@ -14,3 +14,5 @@ object BuildWrap {
}
fun hasApiLevel(level: Int): Boolean = BuildWrap.VERSION.SDK_INT >= level
fun withinApiLevel(start: Int, end: Int): Boolean = BuildWrap.VERSION.SDK_INT in start..end
@@ -10,7 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.bugreporting
package eu.darken.capod.common.debug
import com.bugsnag.android.Bugsnag
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
@@ -8,7 +8,7 @@ import eu.darken.capod.common.debug.logging.logTag
object Bugs {
var ready = false
fun report(exception: Exception) {
fun report(exception: Throwable) {
log(TAG, VERBOSE) { "Reporting $exception" }
if (!ready) {
log(TAG, WARN) { "Bug tracking not initialized yet." }
@@ -1,13 +1,14 @@
package eu.darken.capod.common.debug.bugreporting
package eu.darken.capod.common.debug.autoreport
import android.content.Context
import com.bugsnag.android.Bugsnag
import com.bugsnag.android.Configuration
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.InstallId
import eu.darken.capod.common.debug.bugsnag.BugsnagErrorHandler
import eu.darken.capod.common.debug.bugsnag.BugsnagLogger
import eu.darken.capod.common.debug.bugsnag.NOPBugsnagErrorHandler
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagErrorHandler
import eu.darken.capod.common.debug.autoreport.bugsnag.BugsnagLogger
import eu.darken.capod.common.debug.autoreport.bugsnag.NOPBugsnagErrorHandler
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -16,9 +17,9 @@ import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class BugReporter @Inject constructor(
class AutoReporting @Inject constructor(
@ApplicationContext private val context: Context,
private val bugReportSettings: BugReportSettings,
private val debugSettings: DebugSettings,
private val installId: InstallId,
private val bugsnagLogger: Provider<BugsnagLogger>,
private val bugsnagErrorHandler: Provider<BugsnagErrorHandler>,
@@ -26,12 +27,12 @@ class BugReporter @Inject constructor(
) {
fun setup() {
val isEnabled = bugReportSettings.isEnabled.value
val isEnabled = debugSettings.isAutoReportEnabled.value
log(TAG) { "setup(): isEnabled=$isEnabled" }
try {
val bugsnagConfig = Configuration.load(context).apply {
if (bugReportSettings.isEnabled.value) {
if (debugSettings.isAutoReportEnabled.value) {
Logging.install(bugsnagLogger.get())
setUser(installId.id, null, null)
autoTrackSessions = true
@@ -52,6 +53,6 @@ class BugReporter @Inject constructor(
}
companion object {
private val TAG = logTag("BugReporter")
private val TAG = logTag("Debug", "AutoReport")
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.bugreporting
package eu.darken.capod.common.debug.autoreport
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -7,7 +7,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BugReportSettings @Inject constructor(
class DebugSettings @Inject constructor(
@ApplicationContext private val context: Context,
) {
@@ -15,6 +15,6 @@ class BugReportSettings @Inject constructor(
context.getSharedPreferences("bugreport_settings", Context.MODE_PRIVATE)
}
val isEnabled = prefs.createFlowPreference("bugreport.automatic.enabled", true)
val isAutoReportEnabled = prefs.createFlowPreference("bugreport.automatic.enabled", true)
}
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.bugsnag
package eu.darken.capod.common.debug.autoreport.bugsnag
import android.annotation.SuppressLint
import android.content.Context
@@ -7,6 +7,8 @@ import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.BuildConfig
import eu.darken.capod.common.BuildConfigWrap
import eu.darken.capod.common.debug.autoreport.DebugSettings
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
@@ -17,6 +19,7 @@ import javax.inject.Singleton
class BugsnagErrorHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val bugsnagLogger: BugsnagLogger,
private val debugSettings: DebugSettings,
) : OnErrorCallback {
override fun onError(event: Event): Boolean {
@@ -29,7 +32,7 @@ class BugsnagErrorHandler @Inject constructor(
context.tryFormattedSignature()?.let { event.addMetadata(tab, "signatures", it) }
}
return !BuildConfig.DEBUG
return debugSettings.isAutoReportEnabled.value && !BuildConfigWrap.DEBUG
}
companion object {
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.bugsnag
package eu.darken.capod.common.debug.autoreport.bugsnag
import com.bugsnag.android.Event
import eu.darken.capod.common.debug.logging.Logging
@@ -1,4 +1,4 @@
package eu.darken.capod.common.debug.bugsnag
package eu.darken.capod.common.debug.autoreport.bugsnag
import com.bugsnag.android.Event
import com.bugsnag.android.OnErrorCallback
@@ -6,13 +6,22 @@ import android.os.Build
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import eu.darken.capod.R
import eu.darken.capod.common.withinApiLevel
enum class Permission(
val minApiLevel: Int,
val maxApiLevel: Int = Int.MAX_VALUE,
@StringRes val labelRes: Int,
@StringRes val descriptionRes: Int,
val permissionId: String,
) {
BLUETOOTH(
minApiLevel = Build.VERSION_CODES.BASE,
maxApiLevel = Build.VERSION_CODES.R,
labelRes = R.string.permission_bluetooth_label,
descriptionRes = R.string.permission_bluetooth_description,
permissionId = "android.permission.BLUETOOTH",
),
BLUETOOTH_CONNECT(
minApiLevel = Build.VERSION_CODES.S,
labelRes = R.string.permission_bluetooth_connect_label,
@@ -24,9 +33,17 @@ enum class Permission(
labelRes = R.string.permission_bluetooth_scan_label,
descriptionRes = R.string.permission_bluetooth_scan_description,
permissionId = "android.permission.BLUETOOTH_SCAN",
),
ACCESS_FINE_LOCATION(
minApiLevel = Build.VERSION_CODES.S,
maxApiLevel = Build.VERSION_CODES.R,
labelRes = R.string.permission_access_fine_location_label,
descriptionRes = R.string.permission_access_fine_location_description,
permissionId = "android.permission.ACCESS_FINE_LOCATION",
)
}
fun Permission.isGranted(context: Context): Boolean {
return ContextCompat.checkSelfPermission(context, permissionId) == PackageManager.PERMISSION_GRANTED
fun Permission.isGrantedOrNotRequired(context: Context): Boolean = when {
!withinApiLevel(minApiLevel, maxApiLevel) -> true
else -> ContextCompat.checkSelfPermission(context, permissionId) == PackageManager.PERMISSION_GRANTED
}
@@ -1,78 +0,0 @@
package eu.darken.capod.main.ui
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.common.livedata.SingleLiveEvent
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.permissions.isGranted
import eu.darken.capod.common.uix.ViewModel3
import eu.darken.capod.main.ui.cards.PermissionCardVH
import eu.darken.capod.main.ui.cards.ToggleCardVH
import kotlinx.coroutines.flow.*
import java.util.*
import javax.inject.Inject
@HiltViewModel
class MainFragmentVM @Inject constructor(
handle: SavedStateHandle,
@ApplicationContext private val context: Context,
dispatcherProvider: DispatcherProvider,
private val recorderModule: RecorderModule,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
private val enabledState: Flow<Boolean> = flow {
emit(false)
}
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
private val requiredPermissions: Flow<List<Permission>> = permissionCheckTrigger.map {
Permission.values()
.filter { hasApiLevel(it.minApiLevel) && !it.isGranted(context) }
}
val requestPermissionevent = SingleLiveEvent<Permission>()
val listItems: LiveData<List<MainAdapter.Item>> = combine(
enabledState,
requiredPermissions
) { state, permissions ->
val items = mutableListOf<MainAdapter.Item>()
ToggleCardVH.Item(
isEnabled = state,
onToggle = {
}
).run { items.add(this) }
permissions
.map {
PermissionCardVH.Item(
permission = it,
onRequest = { requestPermissionevent.postValue(it) }
)
}
.forEach { items.add(it) }
items
}.asLiveData2()
fun onPermissionResult(granted: Boolean) {
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
}
fun toggleDebugLog() = launch {
recorderModule.startRecorder()
}
fun goToSettings() = launch {
}
}
@@ -1,35 +0,0 @@
package eu.darken.capod.main.ui.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.databinding.MainToggleItemBinding
import eu.darken.capod.main.ui.MainAdapter
class ToggleCardVH(parent: ViewGroup) :
MainAdapter.BaseVH<ToggleCardVH.Item, MainToggleItemBinding>(
R.layout.main_toggle_item,
parent
) {
override val viewBinding = lazy {
MainToggleItemBinding.bind(itemView)
}
override val onBindData: MainToggleItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = { item, _ ->
toggleAction.text = when (item.isEnabled) {
true -> "Disable"
false -> "Enable"
}
itemView.setOnClickListener { item.onToggle() }
}
data class Item(
val isEnabled: Boolean,
val onToggle: () -> Unit
) : MainAdapter.Item {
override val stableId: Long = this.javaClass.hashCode().toLong()
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.main.ui
package eu.darken.capod.main.ui.overview
import android.view.ViewGroup
import androidx.annotation.LayoutRes
@@ -11,20 +11,22 @@ import eu.darken.capod.common.lists.differ.setupDiffer
import eu.darken.capod.common.lists.modular.ModularAdapter
import eu.darken.capod.common.lists.modular.mods.DataBinderMod
import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
import eu.darken.capod.main.ui.cards.PermissionCardVH
import eu.darken.capod.main.ui.cards.ToggleCardVH
import eu.darken.capod.main.ui.overview.cards.DualApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCardVH
import javax.inject.Inject
class MainAdapter @Inject constructor() :
ModularAdapter<MainAdapter.BaseVH<MainAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<MainAdapter.Item> {
class OverviewAdapter @Inject constructor() :
ModularAdapter<OverviewAdapter.BaseVH<OverviewAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<OverviewAdapter.Item> {
override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
init {
modules.add(DataBinderMod(data))
modules.add(TypedVHCreatorMod({ data[it] is ToggleCardVH.Item }) { ToggleCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is DualApplePodsCardVH.Item }) { DualApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
}
override fun getItemCount(): Int = data.size
@@ -1,4 +1,4 @@
package eu.darken.capod.main.ui
package eu.darken.capod.main.ui.overview
import android.os.Bundle
import android.view.View
@@ -17,13 +17,13 @@ import eu.darken.capod.databinding.MainFragmentBinding
import javax.inject.Inject
@AndroidEntryPoint
class MainFragment : Fragment3(R.layout.main_fragment) {
class OverviewFragment : Fragment3(R.layout.main_fragment) {
override val vm: MainFragmentVM by viewModels()
override val vm: OverviewFragmentVM by viewModels()
override val ui: MainFragmentBinding by viewBinding()
@Inject
lateinit var adapter: MainAdapter
lateinit var adapter: OverviewAdapter
lateinit var permissionLauncher: ActivityResultLauncher<String>
@@ -37,10 +37,10 @@ class MainFragment : Fragment3(R.layout.main_fragment) {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.apply {
list.setupDefaults(adapter)
}
ui.toolbar.apply {
subtitle = BuildConfigWrap.VERSION_DESCRIPTION
setOnMenuItemClickListener {
@@ -0,0 +1,107 @@
package eu.darken.capod.main.ui.overview
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.recording.core.RecorderModule
import eu.darken.capod.common.livedata.SingleLiveEvent
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.ui.overview.cards.DualApplePodsCardVH
import eu.darken.capod.main.ui.overview.cards.PermissionCardVH
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCardVH
import eu.darken.capod.monitor.core.PodMonitor
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.DualApplePods
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import java.util.*
import javax.inject.Inject
@HiltViewModel
class OverviewFragmentVM @Inject constructor(
handle: SavedStateHandle,
@ApplicationContext private val context: Context,
dispatcherProvider: DispatcherProvider,
private val recorderModule: RecorderModule,
private val monitorControl: MonitorControl,
private val podMonitor: PodMonitor,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {
private val enabledState: Flow<Boolean> = flow {
emit(false)
}
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
private val requiredPermissions: Flow<List<Permission>> = permissionCheckTrigger
.map {
Permission.values().filter { !it.isGrantedOrNotRequired(context) }
}
.onEach {
if (it.isEmpty()) {
monitorControl.startMonitor()
}
}
val requestPermissionevent = SingleLiveEvent<Permission>()
private val pods: Flow<List<PodDevice>> = requiredPermissions
.flatMapLatest {
if (it.isEmpty()) {
podMonitor.pods
} else {
channelFlow {
send(emptyList())
awaitClose()
}
}
}
val listItems: LiveData<List<OverviewAdapter.Item>> = combine(
enabledState,
requiredPermissions,
pods
) { state, permissions, pods ->
val items = mutableListOf<OverviewAdapter.Item>()
permissions
.map {
PermissionCardVH.Item(
permission = it,
onRequest = { requestPermissionevent.postValue(it) }
)
}
.forEach { items.add(it) }
pods
.sortedBy { it.rssi }
.map {
when (it) {
is DualApplePods -> DualApplePodsCardVH.Item(it)
else -> UnknownPodDeviceCardVH.Item(it)
}
}
.run { items.addAll(this) }
items
}.asLiveData2()
fun onPermissionResult(granted: Boolean) {
if (granted) permissionCheckTrigger.value = UUID.randomUUID()
}
fun toggleDebugLog() = launch {
recorderModule.startRecorder()
}
fun goToSettings() = launch {
}
}
@@ -0,0 +1,90 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.databinding.OverviewPodsAppleDualItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.DualApplePods.DeviceColor
import eu.darken.capod.pods.core.airpods.DualApplePods.LidState
import eu.darken.capod.pods.core.getBatteryCase
import eu.darken.capod.pods.core.getBatteryLeftPod
import eu.darken.capod.pods.core.getBatteryRightPod
class DualApplePodsCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<DualApplePodsCardVH.Item, OverviewPodsAppleDualItemBinding>(
R.layout.overview_pods_apple_dual_item,
parent
) {
override val viewBinding = lazy {
OverviewPodsAppleDualItemBinding.bind(itemView)
}
override val onBindData: OverviewPodsAppleDualItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = { item, _ ->
val device = item.device
name.apply {
val sb = StringBuilder(device.getLabel(context))
if (device.deviceColor != DeviceColor.UNKNOWN) {
sb.append(" (${device.getDeviceColorLabel(context)})")
}
text = sb
}
reception.text = "${device.rssi} (RSSI)"
podLeft.apply {
val sb = StringBuilder(context.getString(R.string.pods_dual_left_label))
sb.append("\n").append(device.getBatteryLeftPod(context))
when {
device.isLeftPodCharging -> sb.append("\n").append("Charging")
device.isLeftPodInEar -> sb.append("\n").append("In ear")
else -> {}
}
text = sb
}
podRight.apply {
val sb = StringBuilder("Right pod")
sb.append("\n").append(device.getBatteryRightPod(context))
when {
device.isRightPodCharging -> sb.append("\n").append("Charging")
device.isRightPodInEar -> sb.append("\n").append("In ear")
else -> {}
}
text = sb
}
when (device.microPhonePod) {
DualPods.Pod.LEFT -> podLeft.append("\n(Microphone)")
DualPods.Pod.RIGHT -> podRight.append("\n(Microphone)")
}
podCase.apply {
val sb = StringBuilder("Case")
sb.append("\n").append(device.getBatteryCase(context))
if (device.isCaseCharging) sb.append("\n").append("Charging")
when (device.caseLidState) {
LidState.OPEN -> sb.append("\n").append(context.getString(R.string.pods_case_status_open_label))
LidState.CLOSED -> sb.append("\n").append(context.getString(R.string.pods_case_status_closed_label))
LidState.NOT_IN_CASE,
LidState.UNKNOWN -> {
}
}
text = sb
}
status.text = getString(R.string.pods_status_x_label, device.getConnectionStateLabel(context))
}
data class Item(
val device: DualApplePods
) : OverviewAdapter.Item {
override val stableId: Long = this.javaClass.hashCode().toLong()
}
}
@@ -1,13 +1,13 @@
package eu.darken.capod.main.ui.cards
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.databinding.MainPermissionItemBinding
import eu.darken.capod.main.ui.MainAdapter
import eu.darken.capod.main.ui.overview.OverviewAdapter
class PermissionCardVH(parent: ViewGroup) :
MainAdapter.BaseVH<PermissionCardVH.Item, MainPermissionItemBinding>(
OverviewAdapter.BaseVH<PermissionCardVH.Item, MainPermissionItemBinding>(
R.layout.main_permission_item,
parent
) {
@@ -28,7 +28,7 @@ class PermissionCardVH(parent: ViewGroup) :
data class Item(
val permission: Permission,
val onRequest: (Permission) -> Unit
) : MainAdapter.Item {
) : OverviewAdapter.Item {
override val stableId: Long = this.javaClass.hashCode().toLong()
}
}
@@ -0,0 +1,32 @@
package eu.darken.capod.main.ui.overview.cards
import android.view.ViewGroup
import eu.darken.capod.R
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.main.ui.overview.OverviewAdapter
import eu.darken.capod.pods.core.PodDevice
class UnknownPodDeviceCardVH(parent: ViewGroup) :
OverviewAdapter.BaseVH<UnknownPodDeviceCardVH.Item, OverviewPodsUnknownItemBinding>(
R.layout.overview_pods_unknown_item,
parent
) {
override val viewBinding = lazy {
OverviewPodsUnknownItemBinding.bind(itemView)
}
override val onBindData: OverviewPodsUnknownItemBinding.(
item: Item,
payloads: List<Any>
) -> Unit = { item, _ ->
name.text = item.device.getLabel(context)
}
data class Item(
val device: PodDevice
) : OverviewAdapter.Item {
override val stableId: Long = this.javaClass.hashCode().toLong()
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.monitor.core
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MonitorSettings @Inject constructor() {
val mode: Mode = Mode.ALWAYS
enum class Mode(val raw: String) {
MANUAL("monitor.mode.manual"),
AUTOMATIC("monitor.mode.automatic"),
ALWAYS("monitor.mode.always")
}
}
@@ -13,7 +13,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.airpods.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -22,7 +22,7 @@ class MonitorControl @Inject constructor(
suspend fun startMonitor(
bluetoothDevice: BluetoothDevice? = null,
forceStart: Boolean
forceStart: Boolean = false,
): Unit = withContext(dispatcherProvider.IO) {
val workerData = Data.Builder().apply {
@@ -8,17 +8,21 @@ import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.EntryPoints
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.permissions.Permission
import eu.darken.capod.common.permissions.isGrantedOrNotRequired
import eu.darken.capod.monitor.core.MonitorComponent
import eu.darken.capod.monitor.core.MonitorCoroutineScope
import eu.darken.capod.monitor.core.MonitorSettings
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.pods.core.airpods.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@@ -30,6 +34,7 @@ class MonitorWorker @AssistedInject constructor(
monitorComponentBuilder: MonitorComponent.Builder,
private val monitorNotifications: MonitorNotifications,
private val notificationManager: NotificationManager,
private val monitorSettings: MonitorSettings,
) : CoroutineWorker(context, params) {
private val workerScope = MonitorCoroutineScope()
@@ -53,66 +58,80 @@ class MonitorWorker @AssistedInject constructor(
log(TAG, VERBOSE) { "init(): workerId=$id" }
}
override suspend fun doWork(): Result = try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
override suspend fun doWork(): Result {
try {
val start = System.currentTimeMillis()
log(TAG, VERBOSE) { "Executing $inputData now (runAttemptCount=$runAttemptCount)" }
bluetoothManager2
.isBluetoothEnabled
.flatMapLatest { bluetoothManager2.connectedDevices() }
.map { devices ->
devices.any { device ->
ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature ->
device.hasFeature(feature)
val missingPermissions = Permission.values().filter { !it.isGrantedOrNotRequired(context) }
if (missingPermissions.isNotEmpty()) {
log(TAG, WARN) { "Aborting, missing permissions: $missingPermissions" }
return Result.success()
}
bluetoothManager2
.isBluetoothEnabled
.flatMapLatest { bluetoothManager2.connectedDevices() }
.map { devices ->
devices.any { device ->
ContinuityProtocol.BLE_FEATURE_UUIDS.any { feature ->
device.hasFeature(feature)
}
}
}
}
.setupCommonEventHandlers(TAG) { "ConnectedDevices" }
.flatMapLatest { arePodsConnected ->
flow<Unit> {
if (arePodsConnected) {
log(TAG) { "Pods are connected, aborting any timeout." }
} else {
log(TAG) { "No Pods are connected, canceling worker soon." }
delay(60 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
// FIXME
// workerScope.coroutineContext.cancelChildren()
.setupCommonEventHandlers(TAG) { "ConnectedDevices" }
.flatMapLatest { arePodsConnected ->
log(TAG) { "Monitor mode: ${monitorSettings.mode}" }
when (monitorSettings.mode) {
MonitorSettings.Mode.MANUAL -> emptyFlow()
MonitorSettings.Mode.ALWAYS -> emptyFlow()
MonitorSettings.Mode.AUTOMATIC -> flow<Unit> {
if (arePodsConnected) {
log(TAG) { "Pods are connected, aborting any timeout." }
} else {
log(TAG) { "No Pods are connected, canceling worker soon." }
delay(60 * 1000)
log(TAG) { "Canceling worker now, still no Pods connected." }
workerScope.coroutineContext.cancelChildren()
}
}
}
}
}
.launchIn(workerScope)
.launchIn(workerScope)
val monitorJob = podMonitor.pods
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart {
setForeground(monitorNotifications.getForegroundInfo(null))
}
.onEach {
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
monitorNotifications.getNotification(it.firstOrNull())
)
}
.launchIn(workerScope)
val monitorJob = podMonitor.pods
.setupCommonEventHandlers(TAG) { "PodMonitor" }
.onStart {
setForeground(monitorNotifications.getForegroundInfo(null))
}
.onEach {
notificationManager.notify(
MonitorNotifications.NOTIFICATION_ID,
monitorNotifications.getNotification(it.firstOrNull())
)
}
.launchIn(workerScope)
log(TAG, VERBOSE) { "monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "monitor job quit" }
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
val duration = System.currentTimeMillis() - start
val duration = System.currentTimeMillis() - start
log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" }
log(TAG, VERBOSE) { "Execution finished after ${duration}ms, $inputData" }
Result.success(inputData)
} catch (e: Throwable) {
log(TAG, ERROR) { "Execution failed:\n${e.asLog()}" }
finishedWithError = true
// TODO update result?
Result.failure(inputData)
} finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
return Result.success(inputData)
} catch (e: Throwable) {
log(TAG, ERROR) { "Execution failed:\n${e.asLog()}" }
Bugs.report(e)
finishedWithError = true
// TODO update result?
return Result.failure(inputData)
} finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
}
}
companion object {
@@ -0,0 +1,27 @@
package eu.darken.capod.pods.core
interface DualPods : PodDevice {
val microPhonePod: Pod
enum class Pod {
LEFT,
RIGHT
}
val batteryLeftPodPercent: Float?
val batteryRightPodPercent: Float?
val batteryCasePercent: Float?
val isLeftPodInEar: Boolean
val isRightPodInEar: Boolean
val isCaseCharging: Boolean
val isLeftPodCharging: Boolean
val isRightPodCharging: Boolean
}
@@ -0,0 +1,17 @@
package eu.darken.capod.pods.core
import android.content.Context
import eu.darken.capod.R
import kotlin.math.roundToInt
fun DualPods.getBatteryLeftPod(context: Context): String =
batteryLeftPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun DualPods.getBatteryRightPod(context: Context): String =
batteryRightPodPercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun DualPods.getBatteryCase(context: Context): String =
batteryCasePercent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
@@ -1,10 +1,14 @@
package eu.darken.capod.pods.core
import android.bluetooth.le.ScanResult
import android.content.Context
interface PodDevice {
val scanResult: ScanResult
interface Status
val rssi: Int
get() = scanResult.rssi
fun getLabel(context: Context): String
}
@@ -2,7 +2,7 @@ package eu.darken.capod.pods.core.airpods
import android.bluetooth.le.ScanResult
import dagger.Reusable
import eu.darken.capod.common.debug.bugreporting.Bugs
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
@@ -13,6 +13,8 @@ import eu.darken.capod.pods.core.airpods.models.AirPodsGen1
import eu.darken.capod.pods.core.airpods.models.AirPodsGen2
import eu.darken.capod.pods.core.airpods.models.AirPodsPro
import eu.darken.capod.pods.core.airpods.models.UnknownAppleDevice
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import javax.inject.Inject
@Reusable
@@ -2,6 +2,7 @@ package eu.darken.capod.pods.core.airpods
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
interface ApplePods : PodDevice {
@@ -9,6 +10,6 @@ interface ApplePods : PodDevice {
companion object {
val TAG = logTag("Pod", "BaseAirPods")
val TAG = logTag("Pod", "AppleDevice")
}
}
@@ -0,0 +1,197 @@
package eu.darken.capod.pods.core.airpods
import android.content.Context
import androidx.annotation.StringRes
import eu.darken.capod.R
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.DualPods
import eu.darken.capod.pods.core.DualPods.Pod
interface DualApplePods : ApplePods, DualPods {
val tag: String
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
val rawDeviceModel: UShort
get() = (((proximityMessage.data[1].toInt() and 255) shl 8) or (proximityMessage.data[2].toInt() and 255)).toUShort()
val rawStatus: UByte
get() = proximityMessage.data[3]
val rawPodsBattery: UByte
get() = proximityMessage.data[4]
val rawCaseBattery: UByte
get() = proximityMessage.data[5]
val rawCaseLidState: UByte
get() = proximityMessage.data[6]
val rawDeviceColor: UByte
get() = proximityMessage.data[7]
val rawSuffix: UByte
get() = proximityMessage.data[8]
override val microPhonePod: Pod
get() = when (rawStatus.isBitSet(5)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
override val batteryLeftPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt()
Pod.RIGHT -> rawPodsBattery.upperNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log(tag) { "Left pod: Above 100% battery: $value" }
1.0f
} else {
(value / 10f)
}
}
}
override val batteryRightPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.upperNibble.toInt()
Pod.RIGHT -> rawPodsBattery.lowerNibble.toInt()
}
return when (value) {
15 -> null
else -> if (value > 10) {
log(tag) { "Right pod: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
log(tag) { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
override val isLeftPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3)
}
override val isRightPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(3)
Pod.RIGHT -> rawStatus.isBitSet(1)
}
val status: Status
get() = Status.values().firstOrNull { it.raw == rawStatus } ?: Status.UNKNOWN
enum class Status(val raw: UByte?) {
BOTH_AIRPODS_IN_CASE(0x55),
LEFT_IN_EAR(0x33),
AIRPLANE(0x02),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
// 1010101 0x55 85 Both In Case
// 0101011 0x2b 43 Both In Ear
// 0101001 0x29 41 Right in Ear
// 0100011 0x23 35 Left In Ear
// 0100001 0x21 33 Neither in Ear or In Case
// 1110001 0x71 113 Left in Case, Right on Desk
// 0010001 0x11 17 Left in Case, Right on Desk
// 0010011 0x13 19 Left in Case, Right in Ear
// 1110011 0x73 115 Left in Case, Right in Ear
override val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
override val isLeftPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
}
override val isRightPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
}
val caseLidState: LidState
get() = LidState.values().firstOrNull { it.raw == rawCaseLidState } ?: LidState.UNKNOWN
enum class LidState(val raw: UByte?) {
OPEN(0x31),
CLOSED(0x38),
NOT_IN_CASE(0x01),
UNKNOWN(null);
constructor(raw: Int) : this(raw.toUByte())
}
val deviceColor: DeviceColor
get() = DeviceColor.values().firstOrNull { it.raw == rawDeviceColor } ?: DeviceColor.UNKNOWN
fun getDeviceColorLabel(context: Context): String = context.getString(deviceColor.labelRes)
enum class DeviceColor(val raw: UByte?, @StringRes val labelRes: Int) {
WHITE(0x00, R.string.pods_device_color_white_label),
BLACK(0x01, R.string.pods_device_color_black_label),
RED(0x02, R.string.pods_device_color_red_label),
BLUE(0x03, R.string.pods_device_color_blue_label),
PINK(0x04, R.string.pods_device_color_pink_label),
GRAY(0x05, R.string.pods_device_color_gray_label),
SILVER(0x06, R.string.pods_device_color_silver_label),
GOLD(0x07, R.string.pods_device_color_gold_label),
ROSE_GOLD(0x08, R.string.pods_device_color_rose_gold_label),
SPACE_GRAY(0x09, R.string.pods_device_color_space_gray_label),
DARK_BLUE(0x0a, R.string.pods_device_color_dark_blue_label),
LIGHT_BLUE(0x0b, R.string.pods_device_color_light_blue_label),
YELLOW(0x0c, R.string.pods_device_color_yellow_label),
UNKNOWN(null, R.string.general_value_unknown_label);
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
val connectionState: ConnectionState
get() = ConnectionState.values().firstOrNull { rawSuffix == it.raw } ?: ConnectionState.UNKNOWN
fun getConnectionStateLabel(context: Context): String = context.getString(connectionState.labelRes)
enum class ConnectionState(val raw: UByte?, @StringRes val labelRes: Int) {
DISCONNECTED(0x00, R.string.pods_connection_state_disconnected_label),
IDLE(0x04, R.string.pods_connection_state_idle_label),
MUSIC(0x05, R.string.pods_connection_state_music_label),
CALL(0x06, R.string.pods_connection_state_call_label),
RINGING(0x07, R.string.pods_connection_state_ringing_label),
HANGING_UP(0x09, R.string.pods_connection_state_hanging_up_label),
UNKNOWN(null, R.string.general_value_unknown_label);
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -4,8 +4,10 @@ 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.DualPods
import eu.darken.capod.pods.core.DualPods.Pod
interface AirPodsDevice : ApplePods {
interface SingleApplePods : ApplePods, DualPods {
val tag: String
@@ -34,18 +36,13 @@ interface AirPodsDevice : ApplePods {
val rawSuffix: UByte
get() = proximityMessage.data[8]
val microPhonePod: Pod
override val microPhonePod: Pod
get() = when (rawStatus.isBitSet(5)) {
true -> Pod.LEFT
false -> Pod.RIGHT
}
enum class Pod {
LEFT,
RIGHT
}
val batteryLeftPodPercent: Float?
override val batteryLeftPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.lowerNibble.toInt()
@@ -53,7 +50,7 @@ interface AirPodsDevice : ApplePods {
}
return when (value) {
15 -> null
else -> if (value >= 10) {
else -> if (value > 10) {
log(tag) { "Left pod: Above 100% battery: $value" }
1.0f
} else {
@@ -62,7 +59,7 @@ interface AirPodsDevice : ApplePods {
}
}
val batteryRightPodPercent: Float?
override val batteryRightPodPercent: Float?
get() {
val value = when (microPhonePod) {
Pod.LEFT -> rawPodsBattery.upperNibble.toInt()
@@ -79,7 +76,7 @@ interface AirPodsDevice : ApplePods {
}
}
val batteryCasePercent: Float?
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.lowerNibble.toInt()) {
15 -> null
else -> if (value > 10) {
@@ -90,13 +87,13 @@ interface AirPodsDevice : ApplePods {
}
}
val isLeftPodInEar: Boolean
override val isLeftPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(1)
Pod.RIGHT -> rawStatus.isBitSet(3)
}
val isRightPodInEar: Boolean
override val isRightPodInEar: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawStatus.isBitSet(3)
Pod.RIGHT -> rawStatus.isBitSet(1)
@@ -125,16 +122,16 @@ interface AirPodsDevice : ApplePods {
// 1110011 0x73 115 Left in Case, Right in Ear
val isCaseCharging: Boolean
override val isCaseCharging: Boolean
get() = rawCaseBattery.upperNibble.isBitSet(2)
val isLeftPodCharging: Boolean
override val isLeftPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(0)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(1)
}
val isRightPodCharging: Boolean
override val isRightPodCharging: Boolean
get() = when (microPhonePod) {
Pod.LEFT -> rawCaseBattery.upperNibble.isBitSet(1)
Pod.RIGHT -> rawCaseBattery.upperNibble.isBitSet(0)
@@ -1,13 +1,19 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
data class AirPodsGen1 constructor(
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : AirPodsDevice {
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods (Gen 1)"
}
override val tag: String = logTag("Pod", "AirPodsGen1")
}
@@ -1,13 +1,19 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
data class AirPodsGen2 constructor(
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : AirPodsDevice {
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods (Gen 2)"
}
override val tag: String = logTag("Pod", "AirPodsGen2")
}
@@ -1,10 +1,17 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.pods.core.airpods.ApplePods
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
data class AirPodsMax constructor(
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : ApplePods
) : ApplePods {
override fun getLabel(context: Context): String {
return "AirPods Max"
}
}
@@ -1,14 +1,19 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.DualApplePods
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
data class AirPodsPro constructor(
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : AirPodsDevice {
) : DualApplePods {
override fun getLabel(context: Context): String {
return "AirPods Pro"
}
override val tag: String = logTag("Pod", "AirPodsPro")
}
@@ -1,10 +1,17 @@
package eu.darken.capod.pods.core.airpods.models
import android.bluetooth.le.ScanResult
import android.content.Context
import eu.darken.capod.pods.core.airpods.ApplePods
import eu.darken.capod.pods.core.airpods.ProximityPairing
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
data class UnknownAppleDevice constructor(
override val scanResult: ScanResult,
override val proximityMessage: ProximityPairing.Message
) : ApplePods
) : ApplePods {
override fun getLabel(context: Context): String {
return "Unknown device"
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.airpods.protocol
import android.bluetooth.le.ScanResult
import android.os.ParcelUuid
@@ -1,8 +1,9 @@
package eu.darken.capod.pods.core.airpods
package eu.darken.capod.pods.core.airpods.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.airpods.ApplePods
import javax.inject.Inject
@@ -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="M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z" />
</vector>
@@ -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="M6.2,3.01C4.44,2.89 3,4.42 3,6.19L3,16c0,2.76 2.24,5 5,5h0c2.76,0 5,-2.24 5,-5V8c0,-1.66 1.34,-3 3,-3h0c1.66,0 3,1.34 3,3v7l-0.83,0c-1.61,0 -3.06,1.18 -3.17,2.79c-0.12,1.69 1.16,3.1 2.8,3.21c1.76,0.12 3.2,-1.42 3.2,-3.18L21,8c0,-2.76 -2.24,-5 -5,-5h0c-2.76,0 -5,2.24 -5,5v8c0,1.66 -1.34,3 -3,3l0,0c-1.66,0 -3,-1.34 -3,-3V9l0.83,0C7.44,9 8.89,7.82 9,6.21C9.11,4.53 7.83,3.11 6.2,3.01z" />
</vector>
@@ -0,0 +1,16 @@
<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="M9,12a3,5.74 0,1 0,6 0a3,5.74 0,1 0,-6 0z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9.04,16.87C8.71,16.95 8.36,17 8,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5c0.36,0 0.71,0.05 1.04,0.13c0.39,-0.56 0.88,-1.12 1.49,-1.63C9.75,5.19 8.9,5 8,5c-3.86,0 -7,3.14 -7,7s3.14,7 7,7c0.9,0 1.75,-0.19 2.53,-0.5C9.92,17.99 9.43,17.43 9.04,16.87z" />
<path
android:fillColor="@android:color/white"
android:pathData="M16,5c-0.9,0 -1.75,0.19 -2.53,0.5c0.61,0.51 1.1,1.07 1.49,1.63C15.29,7.05 15.64,7 16,7c2.76,0 5,2.24 5,5s-2.24,5 -5,5c-0.36,0 -0.71,-0.05 -1.04,-0.13c-0.39,0.56 -0.88,1.12 -1.49,1.63C14.25,18.81 15.1,19 16,19c3.86,0 7,-3.14 7,-7S19.86,5 16,5z" />
</vector>
@@ -0,0 +1,16 @@
<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="M9,12a3,5.74 0,1 0,6 0a3,5.74 0,1 0,-6 0z" />
<path
android:fillColor="@android:color/white"
android:pathData="M7.5,12c0,-0.97 0.23,-4.16 3.03,-6.5C9.75,5.19 8.9,5 8,5c-3.86,0 -7,3.14 -7,7s3.14,7 7,7c0.9,0 1.75,-0.19 2.53,-0.5C7.73,16.16 7.5,12.97 7.5,12z" />
<path
android:fillColor="@android:color/white"
android:pathData="M16,5c-0.9,0 -1.75,0.19 -2.53,0.5c0.61,0.51 1.1,1.07 1.49,1.63C15.29,7.05 15.64,7 16,7c2.76,0 5,2.24 5,5s-2.24,5 -5,5c-0.36,0 -0.71,-0.05 -1.04,-0.13c-0.39,0.56 -0.88,1.12 -1.49,1.63C14.25,18.81 15.1,19 16,19c3.86,0 7,-3.14 7,-7S19.86,5 16,5z" />
</vector>
@@ -0,0 +1,16 @@
<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="M9,12a3,5.74 0,1 0,6 0a3,5.74 0,1 0,-6 0z" />
<path
android:fillColor="@android:color/white"
android:pathData="M16.5,12c0,0.97 -0.23,4.16 -3.03,6.5C14.25,18.81 15.1,19 16,19c3.86,0 7,-3.14 7,-7s-3.14,-7 -7,-7c-0.9,0 -1.75,0.19 -2.53,0.5C16.27,7.84 16.5,11.03 16.5,12z" />
<path
android:fillColor="@android:color/white"
android:pathData="M8,19c0.9,0 1.75,-0.19 2.53,-0.5c-0.61,-0.51 -1.1,-1.07 -1.49,-1.63C8.71,16.95 8.36,17 8,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5c0.36,0 0.71,0.05 1.04,0.13c0.39,-0.56 0.88,-1.12 1.49,-1.63C9.75,5.19 8.9,5 8,5c-3.86,0 -7,3.14 -7,7S4.14,19 8,19z" />
</vector>
@@ -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="M17,4h3v16h-3zM5,14h3v6L5,20zM11,9h3v11h-3z" />
</vector>
+1 -1
View File
@@ -9,7 +9,7 @@
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="eu.darken.capod.main.ui.MainFragment"
android:name="eu.darken.capod.main.ui.overview.OverviewFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
@@ -0,0 +1,132 @@
<?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"
android:layout_marginVertical="8dp"
tools:context=".main.ui.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/card_content"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/name"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:gravity="center_vertical"
app:drawableLeftCompat="@drawable/ic_baseline_earbuds_24"
app:layout_constraintBottom_toTopOf="@id/barrier_top"
app:layout_constraintEnd_toStartOf="@id/reception"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="Apple AirPods Pro" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/reception"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:gravity="center"
app:drawableRightCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/name"
app:layout_constraintTop_toTopOf="parent"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="pod_left,pod_case,pod_right" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_left"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_baseline_pod_left_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_case"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0"
tools:text="Left pod\n100%\n(Primary)" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_case"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_baseline_pod_case_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toStartOf="@id/pod_right"
app:layout_constraintStart_toEndOf="@id/pod_left"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0"
tools:text="Case\n50%" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pod_right"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
app:drawableTopCompat="@drawable/ic_baseline_pod_right_24"
app:layout_constraintBottom_toTopOf="@id/barrier_bottom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/pod_case"
app:layout_constraintTop_toBottomOf="@id/barrier_top"
app:layout_constraintVertical_bias="0.0"
tools:text="R 90%" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_bottom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="pod_left,pod_case,pod_right" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/status"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:gravity="center_vertical"
app:drawableLeftCompat="@drawable/ic_baseline_apps_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_bottom"
tools:text="Music Active, Case Closed" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,78 @@
<?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/card_content"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/name"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintEnd_toStartOf="@id/reception"
app:layout_constraintHorizontal_bias="0.49"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Unknown Device" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/reception"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/name"
app:layout_constraintTop_toTopOf="parent"
tools:text="RSSI -100 dbM" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/rawdata_label"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@string/pods_unknown_raw_data_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/rawdata"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rawdata_label"
tools:text="07 19 01 0E 20 75 AA B2 31 00 00 2A 46 AF 17 F7 59 C2 71 B8 CA 37 5D 72 5C 15 68" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
+1 -1
View File
@@ -7,7 +7,7 @@
<fragment
android:id="@+id/overviewFragment"
android:name="eu.darken.capod.main.ui.MainFragment"
android:name="eu.darken.capod.main.ui.overview.OverviewFragment"
tools:layout="@layout/main_fragment">
</fragment>
+33
View File
@@ -16,4 +16,37 @@
<string name="debug_debuglog_file_label">Recorded log file</string>
<string name="debug_debuglog_record_action">Record debug log</string>
<string name="general_settings_label">Settings</string>
<string name="permission_bluetooth_label">BLUETOOTH</string>
<string name="permission_bluetooth_description">Allows applications to connect to paired bluetooth devices.</string>
<string name="permission_access_fine_location_description">Allows an app to access precise location.</string>
<string name="permission_access_fine_location_label">ACCESS_FINE_LOCATION</string>
<string name="pods_dual_left_label">Left pod</string>
<string name="pods_dual_right_label">Right pod</string>
<string name="pods_case_status_label">Case</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Closed</string>
<string name="general_value_not_available_label">N/A</string>
<string name="pods_unknown_raw_data_label">Raw data</string>
<string name="general_value_unknown_label">Unknown</string>
<string name="pods_reception_x_label">Reception %s</string>
<string name="pods_device_color_white_label">White</string>
<string name="pods_device_color_yellow_label">Yellow</string>
<string name="pods_device_color_light_blue_label">Light blue</string>
<string name="pods_device_color_dark_blue_label">Dark blue</string>
<string name="pods_device_color_space_gray_label">Space gray</string>
<string name="pods_device_color_rose_gold_label">Rose gold</string>
<string name="pods_device_color_gold_label">Gold</string>
<string name="pods_device_color_silver_label">Silver</string>
<string name="pods_device_color_gray_label">Gray</string>
<string name="pods_device_color_pink_label">Pink</string>
<string name="pods_device_color_blue_label">Blue</string>
<string name="pods_device_color_red_label">Red</string>
<string name="pods_device_color_black_label">Black</string>
<string name="pods_connection_state_disconnected_label">Disconnected</string>
<string name="pods_connection_state_idle_label">Idle</string>
<string name="pods_connection_state_music_label">Music</string>
<string name="pods_connection_state_call_label">Call</string>
<string name="pods_connection_state_ringing_label">Ringing</string>
<string name="pods_connection_state_hanging_up_label">Hanging up</string>
<string name="pods_status_x_label">Status %s</string>
</resources>
@@ -3,10 +3,13 @@ package eu.darken.capod.pods.core.airpods
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.models.AirPodsGen1
import eu.darken.capod.pods.core.airpods.models.AirPodsPro
import eu.darken.capod.pods.core.airpods.models.UnknownAppleDevice
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.instanceOf
import io.mockk.MockKAnnotations
@@ -65,39 +68,39 @@ class AirPodsFactoryTest : BaseTest() {
@Test
fun `test AirPodDevice - active microphone`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >2B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101011
// --^-----
microPhonePod shouldBe AirPodsDevice.Pod.LEFT
microPhonePod shouldBe DualPods.Pod.LEFT
}
create<AirPodsDevice>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >0B< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001011
// --^-----
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
microPhonePod shouldBe DualPods.Pod.RIGHT
}
}
@Test
fun `test AirPodDevice - left pod ear status`() = runBlockingTest {
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >22< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100010
// 765432¹0
isLeftPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100000
// 765432¹0
isLeftPodInEar shouldBe false
}
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >09< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00001001
// 7654³210
isLeftPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >20< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 7654³210
isLeftPodInEar shouldBe false
@@ -107,24 +110,24 @@ class AirPodsFactoryTest : BaseTest() {
@Test
fun `test AirPodDevice - right pod ear status`() = runBlockingTest {
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >29< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00101001
// 7654³210
isRightPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >21< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00100001
// 7654³210
isRightPodInEar shouldBe false
}
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >03< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000011
// 765432¹0
isRightPodInEar shouldBe true
}
create<AirPodsDevice>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
create<DualApplePods>("07 19 01 0E 20 >01< AA B5 31 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4") {
// 00000001
// 765432¹0
isRightPodInEar shouldBe false
@@ -134,13 +137,13 @@ class AirPodsFactoryTest : BaseTest() {
@Test
fun `test AirPodDevice - battery status`() = runBlockingTest {
// Right Pod is primary
create<AirPodsDevice>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
create<DualApplePods>("07 19 01 0E 20 0B >98< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// 88 10001000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
}
// Left Pod primary
create<AirPodsDevice>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
create<DualApplePods>("07 19 01 0E 20 2B >89< 94 52 00 05 09 73 3C 3D F9 2C 3E B3 DD 76 02 DD 4E 16 FD FB") {
// F8 11111000
batteryLeftPodPercent shouldBe 0.9f
batteryRightPodPercent shouldBe 0.8f
@@ -153,13 +156,13 @@ class AirPodsFactoryTest : BaseTest() {
* Right pod is charging
*/
// This is the left
create<AirPodsDevice>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
create<DualApplePods>("07 19 01 0E 20 51 89 >94< 52 00 00 F4 89 82 6D 3E 27 7F 26 62 57 D0 E2 A6 49 E9 35") {
// 1001 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
}
// This is the right
create<AirPodsDevice>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
create<DualApplePods>("07 19 01 0E 20 31 98 >A4< 01 00 00 31 B9 A0 C4 80 CD D1 CF B9 3A 9A 6D 48 31 08 EB") {
// 1010 0100
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe true
@@ -169,26 +172,26 @@ class AirPodsFactoryTest : BaseTest() {
* Left pod is charging
*/
// This is the left
create<AirPodsDevice>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
create<DualApplePods>("07 19 01 0E 20 71 98 >94< 52 00 05 A5 37 31 B2 BD 42 68 0C 64 FD 00 99 4A E5 3E F4") {
// 1001 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// This is the right
create<AirPodsDevice>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
create<DualApplePods>("07 19 01 0E 20 11 89 >A4< 04 00 04 BA 79 1B C0 65 69 C6 9F 19 6E 37 7D 6D 86 8D D9") {
// 1010 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe false
}
// Both charging
create<AirPodsDevice>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
create<DualApplePods>("07 19 01 0E 20 55 88 >B4< 59 00 05 4B FC DF 68 28 A5 45 52 65 9C FE 51 86 3A B5 DB") {
// 1011 0100
isLeftPodCharging shouldBe true
isRightPodCharging shouldBe true
}
// Both not charging
create<AirPodsDevice>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
create<DualApplePods>("07 19 01 0E 20 00 F8 >8F< 03 00 05 4C 0F A0 C4 05 24 DD EB AF 92 99 FD 54 B1 06 48") {
// 1000 1111
isLeftPodCharging shouldBe false
isRightPodCharging shouldBe false
@@ -197,11 +200,11 @@ class AirPodsFactoryTest : BaseTest() {
@Test
fun `test AirPodDevice - case charging`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
create<DualApplePods>("07 19 01 0E 20 75 99 >B4< 31 00 05 77 C8 BA 0C 4E 1F BE AD 70 C5 40 71 D2 E9 17 A2") {
// 0011 0011
isCaseCharging shouldBe false
}
create<AirPodsDevice>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
create<DualApplePods>("07 19 01 0E 20 75 A9 >F4< 51 00 05 A0 37 92 35 49 79 CC DC 27 94 8E FB 72 12 94 52") {
// 0101 0011
isCaseCharging shouldBe true
}
@@ -210,66 +213,66 @@ class AirPodsFactoryTest : BaseTest() {
@Test
fun `test AirPodDevice - case lid test`() = runBlockingTest {
// Lid open
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 >31< 00 00 A1 D0 BD 82 D3 52 86 CA FC 11 62 DC 42 C6 92 8E") {
// 31 0011 0001
caseLidState shouldBe AirPodsDevice.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
}
// Lid just closed
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 >39< 00 00 08 A6 DB 99 E0 5E 14 85 E5 C2 0B 68 D7 FF C3 A1") {
// 39 0011 1001
caseLidState shouldBe AirPodsDevice.LidState.UNKNOWN
caseLidState shouldBe DualApplePods.LidState.UNKNOWN
}
// Lid closed
create<AirPodsDevice>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
create<DualApplePods>("07 19 01 0E 20 55 AA B4 38 00 00 F3 F7 08 3B 98 09 C0 DD E4 BD BD 84 55 56 8B 81") {
// 38 0011 1000
caseLidState shouldBe AirPodsDevice.LidState.CLOSED
caseLidState shouldBe DualApplePods.LidState.CLOSED
}
}
@Test
fun `test AirPodDevice - connection state`() = runBlockingTest {
// Disconnected
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
create<DualApplePods>("07 19 01 0E 20 2B AA 8F 01 00 >00< 62 D4 BB F1 A7 F8 64 98 D2 C8 BD 7B 3A EF 2E 15") {
// 31 0011 0001
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
connectionState shouldBe DualApplePods.ConnectionState.DISCONNECTED
}
// Connected idle
create<AirPodsDevice>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
create<DualApplePods>("07 19 01 0E 20 2B AA 8F 01 00 >04< 1D 69 69 9C C2 51 F3 1F BF 6E 45 DA 90 4A A3 E3") {
// 39 0011 1001
connectionState shouldBe AirPodsDevice.ConnectionState.IDLE
connectionState shouldBe DualApplePods.ConnectionState.IDLE
}
// Connected and playing music
create<AirPodsDevice>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
create<DualApplePods>("07 19 01 0E 20 2B A9 8F 01 00 >05< 14 F7 CB 49 9F D3 B3 22 77 D2 22 F1 74 8C AC A6") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
connectionState shouldBe DualApplePods.ConnectionState.MUSIC
}
// Connected and call active
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >06< 0F 4B 43 25 E0 4A 73 63 14 22 C2 3C 89 13 BD 97") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.CALL
connectionState shouldBe DualApplePods.ConnectionState.CALL
}
// Connected and call active
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >07< E7 DF 76 44 85 B5 30 F4 95 14 02 DC A1 A4 8A 09") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.RINGING
connectionState shouldBe DualApplePods.ConnectionState.RINGING
}
// Switching?
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
// 38 0011 1000
connectionState shouldBe AirPodsDevice.ConnectionState.HANGING_UP
connectionState shouldBe DualApplePods.ConnectionState.HANGING_UP
}
}
@Test
fun `create AirPodsGen1`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
create<DualApplePods>("07 19 01 02 20 55 AA 56 31 00 00 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88") {
this shouldBe instanceOf<AirPodsGen1>()
}
}
@Test
fun `create AirPodsPro`() = runBlockingTest {
create<AirPodsDevice>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
create<DualApplePods>("07 19 01 0E 20 2B 99 8F 01 00 >09< 10 30 EE F3 41 B5 D8 9F A3 B0 B4 17 9F 85 97 5F") {
this shouldBe instanceOf<AirPodsPro>()
}
}
@@ -4,6 +4,8 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanRecord
import android.bluetooth.le.ScanResult
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.airpods.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.airpods.protocol.ProximityPairing
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
@@ -1,7 +1,7 @@
package eu.darken.capod.pods.core.airpods.models
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -32,11 +32,11 @@ class AirPodsGen1Test : BaseAirPodsTest() {
isRightPodInEar shouldBe false
batteryCasePercent shouldBe 0.6f
caseLidState shouldBe AirPodsDevice.LidState.OPEN
caseLidState shouldBe DualApplePods.LidState.OPEN
connectionState shouldBe AirPodsDevice.ConnectionState.DISCONNECTED
connectionState shouldBe DualApplePods.ConnectionState.DISCONNECTED
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
}
@@ -1,7 +1,7 @@
package eu.darken.capod.pods.core.airpods.models
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -31,11 +31,11 @@ class AirPodsGen2Test : BaseAirPodsTest() {
isRightPodInEar shouldBe true
batteryCasePercent shouldBe null
caseLidState shouldBe AirPodsDevice.LidState.NOT_IN_CASE
caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
connectionState shouldBe AirPodsDevice.ConnectionState.MUSIC
connectionState shouldBe DualApplePods.ConnectionState.MUSIC
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
}
@@ -1,7 +1,8 @@
package eu.darken.capod.pods.core.airpods.models
import eu.darken.capod.pods.core.airpods.AirPodsDevice
import eu.darken.capod.pods.core.DualPods
import eu.darken.capod.pods.core.airpods.BaseAirPodsTest
import eu.darken.capod.pods.core.airpods.DualApplePods
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
@@ -21,7 +22,7 @@ class AirPodsProTest : BaseAirPodsTest() {
rawDeviceColor shouldBe 0x00.toUByte()
rawSuffix shouldBe 0x00.toUByte()
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
microPhonePod shouldBe DualPods.Pod.RIGHT
batteryLeftPodPercent shouldBe 1.0f
batteryRightPodPercent shouldBe 1.0f
@@ -31,14 +32,14 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe true
batteryCasePercent shouldBe 0.5f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
@Test
fun `test AirPods from my downstairs neighbour`() = runBlockingTest {
create<AirPodsPro>("07 19 01 0E 20 00 F3 8F 02 00 04 79 C6 3F F9 C3 15 D9 11 A1 3C B1 58 66 B9 8B 67") {
microPhonePod shouldBe AirPodsDevice.Pod.RIGHT
microPhonePod shouldBe DualPods.Pod.RIGHT
batteryLeftPodPercent shouldBe null
batteryRightPodPercent shouldBe 0.3f
@@ -48,7 +49,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
@@ -64,7 +65,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b668f01000500000000000000000000000000000000") {
@@ -76,7 +77,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b668f01000400000000000000000000000000000000") {
@@ -88,7 +89,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e200b668f01000500000000000000000000000000000000") {
@@ -100,7 +101,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2003668f01000500000000000000000000000000000000") {
@@ -112,7 +113,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2001668f01000500000000000000000000000000000000") {
@@ -124,7 +125,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2009668f01000500000000000000000000000000000000") {
@@ -136,7 +137,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e2053669653000500000000000000000000000000000000") {
@@ -148,7 +149,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e203366a602000500000000000000000000000000000000") {
@@ -160,7 +161,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
create<AirPodsPro>("0719010e202b768f02000500000000000000000000000000000000") {
@@ -172,7 +173,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe null
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
@@ -188,7 +189,7 @@ class AirPodsProTest : BaseAirPodsTest() {
isLeftPodCharging shouldBe false
batteryCasePercent shouldBe 0.6f
deviceColor shouldBe AirPodsDevice.DeviceColor.WHITE
deviceColor shouldBe DualApplePods.DeviceColor.WHITE
}
}
}