From c9fa75dfc3f8b43e4ca4ca5635471ea751ff7fae Mon Sep 17 00:00:00 2001 From: Pierre Delisle Date: Fri, 22 Sep 2023 10:59:28 -0700 Subject: [PATCH] Integrate Doug's work on Thread. (#172) Note that ktfmt formatted many files that were not targetted by this PR. --- 3p-ecosystem/build.gradle.kts | 10 + 3p-ecosystem/src/main/AndroidManifest.xml | 9 +- .../java/com/google/homesampleapp/Utils.kt | 29 ++ .../google/homesampleapp/chip/ChipClient.kt | 4 + .../homesampleapp/chip/ClustersHelper.kt | 13 + ...ettingsDeveloperUtilitiesNestedFragment.kt | 8 + .../screens/thread/OtbrHttpClient.kt | 141 +++++++ .../screens/thread/ServiceDiscovery.kt | 153 +++++++ .../screens/thread/ThreadFragment.kt | 195 +++++++++ .../screens/thread/ThreadViewModel.kt | 384 ++++++++++++++++++ .../res/drawable/baseline_device_hub_24.xml | 5 + .../src/main/res/layout/fragment_thread.xml | 160 ++++++++ .../src/main/res/navigation/navigation.xml | 18 +- .../settings_developer_utiltities_screen.xml | 6 + build.gradle.kts | 29 +- gradle/libs.versions.toml | 8 + settings.gradle.kts | 55 ++- 17 files changed, 1179 insertions(+), 48 deletions(-) create mode 100644 3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/OtbrHttpClient.kt create mode 100644 3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ServiceDiscovery.kt create mode 100644 3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadFragment.kt create mode 100644 3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadViewModel.kt create mode 100644 3p-ecosystem/src/main/res/drawable/baseline_device_hub_24.xml create mode 100644 3p-ecosystem/src/main/res/layout/fragment_thread.xml diff --git a/3p-ecosystem/build.gradle.kts b/3p-ecosystem/build.gradle.kts index 2eedf19..bafdfdf 100644 --- a/3p-ecosystem/build.gradle.kts +++ b/3p-ecosystem/build.gradle.kts @@ -165,6 +165,14 @@ dependencies { implementation(libs.play.services.base) implementation(libs.play.services.home) + // Thread Network + implementation(libs.play.services.threadnetwork) + // Thread QR Code Scanning + implementation(libs.code.scanner) + // Thread QR Code Generation + implementation(libs.zxing) + + // AndroidX implementation(libs.appcompat) implementation(libs.constraintlayout) @@ -207,6 +215,8 @@ dependencies { // Other implementation(libs.material) implementation(libs.timber) + // Needed for using BaseEncoding class + implementation(libs.guava) // Test testImplementation(libs.junit) diff --git a/3p-ecosystem/src/main/AndroidManifest.xml b/3p-ecosystem/src/main/AndroidManifest.xml index 7435563..e544363 100644 --- a/3p-ecosystem/src/main/AndroidManifest.xml +++ b/3p-ecosystem/src/main/AndroidManifest.xml @@ -50,7 +50,9 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.GHSAFM"> + android:theme="@style/Theme.GHSAFM" + android:usesCleartextTraffic="true"> + @@ -79,6 +81,11 @@ + + + \ No newline at end of file diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/Utils.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/Utils.kt index f900d07..5a96c4b 100644 --- a/3p-ecosystem/src/main/java/com/google/homesampleapp/Utils.kt +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/Utils.kt @@ -19,7 +19,10 @@ package com.google.homesampleapp import android.content.Context import android.content.Intent import android.content.IntentSender +import android.os.Looper +import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentActivity import com.google.protobuf.Timestamp import java.io.File import java.lang.Long.max @@ -354,3 +357,29 @@ enum class CommissioningWindowStatus(val status: Int) { } val OPEN_COMMISSIONING_WINDOW_API = OpenCommissioningWindowApi.ChipDeviceController + +/** + * ToastTimber logs the same message on both Timber and Toast, thus giving some feedback to the user + * that doesn't have ADB connected + */ +object ToastTimber { + fun d(msg: String, activity: FragmentActivity) { + Timber.d(msg) + checkLooper() + Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show() + } + + fun e(msg: String, activity: FragmentActivity) { + Timber.e(msg) + checkLooper() + Toast.makeText(activity, msg, Toast.LENGTH_SHORT).show() + } + + /** + * Asserts Looper is running in the current thread. Important when using Timber in coroutine + * Threads that don't have a Looper running + */ + private fun checkLooper() { + if (Looper.myLooper() == null) Looper.prepare() + } +} diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ChipClient.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ChipClient.kt index b130394..a32368a 100644 --- a/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ChipClient.kt +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ChipClient.kt @@ -98,6 +98,7 @@ class ChipClient @Inject constructor(@ApplicationContext context: Context) { java.lang.IllegalStateException( "Failed unpairing device [$nodeId] with status [$status]")) } + override fun onSuccess(nodeId: Long) { Timber.d("awaitUnpairDevice.onSuccess: deviceId [$nodeId]") continuation.resume(Unit) @@ -131,6 +132,7 @@ class ChipClient @Inject constructor(@ApplicationContext context: Context) { super.onConnectDeviceComplete() continuation.resume(Unit) } + // Note that an error in processing is not necessarily communicated via onError(). // onCommissioningComplete with a "code != 0" also denotes an error in processing. override fun onPairingComplete(code: Int) { @@ -186,6 +188,7 @@ class ChipClient @Inject constructor(@ApplicationContext context: Context) { continuation.resume(Unit) } } + override fun onError(error: Throwable) { super.onError(error) continuation.resumeWithException(error) @@ -213,6 +216,7 @@ class ChipClient @Inject constructor(@ApplicationContext context: Context) { java.lang.IllegalStateException( "Failed opening the pairing window with status [${status}]")) } + override fun onSuccess(deviceId: Long, manualPairingCode: String?, qrCode: String?) { Timber.d( "ShareDevice: awaitOpenPairingWindowWithPIN.onSuccess: deviceId [${deviceId}]") diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ClustersHelper.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ClustersHelper.kt index b865df8..f2de96e 100644 --- a/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ClustersHelper.kt +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/chip/ClustersHelper.kt @@ -129,6 +129,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(values: MutableList?) { continuation.resume(values) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -159,6 +160,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { ) { continuation.resume(values) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -204,6 +206,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(values: MutableList) { continuation.resume(values) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -220,6 +223,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(values: MutableList) { continuation.resume(values) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -252,6 +256,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(value: MutableList) { continuation.resume(value) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -284,6 +289,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(value: Int?) { continuation.resume(value) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -307,6 +313,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess(values: MutableList) { continuation.resume(values) } + override fun onError(ex: Exception) { continuation.resumeWithException(ex) } @@ -469,6 +476,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess() { continuation.resume(Unit) } + override fun onError(ex: Exception) { Timber.e(ex, "readOnOffAttribute command failure") continuation.resumeWithException(ex) @@ -476,6 +484,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { }) } } + // CODELAB FEATURED END suspend fun setOnOffDeviceStateOnOffCluster(deviceId: Long, isOn: Boolean, endpoint: Int) { @@ -498,6 +507,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { Timber.d("Success for setOnOffDeviceStateOnOffCluster") continuation.resume(Unit) } + override fun onError(ex: Exception) { Timber.e(ex, "Failure for setOnOffDeviceStateOnOffCluster") continuation.resumeWithException(ex) @@ -514,6 +524,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { Timber.d("Success for getOnOffDeviceStateOnOffCluster") continuation.resume(Unit) } + override fun onError(ex: Exception) { Timber.e(ex, "Failure for getOnOffDeviceStateOnOffCluster") continuation.resumeWithException(ex) @@ -540,6 +551,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { Timber.d("readOnOffAttribute success: [$value]") continuation.resume(value) } + override fun onError(ex: Exception) { Timber.e(ex, "readOnOffAttribute command failure") continuation.resumeWithException(ex) @@ -584,6 +596,7 @@ class ClustersHelper @Inject constructor(private val chipClient: ChipClient) { override fun onSuccess() { continuation.resume(Unit) } + override fun onError(ex: java.lang.Exception?) { Timber.e( ex, diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/settings/SettingsDeveloperUtilitiesNestedFragment.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/settings/SettingsDeveloperUtilitiesNestedFragment.kt index ed7ffc6..c361207 100644 --- a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/settings/SettingsDeveloperUtilitiesNestedFragment.kt +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/settings/SettingsDeveloperUtilitiesNestedFragment.kt @@ -105,6 +105,14 @@ class SettingsDeveloperUtilitiesNestedFragment : PreferenceFragmentCompat() { } } + // Thread network. + val threadPreference: Preference? = findPreference("thread") + threadPreference?.setOnPreferenceClickListener { + Timber.d("threadPreference onPreferenceClickListener()") + findNavController().navigate(R.id.action_settingsDeveloperUtilitiesFragment_to_threadFragment) + true + } + // Log Repositories. val logReposPreference: Preference? = findPreference("logrepos") logReposPreference?.setOnPreferenceClickListener { diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/OtbrHttpClient.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/OtbrHttpClient.kt new file mode 100644 index 0000000..7d98a21 --- /dev/null +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/OtbrHttpClient.kt @@ -0,0 +1,141 @@ +package com.google.homesampleapp.screens.thread + +import androidx.fragment.app.FragmentActivity +import com.google.android.gms.threadnetwork.ThreadNetworkCredentials +import com.google.common.io.BaseEncoding +import java.io.BufferedInputStream +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber + +/** + * OtbrHttp object has some useful methods, enums and properties for getting/setting information to + * OpenThread Border Routers using the HTTP REST API: + * https://github.com/openthread/ot-br-posix/blob/main/src/rest/openapi.yaml + */ +object OtbrHttpClient { + enum class Verbs { + GET, + POST, + PUT, + DELETE + } + + /** what is generally an OK response */ + val okResponses = + setOf( + HttpURLConnection.HTTP_OK, + HttpURLConnection.HTTP_CREATED, + HttpURLConnection.HTTP_ACCEPTED, + HttpURLConnection.HTTP_NOT_AUTHORITATIVE, + HttpURLConnection.HTTP_NO_CONTENT, + HttpURLConnection.HTTP_RESET, + HttpURLConnection.HTTP_PARTIAL) + + /** + * Creates credentials in the format used by the OTBR HTTP server. See its documentation in + * https://github.com/openthread/ot-br-posix/blob/main/src/rest/openapi.yaml#L215 + */ + fun createJsonCredentialsObject(newCredentials: ThreadNetworkCredentials): JSONObject { + val jsonTimestamp = JSONObject() + jsonTimestamp.put("Seconds", System.currentTimeMillis() / 1000) + jsonTimestamp.put("Ticks", 0) + jsonTimestamp.put("Authoritative", false) + + val jsonQuery = JSONObject() + jsonQuery.put( + "ActiveDataset", BaseEncoding.base16().encode(newCredentials.activeOperationalDataset)) + jsonQuery.put("PendingTimestamp", jsonTimestamp) + // delay of committing the pending set into active set: 10000ms + jsonQuery.put("Delay", 10000) + + Timber.d(jsonQuery.toString()) + + return jsonQuery + } + + /** + * Suspend function that generically creates an HTTP request and gives back its response. This was + * created mostly for interacting with the OTBR HTTP, but it is generic enough for general use. + * + * When using a GET for a credential, you may either use [acceptMimeType] == "text/plain", which + * will return the credentials in hex/base16 TLV format, or [acceptMimeType] == + * "application/json", which will return the credentials in JSON format. See the OpenThread HTTP + * Rest API for more information (link above) + */ + suspend fun createJsonHttpRequest( + url: URL, + activity: FragmentActivity, + verb: Verbs, + postPayload: String = "", + contentTypeMimeType: String = "application/json", + acceptMimeType: String = "application/json", + ): Pair { + + // Use IO Dispatcher so it doesn't block the UI thread + return withContext(Dispatchers.IO) { + // typecasting the connection exposes additional methods and properties + val urlConnection = url.openConnection() as HttpURLConnection + var inputStream: InputStream? = null + var content = "" + + urlConnection.connectTimeout = 1000 /* ms */ + urlConnection.setRequestProperty("Accept", acceptMimeType) + urlConnection.setRequestProperty("User-Agent", "Mozilla/5.0") + + urlConnection.requestMethod = + when (verb) { + Verbs.GET -> "GET" + Verbs.POST -> "POST" + Verbs.DELETE -> "DELETE" + Verbs.PUT -> "PUT" + } + + when (verb) { + Verbs.POST, + Verbs.PUT -> { + urlConnection.setRequestProperty("Content-Type", contentTypeMimeType) + urlConnection.doOutput = true + try { + val outputStream = urlConnection.outputStream + postPayload?.let { outputStream.write(postPayload.toString().toByteArray()) } + outputStream.flush() + outputStream.close() + + val response = urlConnection.responseCode + Timber.d("$verb response: $response") + inputStream = BufferedInputStream(urlConnection.inputStream) + content = String(inputStream.readBytes(), StandardCharsets.UTF_8) + } catch (e: Exception) { + Timber.e("Error loading page $e") + } finally { + inputStream?.let { inputStream!!.close() } + } + } + Verbs.GET, + Verbs.DELETE -> { + try { + inputStream = BufferedInputStream(urlConnection.inputStream) + content = String(inputStream.readBytes(), StandardCharsets.UTF_8) + val response = urlConnection.responseCode + Timber.d("$verb response: $response") + } catch (e: Exception) { + Timber.e("Error loading page $e") + } finally { + inputStream?.let { inputStream.close() } + } + } + else -> { + Timber.e("HTTP verb not supported") + } + } + + Pair(urlConnection, content) + } + } +} diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ServiceDiscovery.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ServiceDiscovery.kt new file mode 100644 index 0000000..159e6d3 --- /dev/null +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ServiceDiscovery.kt @@ -0,0 +1,153 @@ +package com.google.homesampleapp.screens.thread.servicediscovery + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import com.google.common.io.BaseEncoding +import java.util.concurrent.Semaphore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Service discovery for Thread Border Routers + * + * This method starts the discovery of Thread Border Router services, identified by the + * [threadBorderRouterServiceType]. Service discovery will *only* find devices in the same network + * partition or subnet, such as we find on a home or SMB. Once there is a router in between + * partitions on complex network topologies, such as corporate networks, the multicasts will not + * traverse them, and you'll not be able to see devices beyond the router. + * + * The exception to the rule is the Thread Border Router, which will expose the Thread devices in + * the Thread Network, which register using the Service Registration Protocol (SRP) + * + * Once the service (device) is found, it is resolved by the [ResolveListener], which will add the + * device information to the list of [resolvedDevices]. A list is used here (and not a map) because + * it makes the iteration easier when user selects a BR in the pop up dialog + * + * This set has rich service information, such as IP addresses and several TXT fields. See more + * information about the [threadBorderRouterServiceType] on + * https://developers.home.google.com/thread#border_agent_discovery + * + * You'll find similar mDNS/Service Discovery usage, but for Matter devices, on + * screens/commissionable/mdns. Matter has its own specific libraries that encapsulate nsdManager + * and Matter-specific mDNS/SD code + */ +class ServiceDiscovery(context: Context, val viewModelScope: CoroutineScope) { + val resolvedDevices = mutableListOf() + private val threadBorderRouterServiceType = "_meshcop._udp." + private val nsdManager = (context.getSystemService(Context.NSD_SERVICE) as NsdManager) + private val lock = Semaphore(1) + private val discoveryListener: NsdManager.DiscoveryListener = + DiscoveryListener( + threadBorderRouterServiceType, nsdManager, resolvedDevices, viewModelScope, lock) + + fun start() { + viewModelScope.launch(Dispatchers.IO) { + nsdManager.discoverServices( + threadBorderRouterServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } + } + + fun stop() { + // resolvedDevices.clear() + nsdManager.stopServiceDiscovery(discoveryListener) + } +} + +/** + * DiscoveryListener overrides several methods that are called throughout the lifecycle of the + * service/device in Service Discovery. + * + * Whenever a service/device is found, we resolve the device. Please note that due to some + * limitations of NsdManager on Android V and previous, we use a lock and a short delay to prevent + * simultaneous resolving of services. + */ +class DiscoveryListener( + private val serviceType: String, + private val nsdManager: NsdManager, + private val resolvedServices: MutableList, + private val viewModelScope: CoroutineScope, + private val lock: Semaphore +) : NsdManager.DiscoveryListener { + // Called as soon as service discovery begins. + override fun onDiscoveryStarted(regType: String) { + Timber.d("threadClient: Border Router Service discovery started") + } + + override fun onServiceFound(service: NsdServiceInfo) { + if (service.serviceType != + serviceType) { // Service type is the string containing the protocol and transport layer for + // this service. + Timber.d("Unknown Service discovered: ${service.serviceType}") + } else { + Timber.d("Service discovered $service") + // Sending this coroutine to the Dispatcher.IO thread, so it doesn't block UI + viewModelScope.launch(Dispatchers.IO) { + // NsdManager doesn't like simultaneous resolve calls. Thus using a lock + lock.acquire() + nsdManager.resolveService(service, ResolveListener(resolvedServices)) + // NsdManager fails if several resolve requests are sent without delays between them + delay(100) + lock.release() + } + } + } + + override fun onServiceLost(service: NsdServiceInfo) { + if (resolvedServices.contains(service)) { + resolvedServices.remove(service) + } + Timber.e("Service Lost: $service") + } + + override fun onDiscoveryStopped(serviceType: String) { + resolvedServices.clear() + Timber.i("Discovery stopped: $serviceType") + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + resolvedServices.clear() + Timber.e("Discovery failed: Error code: $errorCode") + nsdManager.stopServiceDiscovery(this) + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + resolvedServices.clear() + Timber.e("Discovery failed: Error code: $errorCode") + nsdManager.stopServiceDiscovery(this) + } +} + +/** + * ResolveListener overrides is the last step on acquiring information about a service/device. + * + * It will log part of the information found and add the data to [resolvedDevices] for future usage + * when we want to show a list of known Border Routers in the local network. + */ +class ResolveListener( + private val resolvedDevices: MutableList, +) : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + // Called when the resolve fails. Use the error code to debug. + Timber.e("Resolve failed: $errorCode") + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + if ((serviceInfo.attributes["id"] != null && serviceInfo.attributes["id"]!!.isNotEmpty())) { + Timber.d( + "Resolve Succeeded\n" + + " Host Service Name: ${serviceInfo.serviceName}\n" + + " ID: ${ + BaseEncoding.base16().encode(serviceInfo.attributes["id"]?.let { it }) + }\n" + + " NN: ${serviceInfo.attributes["nn"]?.let { String(it) }}\n" + + " IP: ${serviceInfo.host.hostAddress}\n") + resolvedDevices.add(serviceInfo) + } else { + Timber.e("Resolve failed") + } + } +} diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadFragment.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadFragment.kt new file mode 100644 index 0000000..ec412e6 --- /dev/null +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadFragment.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.homesampleapp.screens.thread + +import android.app.Activity.RESULT_OK +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.google.android.gms.threadnetwork.ThreadNetworkCredentials +import com.google.common.io.BaseEncoding +import com.google.homesampleapp.R +import com.google.homesampleapp.databinding.FragmentThreadBinding +import com.google.homesampleapp.intentSenderToString +import com.google.homesampleapp.lifeCycleEvent +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber + +/** The Thread Fragment */ +@AndroidEntryPoint +class ThreadFragment : Fragment() { + + // Fragment binding + private lateinit var binding: FragmentThreadBinding + private lateinit var threadClientLauncher: ActivityResultLauncher + + // The fragment's ViewModel. + private val viewModel: ThreadViewModel by viewModels() + + /** Lifecycle functions */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + super.onCreateView(inflater, container, savedInstanceState) + + Timber.d(lifeCycleEvent("onCreateView()")) + + // Setup the binding with the fragment. + binding = DataBindingUtil.inflate(inflater, R.layout.fragment_thread, container, false) + + // Setup UI elements and livedata observers. + setupUiElements() + setupObservers() + + return binding.root + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onResume() { + super.onResume() + Timber.d("onResume(): Starting Service Discovery") + viewModel.startServiceDiscovery(requireContext()) + } + + override fun onPause() { + super.onPause() + Timber.d("onPause(): Stopping Service Discovery") + viewModel.stopServiceDiscovery() + } + + /** Setup UI elements */ + private fun setupUiElements() { + binding.getGPSButton.setOnClickListener { + viewModel.getGPSThreadPreferredCredentials(requireActivity()) + } + + binding.setGPSButton.setOnClickListener { viewModel.setGPSThreadCredentials(requireActivity()) } + + binding.clearGPSButton.setOnClickListener { + viewModel.clearGPSPreferredCredentials(requireActivity(), requireContext()) + } + + binding.setOTBRButton.setOnClickListener { + viewModel.setOTBRPendingThreadCredentials(requireActivity()) + } + + binding.getOTBRButton.setOnClickListener { + viewModel.getOTBRActiveThreadCredentials(requireActivity()) + } + + binding.readQRCode.setOnClickListener { viewModel.readQRCodeWorkingSet(requireActivity()) } + + binding.showQRCode.setOnClickListener { viewModel.showWorkingSetQRCode(requireActivity()) } + + binding.doGPSPreferredCredsExistButton.setOnClickListener { + viewModel.doGPSPreferredCredsExist(requireActivity()) + } + + setupMenu() + } + + private fun setupMenu() { + // Navigate back + binding.topAppBar.setOnClickListener { + findNavController().navigate(R.id.action_threadFragment_to_homeFragment) + } + + binding.topAppBar.setOnMenuItemClickListener { + // Navigate to Settings + findNavController().navigate(R.id.action_threadFragment_to_settingsFragment) + true + } + } + + /** Setup Observers */ + private fun setupObservers() { + /** Registers for activity result from Google Play Services */ + threadClientLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val threadNetworkCredentials = + ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!) + viewModel.threadPreferredCredentialsOperationalDataset.postValue( + threadNetworkCredentials) + } else { + val error = "User denied request." + Timber.d(error) + updateThreadInfo(null, "") + } + } + + viewModel.threadClientIntentSender.observe(viewLifecycleOwner) { sender -> + Timber.d("threadClient: intent observe is called with [${intentSenderToString(sender)}]") + if (sender != null) { + Timber.d("threadClient: Launch GPS activity to get ThreadClient") + threadClientLauncher.launch(IntentSenderRequest.Builder(sender).build()) + viewModel.consumeThreadClientIntentSender() + } + } + + viewModel.threadPreferredCredentialsOperationalDataset.observe(viewLifecycleOwner) { + updateThreadInfo(it, "") + } + } + + /** UI update functions */ + private fun updateThreadInfo(credentials: ThreadNetworkCredentials?, title: String = "") { + + var textBox = "" + var tlv = "" + + if (credentials != null) { + textBox = + "NetworkName: " + + credentials.networkName + + "\nChannel: " + + credentials.channel + + "\nPanID: " + + credentials.panId + + "\nExtendedPanID: " + + BaseEncoding.base16().encode(credentials.extendedPanId) + + "\nNetworkKey: " + + BaseEncoding.base16().encode(credentials.networkKey) + + "\nPskc:" + + BaseEncoding.base16().encode(credentials.pskc) + + "\nMesh Local Prefix: " + + BaseEncoding.base16().encode(credentials.meshLocalPrefix) + + tlv = BaseEncoding.base16().encode(credentials.activeOperationalDataset) + } else { + textBox = "Error" + } + + Timber.d(textBox) + + binding.threadNetworkInformationTextView.text = textBox + binding.threadTLVTextView.text = tlv + } +} diff --git a/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadViewModel.kt b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadViewModel.kt new file mode 100644 index 0000000..d8fadb7 --- /dev/null +++ b/3p-ecosystem/src/main/java/com/google/homesampleapp/screens/thread/ThreadViewModel.kt @@ -0,0 +1,384 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.homesampleapp.screens.thread + +import android.app.AlertDialog +import android.content.Context +import android.content.IntentSender +import android.net.nsd.NsdServiceInfo +import android.widget.EditText +import android.widget.ImageView +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.* +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.threadnetwork.ThreadBorderAgent +import com.google.android.gms.threadnetwork.ThreadNetwork +import com.google.android.gms.threadnetwork.ThreadNetworkCredentials +import com.google.android.gms.threadnetwork.ThreadNetworkStatusCodes.* +import com.google.common.io.BaseEncoding +import com.google.homesampleapp.ToastTimber +import com.google.homesampleapp.screens.thread.servicediscovery.ServiceDiscovery +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import com.google.zxing.BarcodeFormat +import com.google.zxing.MultiFormatWriter +import com.journeyapps.barcodescanner.BarcodeEncoder +import dagger.hilt.android.lifecycle.HiltViewModel +import java.net.URL +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber + +/** The ViewModel for the Thread Fragment. See [ThreadFragment] for additional information. */ +@HiltViewModel +class ThreadViewModel @Inject constructor() : ViewModel() { + /** Thread network info. */ + val threadPreferredCredentialsOperationalDataset = MutableLiveData() + private lateinit var sd: ServiceDiscovery + private val otbrPort = "8081" + private val otbrDatasetPendingEndpoint = "/node/dataset/pending" + private val otbrDatasetActiveEndpoint = "/node/dataset/active" + private val threadCredentialsQRCodePrefix = "TD:" + /** IntentSender LiveData triggered by getting thread client information. */ + private val _threadClientIntentSender = MutableLiveData() + val threadClientIntentSender: LiveData + get() = _threadClientIntentSender + + /** Scans for border routers in the network */ + fun startServiceDiscovery(context: Context) { + sd = ServiceDiscovery(context, this.viewModelScope) + sd.start() + } + + /** Stops scanning for border routers in the network */ + fun stopServiceDiscovery() { + sd.stop() + } + + /** Gets preferred thread network credentials from Google Play Services */ + fun getGPSThreadPreferredCredentials(activity: FragmentActivity) { + Timber.d("threadClient: getPreferredCredentials intent sent") + ThreadNetwork.getClient(activity) + ThreadNetwork.getClient(activity) + .preferredCredentials + .addOnSuccessListener { intentSenderResult -> + intentSenderResult.intentSender?.let { intentSender -> + Timber.d("threadClient: intent returned result") + _threadClientIntentSender.postValue(intentSender) + } + ?: ToastTimber.d("threadClient: no preferred credentials found", activity) + } + .addOnFailureListener { e: Exception -> + Timber.d("threadClient: " + getStatusCodeString((e as ApiException).statusCode)) + } + } + + /** + * Sets the thread network credentials into Google Play Services, pertaining a specific BR + * + * The first credentials set become the preferred credentials. Thus, whenever installing a new + * border router, always follow the procedure + * 1. Check if a set of preferred credentials exists in GPS (use [isPreferredCredentials] to a + * random set) + * 2. If preferred credentials already exist, set those to your TBR, and update GPS credentials + * with that information + * 3. If preferred credentials don't exist, create a set (E.g. use the random credentials shown + * below), set those to your TBR and update the GPS credentials with that information. Your set + * of credentials will thus become the preferred credentials + */ + fun setGPSThreadCredentials(activity: FragmentActivity) { + actionOnOTBRDialog(activity, Dispatchers.Main) { serviceInfo -> + val selectedThreadBorderRouterId = serviceInfo.attributes["id"] + if (selectedThreadBorderRouterId == null) { + ToastTimber.e("Could not determine the Border Router ID from its TXT record", activity) + } else { + /** Dialog to enter the thread credentials */ + val enterTBRCredentialsDialog = AlertDialog.Builder(activity) + enterTBRCredentialsDialog.setTitle("Thread Credentials") + enterTBRCredentialsDialog.setMessage( + "Use the preferred credentials (default), create random or copy your own Base16-encoded credentials") + val enterTBRCredentialsEditText = EditText(activity) + val credentials = threadPreferredCredentialsOperationalDataset.value + var base16Credentials = "" + if (credentials != null) { + base16Credentials = + BaseEncoding.base16() + .encode((credentials as ThreadNetworkCredentials).activeOperationalDataset) + } + enterTBRCredentialsEditText.setText(base16Credentials) + enterTBRCredentialsDialog.setView(enterTBRCredentialsEditText) + /** Ok button: assigns to GPS credentials from text edit box */ + enterTBRCredentialsDialog.setPositiveButton("OK") { _, _ -> + setGPSThreadCredentials( + selectedThreadBorderRouterId, enterTBRCredentialsEditText.text.toString(), activity) + } + /** Random button: creates new random set of credentials and assigns to GPS */ + enterTBRCredentialsDialog.setNeutralButton("Create Random") { _, _ -> + setGMSThreadRandomCredentials(selectedThreadBorderRouterId, activity) + } + /** Cancel button */ + enterTBRCredentialsDialog.setNegativeButton("Cancel", null) + enterTBRCredentialsDialog.show() + } + } + } + + /** Sets the GPS thread credentials from new random credentials */ + private fun setGMSThreadRandomCredentials(borderRouterId: ByteArray, activity: FragmentActivity) { + // Dialog for network name + val enterNetworkName = AlertDialog.Builder(activity) + enterNetworkName.setTitle("Network Name") + enterNetworkName.setMessage("Enter the network name") + val enterNetworkNameEditText = EditText(activity) + enterNetworkName.setView(enterNetworkNameEditText) + enterNetworkName.setPositiveButton("OK") { _, _ -> + val credentials = + ThreadNetworkCredentials.newRandomizedBuilder() + .setNetworkName(enterNetworkNameEditText.text.toString()) + .build() + val threadBorderAgent = ThreadBorderAgent.newBuilder(borderRouterId).build() + associateGPSThreadCredentialsToThreadBorderRouterAgent( + credentials, activity, threadBorderAgent) + } + enterNetworkName.show() + } + + /** Sets the GPS thread credentials based on base16 credentials and the TBR id */ + private fun setGPSThreadCredentials( + borderRouterId: ByteArray, + base16Credentials: String, + activity: FragmentActivity, + ) { + val threadBorderAgent = ThreadBorderAgent.newBuilder(borderRouterId).build() + Timber.d("threadClient: using BR $borderRouterId and credentials $base16Credentials") + var credentials: ThreadNetworkCredentials? = null + try { + credentials = + ThreadNetworkCredentials.fromActiveOperationalDataset( + BaseEncoding.base16().decode(base16Credentials)) + } catch (e: Exception) { + ToastTimber.e("threadClient: error $e", activity) + } + associateGPSThreadCredentialsToThreadBorderRouterAgent(credentials, activity, threadBorderAgent) + } + + /** Last step in setting the GPS thread credentials of a TBR */ + private fun associateGPSThreadCredentialsToThreadBorderRouterAgent( + credentials: ThreadNetworkCredentials?, + activity: FragmentActivity, + threadBorderAgent: ThreadBorderAgent, + ) { + credentials?.let { + ThreadNetwork.getClient(activity) + .addCredentials(threadBorderAgent, credentials) + .addOnSuccessListener { ToastTimber.d("threadClient: Credentials added", activity) } + .addOnFailureListener { e: Exception -> + ToastTimber.e( + "threadClient: Error adding the new credentials: " + + getStatusCodeString((e as ApiException).statusCode), + activity) + } + } + } + + /** Sets the credentials of a sample RPi running a Thread Border Router */ + fun setOTBRPendingThreadCredentials(activity: FragmentActivity) { + val credentials = threadPreferredCredentialsOperationalDataset.value + if (credentials != null) { + actionOnOTBRDialog(activity) { serviceInfo -> + val ipAddress = serviceInfo.host.hostAddress + val jsonQuery = OtbrHttpClient.createJsonCredentialsObject(credentials) + var response = + OtbrHttpClient.createJsonHttpRequest( + URL("http://$ipAddress:$otbrPort$otbrDatasetPendingEndpoint"), + activity, + OtbrHttpClient.Verbs.PUT, + jsonQuery.toString()) + if (response.first.responseCode in OtbrHttpClient.okResponses) { + ToastTimber.d("Success", activity) + } + } + } else { + ToastTimber.e("You must set the working dataset", activity) + } + } + + /** Gets the credentials of a sample RPi running a Thread Border Router */ + fun getOTBRActiveThreadCredentials(activity: FragmentActivity) { + actionOnOTBRDialog(activity) { serviceInfo -> + val ipAddress = serviceInfo.host.hostAddress + var response = + OtbrHttpClient.createJsonHttpRequest( + URL("http://$ipAddress:$otbrPort$otbrDatasetActiveEndpoint"), + activity, + OtbrHttpClient.Verbs.GET, + acceptMimeType = "text/plain") + if (response.first.responseCode in OtbrHttpClient.okResponses) { + val otbrDataset = + ThreadNetworkCredentials.fromActiveOperationalDataset( + BaseEncoding.base16().decode(response.second)) + threadPreferredCredentialsOperationalDataset.postValue(otbrDataset) + ToastTimber.d("Success", activity) + Timber.d("${response.second}") + } + } + } + + /** Shows the QR Code of the credentials in the working set */ + fun showWorkingSetQRCode(activity: FragmentActivity) { + val mWriter = MultiFormatWriter() + try { + // BitMatrix class to encode entered text and set Width & Height + val credentials = threadPreferredCredentialsOperationalDataset.value + if (credentials != null) { + val qrCodeContent = + threadCredentialsQRCodePrefix + + BaseEncoding.base16().encode(credentials.activeOperationalDataset) + Timber.d("Showing QRCode of $qrCodeContent") + val mMatrix = mWriter.encode(qrCodeContent, BarcodeFormat.QR_CODE, 600, 600) + val mEncoder = BarcodeEncoder() + val mBitmap = mEncoder.createBitmap(mMatrix) + val qrView = ImageView(activity) + qrView.setImageBitmap(mBitmap) + val dialogBuilder = AlertDialog.Builder(activity).setView(qrView) + dialogBuilder.show() + } else { + ToastTimber.e("You must set the working dataset", activity) + } + } catch (e: Exception) { + ToastTimber.e("Error $e", activity) + } + } + + /** Prompts whether credentials exist in storage or now. Consent from user is not necessary */ + fun doGPSPreferredCredsExist(activity: FragmentActivity) { + try { + Timber.d("threadClient: getPreferredCredentials intent sent") + ThreadNetwork.getClient(activity) + .preferredCredentials + .addOnSuccessListener { intentSenderResult -> + intentSenderResult.intentSender?.let { intentSender -> + ToastTimber.d("threadClient: preferred credentials exist", activity) + // don't post the intent on `threadClientIntentSender` as we do when + // we really want to know which are the credentials. That will prompt a + // user consent. In this case we just want to know whether they exist + } + ?: ToastTimber.d("threadClient: no preferred credentials found", activity) + } + .addOnFailureListener { e: Exception -> + ToastTimber.e( + "threadClient: Error adding the new credentials: " + + getStatusCodeString((e as ApiException).statusCode), + activity) + } + } catch (e: Exception) { + ToastTimber.e("Error $e", activity) + } + } + + /** Reads the QR Code of Thread credentials into the working set */ + fun readQRCodeWorkingSet(activity: FragmentActivity) { + val options = + GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE, Barcode.FORMAT_AZTEC) + .build() + val scanner = GmsBarcodeScanning.getClient(activity, options) + scanner + .startScan() + .addOnSuccessListener { barcode -> + try { + val qrCodeDataset = + ThreadNetworkCredentials.fromActiveOperationalDataset( + BaseEncoding.base16().decode(barcode.displayValue?.substringAfter(":"))) + threadPreferredCredentialsOperationalDataset.postValue(qrCodeDataset) + ToastTimber.d("Working set updated with QR Code content", activity) + } catch (e: Exception) { + ToastTimber.e("Error reading QR Code content: $e", activity) + } + } + .addOnCanceledListener { ToastTimber.d("QR Code scanning cancelled", activity) } + .addOnFailureListener { e -> ToastTimber.e("Error reading QR Code: $e", activity) } + } + + /** + * Creates a dialog where user may pick a Border Router from the ones found on the local network + * via mDNS/Service Discovery. The caller must provide a lambda function that will be called with + * the [NsdServiceInfo] of the selected border router + */ + private fun actionOnOTBRDialog( + activity: FragmentActivity, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + block: suspend (serviceInfo: NsdServiceInfo) -> Unit, + ) { + // Get the ips of the Border Routers in the network and prompts the user to select them + val selectTBRDialog = AlertDialog.Builder(activity) + selectTBRDialog.setTitle("Select the Border Router. Ensure the BR has the REST API enabled") + // creates a local immutable list Border Routers. Prevents an update of the list + // mid operation and a possible invalid reference to a [sd.resolvedDevices] item + val borderRouterLocalList: List = sd.resolvedDevices.toList() + val borderRouterStringList = + borderRouterLocalList.map { serviceInfo -> serviceInfo.serviceName }.toTypedArray() + selectTBRDialog.setItems(borderRouterStringList) { _, selectedThreadBorderRouter -> + viewModelScope.launch(dispatcher) { + // We need to catch this again for java.net.ConnectException + try { + block(borderRouterLocalList[selectedThreadBorderRouter]) + } catch (e: Exception) { + ToastTimber.e("$e", activity) + } + } + } + selectTBRDialog.show() + } + + /** + * There is no API to clear the preferred credential's storage. This method displays a message on + * how to perform it in development phones via adb + */ + fun clearGPSPreferredCredentials(activity: FragmentActivity, context: Context) { + val input = EditText(context) + val dialogBuilder = AlertDialog.Builder(activity).setView(input) + dialogBuilder + .setMessage( + "You can't clear credentials programmatically.\n\n" + + "If you would like to alter your GPS Preferred Thread credentials " + + "after they have been set, you must:\n" + + "1. Factory Reset all your Google Border Routers\n" + + "2. Clear all GPS information via `adb -d shell pm clear com.google.android.gms`\n" + + "3a. get credentials from your OTBR or\n" + + "3b. create a new set\n" + + "4. Set the credentials to a BR in GPS. First credential set will become preferred\n\n" + + "Clearing all the GPS data might have unforeseen side effects on other Google " + + "Services. Use cautiously on a phone and on an user dedicated to development purposes.") + .setCancelable(false) + .setPositiveButton("Ok") { dialog, id -> dialog.dismiss() } + val alert = dialogBuilder.create() + alert.setTitle("Warning") + alert.show() + } + + /** + * Consumes the value in [_threadClientIntentSender] and sets it back to null. Needs to be called + * to avoid re-processing the IntentSender after a configuration change (where the LiveData is + * re-posted. + */ + fun consumeThreadClientIntentSender() { + _threadClientIntentSender.postValue(null) + } +} diff --git a/3p-ecosystem/src/main/res/drawable/baseline_device_hub_24.xml b/3p-ecosystem/src/main/res/drawable/baseline_device_hub_24.xml new file mode 100644 index 0000000..d8bfc59 --- /dev/null +++ b/3p-ecosystem/src/main/res/drawable/baseline_device_hub_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/3p-ecosystem/src/main/res/layout/fragment_thread.xml b/3p-ecosystem/src/main/res/layout/fragment_thread.xml new file mode 100644 index 0000000..a78ec29 --- /dev/null +++ b/3p-ecosystem/src/main/res/layout/fragment_thread.xml @@ -0,0 +1,160 @@ + + + + + + + + + +