Add troubleshooting menu to settings, with Thread credentials sync (#3614)

* Add troubleshooting menu to settings, with Thread credentials sync

 - Add a troubleshooting menu to the app settings which includes logs/debugging settings
 - Add an option to manually sync Thread credentials and view the result

* Less technical messages matching frontend
This commit is contained in:
Joris Pelgröm 2023-06-30 04:18:28 +02:00 committed by GitHub
parent c75b315d81
commit 1ed0f6a094
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 352 additions and 35 deletions

View file

@ -94,11 +94,16 @@ class MatterCommissioningViewModel @Inject constructor(
suspend fun syncThreadIfNecessary(): IntentSender? {
step = CommissioningFlowStep.Working
return try {
threadManager.syncPreferredDataset(
val result = threadManager.syncPreferredDataset(
getApplication<Application>().applicationContext,
serverId,
viewModelScope
)
when (result) {
is ThreadManager.SyncResult.OnlyOnDevice -> result.exportIntent
is ThreadManager.SyncResult.AllHaveCredentials -> result.exportIntent
else -> null
}
} catch (e: Exception) {
Log.w(TAG, "Unable to sync preferred Thread dataset, continuing", e)
null

View file

@ -48,8 +48,9 @@ class ThreadManagerImpl @Inject constructor(
context: Context,
serverId: Int,
scope: CoroutineScope
): IntentSender? {
if (!appSupportsThread() || !coreSupportsThread(serverId)) return null
): ThreadManager.SyncResult {
if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported
if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported
val getDeviceDataset = scope.async { getPreferredDatasetFromDevice(context) }
val getCoreDatasets = scope.async { getDatasetsFromServer(serverId) }
@ -57,29 +58,34 @@ class ThreadManagerImpl @Inject constructor(
val coreThreadDatasets = getCoreDatasets.await()
val coreThreadDataset = coreThreadDatasets?.firstOrNull { it.preferred }
if (deviceThreadIntent == null && coreThreadDataset != null) {
return if (deviceThreadIntent == null && coreThreadDataset != null) {
try {
importDatasetFromServer(context, coreThreadDataset.datasetId, serverId)
Log.d(TAG, "Thread import to device completed")
ThreadManager.SyncResult.OnlyOnServer(imported = true)
} catch (e: Exception) {
Log.e(TAG, "Thread import to device failed", e)
ThreadManager.SyncResult.OnlyOnServer(imported = false)
}
} else if (deviceThreadIntent != null && coreThreadDataset == null) {
Log.d(TAG, "Thread export is ready")
return deviceThreadIntent
ThreadManager.SyncResult.OnlyOnDevice(exportIntent = deviceThreadIntent)
} else if (deviceThreadIntent != null && coreThreadDataset != null) {
try {
val coreIsDevicePreferred = isPreferredDatasetByDevice(context, coreThreadDataset.datasetId, serverId)
Log.d(TAG, "Thread: device ${if (coreIsDevicePreferred) "prefers" else "doesn't prefer" } core preferred dataset")
if (!coreIsDevicePreferred) {
return deviceThreadIntent // Import the dataset to core
}
// Import the dataset to core if different from device
ThreadManager.SyncResult.AllHaveCredentials(
matches = coreIsDevicePreferred,
exportIntent = if (coreIsDevicePreferred) null else deviceThreadIntent
)
} catch (e: Exception) {
Log.e(TAG, "Thread device/core preferred comparison failed", e)
ThreadManager.SyncResult.AllHaveCredentials(matches = null, exportIntent = null)
}
} // else if device and core both don't have datasets, continue
return null
} else {
ThreadManager.SyncResult.NoneHaveCredentials
}
}
override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? =
@ -123,14 +129,16 @@ class ThreadManagerImpl @Inject constructor(
return false
}
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int) {
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? {
if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) {
val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
try {
serverManager.webSocketRepository(serverId).addThreadDataset(threadNetworkCredentials.activeOperationalDataset)
val added = serverManager.webSocketRepository(serverId).addThreadDataset(threadNetworkCredentials.activeOperationalDataset)
if (added) return threadNetworkCredentials.networkName
} catch (e: Exception) {
Log.e(TAG, "Error while executing server new Thread credentials request", e)
}
}
return null
}
}

View file

@ -32,8 +32,8 @@ import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.nfc.NfcSetupActivity
import io.homeassistant.companion.android.onboarding.OnboardApp
import io.homeassistant.companion.android.settings.controls.ManageControlsSettingsFragment
import io.homeassistant.companion.android.settings.developer.DeveloperSettingsFragment
import io.homeassistant.companion.android.settings.language.LanguagesProvider
import io.homeassistant.companion.android.settings.log.LogFragment
import io.homeassistant.companion.android.settings.notification.NotificationChannelFragment
import io.homeassistant.companion.android.settings.notification.NotificationHistoryFragment
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
@ -292,10 +292,10 @@ class SettingsFragment(
it.intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.summary.toString()))
}
findPreference<Preference>("show_share_logs")?.setOnPreferenceClickListener {
findPreference<Preference>("developer")?.setOnPreferenceClickListener {
parentFragmentManager.commit {
replace(R.id.content, LogFragment::class.java, null)
addToBackStack(getString(commonR.string.log))
replace(R.id.content, DeveloperSettingsFragment::class.java, null)
addToBackStack(getString(commonR.string.troubleshooting))
}
return@setOnPreferenceClickListener true
}

View file

@ -4,6 +4,8 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import io.homeassistant.companion.android.settings.developer.DeveloperSettingsPresenter
import io.homeassistant.companion.android.settings.developer.DeveloperSettingsPresenterImpl
import io.homeassistant.companion.android.settings.server.ServerSettingsPresenter
import io.homeassistant.companion.android.settings.server.ServerSettingsPresenterImpl
@ -11,6 +13,9 @@ import io.homeassistant.companion.android.settings.server.ServerSettingsPresente
@InstallIn(ActivityComponent::class)
abstract class SettingsModule {
@Binds
abstract fun developerSettingsPresenter(developerSettingsPresenterImpl: DeveloperSettingsPresenterImpl): DeveloperSettingsPresenter
@Binds
abstract fun serverSettingsPresenter(serverSettingsPresenterImpl: ServerSettingsPresenterImpl): ServerSettingsPresenter

View file

@ -61,7 +61,6 @@ class SettingsPresenterImpl @Inject constructor(
"crash_reporting" -> prefsRepository.isCrashReporting()
"autoplay_video" -> prefsRepository.isAutoPlayVideoEnabled()
"always_show_first_view_on_app_start" -> prefsRepository.isAlwaysShowFirstViewOnAppStartEnabled()
"webview_debug" -> prefsRepository.isWebViewDebugEnabled()
else -> throw IllegalArgumentException("No boolean found by this key: $key")
}
}
@ -75,7 +74,6 @@ class SettingsPresenterImpl @Inject constructor(
"crash_reporting" -> prefsRepository.setCrashReporting(value)
"autoplay_video" -> prefsRepository.setAutoPlayVideo(value)
"always_show_first_view_on_app_start" -> prefsRepository.setAlwaysShowFirstViewOnAppStart(value)
"webview_debug" -> prefsRepository.setWebViewDebugEnabled(value)
else -> throw IllegalArgumentException("No boolean found by this key: $key")
}
}

View file

@ -0,0 +1,100 @@
package io.homeassistant.companion.android.settings.developer
import android.content.IntentSender
import android.os.Bundle
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.commit
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.settings.log.LogFragment
import io.homeassistant.companion.android.settings.server.ServerChooserFragment
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class DeveloperSettingsFragment : DeveloperSettingsView, PreferenceFragmentCompat() {
@Inject
lateinit var presenter: DeveloperSettingsPresenter
private var threadDebugDialog: AlertDialog? = null
private var threadIntentServer: Int = ServerManager.SERVER_ID_ACTIVE
private var threadIntentDeviceOnly: Boolean = true
private val threadPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
presenter.onThreadPermissionResult(requireContext(), result, threadIntentServer, threadIntentDeviceOnly)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
presenter.init(this)
preferenceManager.preferenceDataStore = presenter.getPreferenceDataStore()
setPreferencesFromResource(R.xml.preferences_developer, rootKey)
findPreference<Preference>("show_share_logs")?.setOnPreferenceClickListener {
parentFragmentManager.commit {
replace(R.id.content, LogFragment::class.java, null)
addToBackStack(getString(io.homeassistant.companion.android.common.R.string.log))
}
return@setOnPreferenceClickListener true
}
findPreference<Preference>("thread_debug")?.let {
it.isVisible = presenter.appSupportsThread()
it.setOnPreferenceClickListener {
if (presenter.hasMultipleServers()) {
parentFragmentManager.setFragmentResultListener(ServerChooserFragment.RESULT_KEY, this) { _, bundle ->
if (bundle.containsKey(ServerChooserFragment.RESULT_SERVER)) {
startThreadDebug(bundle.getInt(ServerChooserFragment.RESULT_SERVER))
}
parentFragmentManager.clearFragmentResultListener(ServerChooserFragment.RESULT_KEY)
}
ServerChooserFragment().show(parentFragmentManager, ServerChooserFragment.TAG)
} else {
startThreadDebug(ServerManager.SERVER_ID_ACTIVE)
}
return@setOnPreferenceClickListener true
}
}
}
private fun startThreadDebug(serverId: Int) {
presenter.runThreadDebug(requireContext(), serverId)
threadDebugDialog = AlertDialog.Builder(requireContext())
.setMessage(commonR.string.thread_debug_active)
.setCancelable(false)
.create()
threadDebugDialog?.show()
}
override fun onThreadPermissionRequest(intent: IntentSender, serverId: Int, isDeviceOnly: Boolean) {
threadIntentServer = serverId
threadIntentDeviceOnly = isDeviceOnly
threadPermissionLauncher.launch(IntentSenderRequest.Builder(intent).build())
}
override fun onThreadDebugResult(result: String, success: Boolean?) {
threadDebugDialog?.hide()
AlertDialog.Builder(requireContext())
.setTitle(commonR.string.thread_debug)
.setMessage("${if (success == true) "✅" else if (success == null) "⚠️" else "⛔"}\n\n$result")
.setPositiveButton(commonR.string.ok, null)
.show()
}
override fun onResume() {
super.onResume()
activity?.title = getString(commonR.string.troubleshooting)
}
override fun onDestroy() {
presenter.onFinish()
super.onDestroy()
}
}

View file

@ -0,0 +1,17 @@
package io.homeassistant.companion.android.settings.developer
import android.content.Context
import androidx.activity.result.ActivityResult
import androidx.preference.PreferenceDataStore
interface DeveloperSettingsPresenter {
fun init(view: DeveloperSettingsView)
fun getPreferenceDataStore(): PreferenceDataStore
fun onFinish()
fun hasMultipleServers(): Boolean
fun appSupportsThread(): Boolean
fun runThreadDebug(context: Context, serverId: Int)
fun onThreadPermissionResult(context: Context, result: ActivityResult, serverId: Int, isDeviceOnly: Boolean)
}

View file

@ -0,0 +1,126 @@
package io.homeassistant.companion.android.settings.developer
import android.content.Context
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.preference.PreferenceDataStore
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.thread.ThreadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
class DeveloperSettingsPresenterImpl @Inject constructor(
private val prefsRepository: PrefsRepository,
private val serverManager: ServerManager,
private val threadManager: ThreadManager
) : DeveloperSettingsPresenter, PreferenceDataStore() {
companion object {
private const val TAG = "DevSettingsPresenter"
}
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
private lateinit var view: DeveloperSettingsView
override fun init(view: DeveloperSettingsView) {
this.view = view
}
override fun getPreferenceDataStore(): PreferenceDataStore = this
override fun onFinish() {
mainScope.cancel()
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean = runBlocking {
return@runBlocking when (key) {
"webview_debug" -> prefsRepository.isWebViewDebugEnabled()
else -> throw IllegalArgumentException("No boolean found by this key: $key")
}
}
override fun putBoolean(key: String?, value: Boolean) {
mainScope.launch {
when (key) {
"webview_debug" -> prefsRepository.setWebViewDebugEnabled(value)
else -> throw IllegalArgumentException("No boolean found by this key: $key")
}
}
}
override fun hasMultipleServers(): Boolean = serverManager.defaultServers.size > 1
override fun appSupportsThread(): Boolean = threadManager.appSupportsThread()
override fun runThreadDebug(context: Context, serverId: Int) {
mainScope.launch {
try {
when (val syncResult = threadManager.syncPreferredDataset(context, serverId, this)) {
is ThreadManager.SyncResult.ServerUnsupported ->
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_unsupported_server), false)
is ThreadManager.SyncResult.OnlyOnServer -> {
if (syncResult.imported) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_imported), true)
} else {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
}
is ThreadManager.SyncResult.OnlyOnDevice -> {
if (syncResult.exportIntent != null) {
view.onThreadPermissionRequest(syncResult.exportIntent, serverId, true)
} // else currently doesn't happen
}
is ThreadManager.SyncResult.AllHaveCredentials -> {
if (syncResult.exportIntent != null) {
view.onThreadPermissionRequest(syncResult.exportIntent, serverId, false)
} else if (syncResult.matches == true) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_match), true)
} else {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
}
is ThreadManager.SyncResult.NoneHaveCredentials ->
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_none), null)
else ->
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
} catch (e: Exception) {
Log.e(TAG, "Exception while syncing preferred Thread dataset", e)
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
}
}
override fun onThreadPermissionResult(
context: Context,
result: ActivityResult,
serverId: Int,
isDeviceOnly: Boolean
) {
mainScope.launch {
try {
val submitted = threadManager.sendThreadDatasetExportResult(result, serverId)
if (submitted != null) {
if (isDeviceOnly) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_exported), true)
} else {
// If we got permission while both had a dataset, the device prefers a different network
val out = "${context.getString(commonR.string.thread_debug_result_mismatch)} ${context.getString(commonR.string.thread_debug_result_mismatch_detail, submitted)}"
view.onThreadDebugResult(out, null)
}
} else {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
} catch (e: Exception) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
}
}
}

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.settings.developer
import android.content.IntentSender
interface DeveloperSettingsView {
fun onThreadPermissionRequest(intent: IntentSender, serverId: Int, isDeviceOnly: Boolean)
fun onThreadDebugResult(result: String, success: Boolean?)
}

