diff --git a/app-common/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt index e07ba886..46264276 100644 --- a/app-common/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt +++ b/app-common/src/main/java/eu/darken/capod/common/bluetooth/BleScanner.kt @@ -32,9 +32,9 @@ class BleScanner @Inject constructor( @SuppressLint("MissingPermission") fun scan( filters: Set, scannerMode: ScannerMode = ScannerMode.BALANCED, - disableOffloadFiltering: Boolean = true, - disableOffloadBatching: Boolean = true, - disableDirectScanCallback: Boolean = true, + disableOffloadFiltering: Boolean = false, + disableOffloadBatching: Boolean = false, + disableDirectScanCallback: Boolean = false, ): Flow> = callbackFlow { log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" } diff --git a/app-common/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt b/app-common/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt index e8be00dd..ad80aa74 100644 --- a/app-common/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt +++ b/app-common/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt @@ -14,6 +14,7 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.flow.setupCommonEventHandlers +import eu.darken.capod.common.flow.throttleLatest import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.PermissionTool import eu.darken.capod.pods.core.PodDevice @@ -113,6 +114,7 @@ class PodMonitor @Inject constructor( disableDirectCallback = useIndirectScanResultCallback, ) } + .throttleLatest(1000) .flatMapLatest { options -> val filters = when { options.showUnfiltered -> { diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt index 10c65aaa..15f05e6d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewFragmentVM.kt @@ -123,7 +123,9 @@ class OverviewFragmentVM @Inject constructor( if (!isBluetoothEnabled) { items.add(0, BluetoothDisabledVH.Item) } else if (mainPod == null) { - items.add(0, MissingMainDeviceVH.Item) + items.add(0, MissingMainDeviceVH.Item { + OverviewFragmentDirections.actionOverviewFragmentToTroubleShooterFragment().navVia(this) + }) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt index 437b4a55..97741bfc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/MissingMainDeviceVH.kt @@ -21,10 +21,12 @@ class MissingMainDeviceVH(parent: ViewGroup) : item: Item, payloads: List ) -> Unit = binding(payload = true) { item -> - + troubleshootAction.setOnClickListener { item.onTroubleShoot() } } - object Item : OverviewAdapter.Item { + data class Item( + val onTroubleShoot: () -> Unit, + ) : OverviewAdapter.Item { override val stableId: Long = Item::class.hashCode().toLong() override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?) diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt new file mode 100644 index 00000000..7249b1e1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragment.kt @@ -0,0 +1,80 @@ +package eu.darken.capod.troubleshooter.ui + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.ui.setupWithNavController +import dagger.hilt.android.AndroidEntryPoint +import eu.darken.capod.R +import eu.darken.capod.common.WebpageTool +import eu.darken.capod.common.navigation.popBackStack +import eu.darken.capod.common.uix.Fragment3 +import eu.darken.capod.common.viewbinding.viewBinding +import eu.darken.capod.databinding.TroubleshooterFragmentBinding +import eu.darken.capod.troubleshooter.ui.TroubleShooterFragmentVM.BleState +import javax.inject.Inject + + +@AndroidEntryPoint +class TroubleShooterFragment : Fragment3(R.layout.troubleshooter_fragment) { + + override val vm: TroubleShooterFragmentVM by viewModels() + override val ui: TroubleshooterFragmentBinding by viewBinding() + + @Inject lateinit var webpageTool: WebpageTool + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + ui.toolbar.apply { + setupWithNavController(findNavController()) + } + + ui.bleIntroStartAction.setOnClickListener { vm.troubleShootBle() } + + vm.bleState.observe2(ui) { state -> + bleIntroContainer.isVisible = state is BleState.Intro + bleProcessContainer.isVisible = state is BleState.Working + bleResultContainer.isVisible = state is BleState.Result + + when (state) { + is BleState.Intro -> {} + is BleState.Working -> { + bleProcessHistory.apply { + text = "" + state.allSteps.forEachIndexed { index, s -> append("#$index: $s\n") } + } + } + is BleState.Result -> { + bleResultHistory.apply { + text = "" + state.history.forEachIndexed { index, s -> append("#$index: $s\n") } + } + + + when (state) { + is BleState.Result.Success -> { + bleResultAction.isVisible = false + bleResultTitle.text = getString(R.string.troubleshooter_ble_result_success_title) + bleResultBody.text = getString(R.string.troubleshooter_ble_result_success_body) + bleResultAction.text = getString(R.string.general_close_action) + bleResultAction.setOnClickListener { popBackStack() } + } + is BleState.Result.Failure -> { + bleResultTitle.text = getString(R.string.troubleshooter_ble_result_failure_title) + bleResultBody.text = when (state.failureType) { + BleState.Result.Failure.Type.PHONE -> getString(R.string.troubleshooter_ble_result_failure_phone_body) + BleState.Result.Failure.Type.HEADPHONES -> getString(R.string.troubleshooter_ble_result_failure_phone_headphones) + } + bleResultAction.text = getString(R.string.general_check_action) + bleResultAction.setOnClickListener { vm.troubleShootBle() } + } + } + } + } + } + + super.onViewCreated(view, savedInstanceState) + } + +} diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt new file mode 100644 index 00000000..021673a4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterFragmentVM.kt @@ -0,0 +1,275 @@ +package eu.darken.capod.troubleshooter.ui + +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.darken.capod.common.bluetooth.ScannerMode +import eu.darken.capod.common.coroutine.DispatcherProvider +import eu.darken.capod.common.debug.DebugSettings +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import eu.darken.capod.common.uix.ViewModel3 +import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.monitor.core.PodMonitor +import eu.darken.capod.pods.core.PodDevice +import eu.darken.capod.pods.core.unknown.UnknownDevice +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +@HiltViewModel +class TroubleShooterFragmentVM @Inject constructor( + @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle, + private val dispatcherProvider: DispatcherProvider, + private val generalSettings: GeneralSettings, + private val podMonitor: PodMonitor, + private val debugSettings: DebugSettings, +) : ViewModel3(dispatcherProvider = dispatcherProvider) { + + private val _bleState = MutableStateFlow(BleState.Intro()) + val bleState = _bleState.asLiveData2() + + init { + _bleState + .onEach { log(TAG) { "New BLE State: $it" } } + .launchIn(vmScope) + } + + private fun progress(message: String) { + var state = _bleState.value + if (state !is BleState.Working) { + state = BleState.Working("Starting...") + } + + _bleState.value = state.nextStep(message) + } + + private fun success(message: String) { + var state = _bleState.value + if (state !is BleState.Working) { + state = BleState.Working("Starting...") + } + _bleState.value = state.toSuccess(message) + } + + private fun failure(message: String, type: BleState.Result.Failure.Type) { + var state = _bleState.value + if (state !is BleState.Working) { + state = BleState.Working("Starting...") + } + _bleState.value = state.toFailure(message, type) + } + + fun troubleShootBle() = launch(context = dispatcherProvider.IO) { + log(TAG) { "troubleShootBle()" } + + generalSettings.scannerMode.value = ScannerMode.LOW_LATENCY + + run { + progress("Checking for headphones...") + val mainDevice = withTimeoutOrNull(STEP_TIME) { + podMonitor.mainDevice.filterNotNull().firstOrNull() + } + if (mainDevice != null) { + success("Headphones found, nothing to troubleshoot.") + return@launch + } else { + progress("Headphones not detected.\n") + } + } + + val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection = { hardwareFilteringDisabled, + hardwareBatchingDisabled, + indirectCallback, + unfiltered -> + val sb = StringBuilder("SCAN - Settings: ") + sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ") + sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ") + sb.append("indirectCallback=$indirectCallback, ") + sb.append("unfiltered=$unfiltered") + progress(sb.toString()) + generalSettings.isOffloadedFilteringDisabled.value = hardwareFilteringDisabled + generalSettings.isOffloadedBatchingDisabled.value = hardwareBatchingDisabled + generalSettings.useIndirectScanResultCallback.value = indirectCallback + debugSettings.showUnfiltered.value = unfiltered + + val start = System.currentTimeMillis() + val devices = withTimeoutOrNull(STEP_TIME) { + podMonitor.devices + .take(10) + .takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 } + .toList() + .flatten() + .distinctBy { it.address } + } ?: emptyList() + log(TAG) { "SCAN: BLE Devices: $devices" } + if (devices.isNotEmpty()) { + progress("SCAN: Received data from ${devices.size} BLE devices") + devices + } else { + progress("SCAN: No data received") + devices + } + } + + + run { + progress("Checking if we can receive BLE data at all.") + if (doScan(false, false, false, true).isNotEmpty()) return@run + if (doScan(false, false, true, true).isNotEmpty()) return@run + if (doScan(true, true, true, true).isNotEmpty()) return@run + if (doScan(true, true, false, true).isNotEmpty()) return@run + if (doScan(true, false, true, true).isNotEmpty()) return@run + if (doScan(true, false, false, true).isNotEmpty()) return@run + if (doScan(false, true, true, true).isNotEmpty()) return@run + if (doScan(false, true, false, true).isNotEmpty()) return@run + + failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE) + + generalSettings.isOffloadedFilteringDisabled.value = false + generalSettings.isOffloadedBatchingDisabled.value = false + generalSettings.useIndirectScanResultCallback.value = false + debugSettings.showUnfiltered.value = false + + return@launch + } + + progress("We received at least some BLE data.\n") + + run { + progress("Checking for supported headphones.") + + if (doScan(false, false, false, false).any { it !is UnknownDevice }) return@run + if (doScan(false, false, true, false).any { it !is UnknownDevice }) return@run + if (doScan(true, true, true, false).any { it !is UnknownDevice }) return@run + if (doScan(true, true, false, false).any { it !is UnknownDevice }) return@run + if (doScan(true, false, true, false).any { it !is UnknownDevice }) return@run + if (doScan(true, false, false, false).any { it !is UnknownDevice }) return@run + if (doScan(false, true, true, false).any { it !is UnknownDevice }) return@run + if (doScan(false, true, false, false).any { it !is UnknownDevice }) return@run + + failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES) + + generalSettings.isOffloadedFilteringDisabled.value = false + generalSettings.isOffloadedBatchingDisabled.value = false + generalSettings.useIndirectScanResultCallback.value = false + + return@launch + } + + progress("Found some headphones that are supported by CAPod.\n") + + run { + progress("Checking for your headphones with new BLE settings...") + val mainDevice = withTimeoutOrNull(STEP_TIME) { + podMonitor.mainDevice.filterNotNull().firstOrNull() + } + if (mainDevice != null) { + success("Found your headphones, new BLE settings worked :)!") + return@launch + } + } + + progress("Still no headphones detected that count as yours.\n") + + run { + progress("Checking all closeby headphones.") + + var otherDevices = withTimeoutOrNull(STEP_TIME) { + podMonitor.devices.take(10).toList().flatten() + } ?: emptyList() + + if (otherDevices.isEmpty()) { + failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES) + return@launch + } + + progress("Headphones found nearby, but not detected as yours. Resetting filters.\n") + + generalSettings.mainDeviceModel.value = PodDevice.Model.UNKNOWN + generalSettings.mainDeviceAddress.value = null + generalSettings.minimumSignalQuality.value = 0.25f + + otherDevices = withTimeoutOrNull(STEP_TIME) { + withTimeoutOrNull(STEP_TIME) { + val start = System.currentTimeMillis() + podMonitor.devices + .take(10) + .takeWhile { System.currentTimeMillis() - start < STEP_TIME - 1000 } + .toList() + .flatten() + .distinctBy { it.address } + } ?: emptyList() + } ?: emptyList() + + progress("Setting headphone with strongest signal as yours.") + + generalSettings.mainDeviceModel.value = otherDevices.maxBy { it.signalQuality }.model + + val mainDevice = withTimeoutOrNull(STEP_TIME) { + podMonitor.mainDevice.filterNotNull().firstOrNull() + } + if (mainDevice != null) { + generalSettings.mainDeviceModel.value = mainDevice.model + generalSettings.scannerMode.value = ScannerMode.BALANCED + success("Success! Detected your headphones.") + return@launch + } + + failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES) + } + } + + sealed class BleState { + class Intro : BleState() + + data class Working( + val current: String, + val history: List = emptyList(), + ) : BleState() { + + val allSteps: List + get() = history + current + + fun nextStep(message: String) = this.copy( + current = message, + history = history + current + ) + + fun toSuccess(message: String) = Result.Success( + history = history + message + ) + + fun toFailure(message: String, type: Result.Failure.Type) = Result.Failure( + failureType = type, + history = history + message, + ) + } + + sealed class Result : BleState() { + + abstract val history: List + + data class Success( + override val history: List, + ) : Result() + + data class Failure( + val failureType: Type, + override val history: List, + ) : Result() { + enum class Type { + PHONE, + HEADPHONES, + ; + } + } + } + } + + + companion object { + const val STEP_TIME = 10 * 1000L // 6 scans per 30 seconds max + + val TAG = logTag("TroubleShooter", "Fragment", "VM") + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/overview_nomaindevice_item.xml b/app/src/main/res/layout/overview_nomaindevice_item.xml index 02535fca..737647b5 100644 --- a/app/src/main/res/layout/overview_nomaindevice_item.xml +++ b/app/src/main/res/layout/overview_nomaindevice_item.xml @@ -32,12 +32,22 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" android:layout_marginTop="4dp" - android:layout_marginBottom="16dp" android:text="@string/overview_nomaindevice_description" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@id/troubleshoot_action" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/permission_label" /> + + diff --git a/app/src/main/res/layout/troubleshooter_fragment.xml b/app/src/main/res/layout/troubleshooter_fragment.xml new file mode 100644 index 00000000..70981612 --- /dev/null +++ b/app/src/main/res/layout/troubleshooter_fragment.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 4293a86c..3afaa438 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -13,6 +13,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d98153ff..1aea1438 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,19 @@ A widget showing the last known device status. Indirect data delivery Use an alternative method to receive BLE data from the system (broadcast instead of callback). + + Troubleshooter + AirPods broadcast their status information using a BLE technology called \"advertisements\". Some devices don\'t implement this technology correctly. This process will try different compatibility settings to fix the issue on your device. Setup your headphones to play music and place them close to your phone while this process is running. + Bluetooth Low Energy Broadcasts + Start investigating + Troubleshoot in progress + Each step takes 5–10 seconds + Success + BLE advertisement broadcasts are being received by CAPod. + Unsuccessful + No combination of compatibility options helped. + Your phone didn\'t receive any BLE data at all. You can retry this test in a crowded area to see if data sources (other than your headphones) can be received. No data being received points towards an issue with your phones operating system. + Your phone received BLE data, but the data does not come from any supported device. Are your headphones powered on? Does CAPod support your headphone? + Discuss on Discord + Troubleshoot \ No newline at end of file