A troubleshooter that automatically goes through different compatibility settings. (#125)

This commit is contained in:
Matthias Urhahn
2023-04-02 22:22:15 +02:00
committed by GitHub
parent cea8f2290d
commit 20ce2d3e22
10 changed files with 587 additions and 8 deletions
@@ -32,9 +32,9 @@ class BleScanner @Inject constructor(
@SuppressLint("MissingPermission") fun scan(
filters: Set<ScanFilter>,
scannerMode: ScannerMode = ScannerMode.BALANCED,
disableOffloadFiltering: Boolean = true,
disableOffloadBatching: Boolean = true,
disableDirectScanCallback: Boolean = true,
disableOffloadFiltering: Boolean = false,
disableOffloadBatching: Boolean = false,
disableDirectScanCallback: Boolean = false,
): Flow<Collection<BleScanResult>> = callbackFlow {
log(TAG) { "scan(filters=$filters, scannerMode=$scannerMode)" }
@@ -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 -> {
@@ -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)
})
}
}
@@ -21,10 +21,12 @@ class MissingMainDeviceVH(parent: ViewGroup) :
item: Item,
payloads: List<Any>
) -> 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?)
@@ -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)
}
}
@@ -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>(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<PodDevice> = { 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<String> = emptyList(),
) : BleState() {
val allSteps: List<String>
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<String>
data class Success(
override val history: List<String>,
) : Result()
data class Failure(
val failureType: Type,
override val history: List<String>,
) : 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")
}
}
@@ -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" />
<com.google.android.material.button.MaterialButton
android:id="@+id/troubleshoot_action"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/troubleshoot_action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/permission_description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.MaterialComponents.Toolbar.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:title="@string/troubleshooter_title" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:id="@+id/ble_card"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/ble_intro_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_intro_title" />
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/troubleshooter_ble_intro_body1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ble_intro_start_action"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/troubleshooter_ble_intro_start_action" />
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/ble_process_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:visibility="visible">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/ble_process_progress"
style="@style/Widget.Material3.CircularProgressIndicator.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminate="true"
app:layout_constraintBottom_toBottomOf="@id/ble_process_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/ble_process_primary" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_primary"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/troubleshooter_ble_process_title"
app:layout_constraintBottom_toTopOf="@id/ble_process_secondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/ble_process_progress"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_secondary"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_process_subtile"
app:layout_constraintBottom_toTopOf="@id/ble_process_history"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/ble_process_primary"
app:layout_constraintTop_toBottomOf="@id/ble_process_primary" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_process_history"
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ble_process_secondary"
tools:text="Step 1\nStep 2\n\Step 3" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/ble_result_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:visibility="visible">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/troubleshooter_ble_result_success_title" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_body"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/troubleshooter_ble_result_success_body" />
<com.google.android.material.button.MaterialButton
android:id="@+id/ble_result_action"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/troubleshooter_ble_result_failure_action" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ble_result_history"
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
tools:text="Step 1\nStep 2\n\Step 3" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -13,6 +13,9 @@
<action
android:id="@+id/action_overviewFragment_to_settingsFragment"
app:destination="@id/settingsFragment" />
<action
android:id="@+id/action_overviewFragment_to_troubleShooterFragment"
app:destination="@id/troubleShooterFragment" />
</fragment>
<fragment
@@ -20,5 +23,9 @@
android:name="eu.darken.capod.main.ui.settings.SettingsFragment"
android:label="SettingsFragment"
tools:layout="@layout/settings_fragment" />
<fragment
android:id="@+id/troubleShooterFragment"
android:name="eu.darken.capod.troubleshooter.ui.TroubleShooterFragment"
tools:layout="@layout/troubleshooter_fragment" />
</navigation>
+15
View File
@@ -102,4 +102,19 @@
<string name="widget_description">A widget showing the last known device status.</string>
<string name="settings_compat_indirectcallback_title">Indirect data delivery</string>
<string name="settings_compat_indirectcallback_summary">Use an alternative method to receive BLE data from the system (broadcast instead of callback).</string>
<string name="troubleshooter_title">Troubleshooter</string>
<string name="troubleshooter_ble_intro_body1">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.</string>
<string name="troubleshooter_ble_intro_title">Bluetooth Low Energy Broadcasts</string>
<string name="troubleshooter_ble_intro_start_action">Start investigating</string>
<string name="troubleshooter_ble_process_title">Troubleshoot in progress</string>
<string name="troubleshooter_ble_process_subtile">Each step takes 510 seconds</string>
<string name="troubleshooter_ble_result_success_title">Success</string>
<string name="troubleshooter_ble_result_success_body">BLE advertisement broadcasts are being received by CAPod.</string>
<string name="troubleshooter_ble_result_failure_title">Unsuccessful</string>
<string name="troubleshooter_ble_result_failure_body">No combination of compatibility options helped.</string>
<string name="troubleshooter_ble_result_failure_phone_body">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.</string>
<string name="troubleshooter_ble_result_failure_phone_headphones">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?</string>
<string name="troubleshooter_ble_result_failure_action">Discuss on Discord</string>
<string name="troubleshoot_action">Troubleshoot</string>
</resources>