View file

@ -8,6 +8,15 @@ import kotlinx.coroutines.CoroutineScope
interface ThreadManager {
sealed class SyncResult {
object AppUnsupported : SyncResult()
object ServerUnsupported : SyncResult()
class OnlyOnServer(val imported: Boolean) : SyncResult()
class OnlyOnDevice(val exportIntent: IntentSender?) : SyncResult()
class AllHaveCredentials(val matches: Boolean?, val exportIntent: IntentSender?) : SyncResult()
object NoneHaveCredentials : SyncResult()
}
/**
* Indicates if the app on this device supports Thread credential management.
*/
@ -22,14 +31,14 @@ interface ThreadManager {
* Try to sync the preferred Thread dataset with the device and server. If one has a preferred
* dataset while the other one doesn't, it will sync. If both have preferred datasets, it will
* send updated data to the server if needed. If neither has a preferred dataset, skip syncing.
* @return [IntentSender] if permission is required to import the device dataset, `null` if
* syncing completed or there is nothing to sync
* @return [SyncResult] with details of the sync operation, which may include an [IntentSender]
* if permission is required to import the device dataset
*/
suspend fun syncPreferredDataset(
context: Context,
serverId: Int,
scope: CoroutineScope
): IntentSender?
): SyncResult
/**
* Get the preferred Thread dataset from the server.
@ -55,6 +64,7 @@ interface ThreadManager {
/**
* Process the result from [syncPreferredDataset] or [getPreferredDatasetFromDevice]'s intent
* and add the Thread dataset, if any, to the server.
* @return Network name that was sent and accepted, or `null` if not sent or accepted
*/
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int)
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String?
}

View file

@ -333,7 +333,11 @@ class WebViewPresenterImpl @Inject constructor(
mainScope.launch {
val deviceThreadIntent = try {
threadUseCase.syncPreferredDataset(context, serverId, this)
when (val result = threadUseCase.syncPreferredDataset(context, serverId, this)) {
is ThreadManager.SyncResult.OnlyOnDevice -> result.exportIntent
is ThreadManager.SyncResult.AllHaveCredentials -> result.exportIntent
else -> null
}
} catch (e: Exception) {
Log.w(TAG, "Unable to sync preferred Thread dataset, continuing", e)
null

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="@color/colorAccent"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:viewportHeight="165"
android:viewportWidth="165" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/colorAccent" android:pathData="M82.5,0C37.01,0 0,37.01 0,82.5c0,45.18 36.51,81.98 81.57,82.48V82.57l-27,-0c-8.02,0 -14.55,6.53 -14.55,14.56c0,8.02 6.53,14.55 14.55,14.55v17.98c-17.94,0 -32.53,-14.6 -32.53,-32.53c0,-17.94 14.6,-32.54 32.53,-32.54l27,0v-9.1c0,-14.93 12.15,-27.08 27.08,-27.08c14.93,0 27.08,12.15 27.08,27.08c0,14.93 -12.15,27.08 -27.08,27.08l-9.1,-0v80.64C136.89,155.33 165,122.14 165,82.5C165,37.01 127.99,0 82.5,0z"/>
<path android:fillColor="@color/colorAccent" android:pathData="M117.75,55.49c0,-5.02 -4.08,-9.1 -9.1,-9.1c-5.01,0 -9.1,4.08 -9.1,9.1v9.1l9.1,0C113.67,64.59 117.75,60.51 117.75,55.49z"/>
</vector>

View file

@ -67,11 +67,6 @@
android:icon="@drawable/ic_home_variant_outline"
android:title="@string/always_show_first_view_on_app_start"
android:summary="@string/always_show_first_view_on_app_start_summary" />
<SwitchPreference
android:key="webview_debug"
android:icon="@drawable/ic_android_debug_bridge"
android:title="@string/remote_debugging"
android:summary="@string/remote_debugging_summary" />
<Preference
android:key="nfc_tags"
android:icon="@drawable/ic_nfc"
@ -176,10 +171,10 @@
android:data="https://companion.home-assistant.io" />
</Preference>
<Preference
android:key="show_share_logs"
android:icon="@drawable/ic_notes"
android:title="@string/show_share_logs"
android:summary="@string/show_share_logs_summary" />
android:key="developer"
android:icon="@drawable/ic_bug_report"
android:title="@string/troubleshooting"
android:summary="@string/troubleshooting_summary"/>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/app_version_info">

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="show_share_logs"
android:icon="@drawable/ic_notes"
android:title="@string/show_share_logs"
android:summary="@string/show_share_logs_summary" />
<SwitchPreference
android:key="webview_debug"
android:icon="@drawable/ic_android_debug_bridge"
android:title="@string/remote_debugging"
android:summary="@string/remote_debugging_summary" />
<Preference
android:key="thread_debug"
android:icon="@drawable/ic_thread"
android:title="@string/thread_debug"
android:summary="@string/thread_debug_summary"/>
</PreferenceScreen>

View file

@ -20,7 +20,7 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
context: Context,
serverId: Int,
scope: CoroutineScope
): IntentSender? = null
): ThreadManager.SyncResult = ThreadManager.SyncResult.AppUnsupported
override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = null
@ -30,5 +30,5 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
throw IllegalStateException("Thread is not supported with the minimal flavor")
}
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int) { }
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? = null
}

View file

@ -782,6 +782,8 @@
<string name="template_widget_default">Enter Template Here</string>
<string name="template_widget_desc">Render any template with HTML formatting</string>
<string name="template_widget">Template Widget</string>
<string name="troubleshooting">Troubleshooting</string>
<string name="troubleshooting_summary">Logs and other tools to diagnose issues</string>
<string name="themes_option_label_dark">Dark</string>
<string name="themes_option_label_light">Light</string>
<string name="themes_option_label_system">Follow System Settings</string>
@ -1050,6 +1052,17 @@
<string name="sensor_description_daily_calories">The total number of calories over a day (including both BMR and active calories), where the previous day ends and a new day begins at 12:00 AM local time.</string>
<string name="sensor_name_daily_steps">Daily Steps</string>
<string name="sensor_description_daily_steps">The total step count over a day, where the previous day ends and a new day begins at 12:00 AM local time.</string>
<string name="thread_debug">Sync Thread credentials</string>
<string name="thread_debug_active">Syncing…</string>
<string name="thread_debug_result_error">An unexpected error occurred while syncing</string>
<string name="thread_debug_result_exported">Added network from this device to Home Assistant</string>
<string name="thread_debug_result_imported">Added network from Home Assistant to this device</string>
<string name="thread_debug_result_match">Home Assistant and this device use the same network</string>
<string name="thread_debug_result_mismatch">Home Assistant and this device prefer different networks</string>
<string name="thread_debug_result_mismatch_detail">(device prefers: %1$s)</string>
<string name="thread_debug_result_none">No credentials to sync</string>
<string name="thread_debug_result_unsupported_server">The Home Assistant server does not support Thread</string>
<string name="thread_debug_summary">Manually update device and server Thread credentials and verify results</string>
<string name="tile_vibrate">Vibrate when clicked</string>
<string name="tile_auth_required">Requires unlocked device</string>
<string name="no_results">No results yet</string>