From 138b4b1c4c768cb7265352d88fa432fe17fd420c Mon Sep 17 00:00:00 2001 From: Kangping Dong Date: Fri, 1 Sep 2023 18:27:43 +0800 Subject: [PATCH] [Thread] add demoapp for Thread network APIs A screenshot of the demo app: ![](https://screenshot.googleplex.com/AsiYKPzG4nJpr6x.png) Bug: 273227725 Change-Id: I8d9bcf00bb54d3f152c436b56df919d967c5b71b --- thread/demoapp/Android.bp | 39 +++ thread/demoapp/AndroidManifest.xml | 40 +++ .../demoapp/ConnectivityToolsFragment.java | 317 ++++++++++++++++++ .../threadnetwork/demoapp/MainActivity.java | 61 ++++ .../ThreadNetworkSettingsFragment.java | 277 +++++++++++++++ .../BackgroundExecutorProvider.java | 43 +++ .../res/drawable/ic_launcher_foreground.xml | 40 +++ thread/demoapp/res/drawable/ic_menu_24dp.xml | 26 ++ .../res/drawable/ic_thread_wordmark.xml | 40 +++ .../layout/connectivity_tools_fragment.xml | 137 ++++++++ .../layout/list_server_ip_address_view.xml | 25 ++ .../res/layout/list_server_port_view.xml | 25 ++ thread/demoapp/res/layout/main_activity.xml | 63 ++++ thread/demoapp/res/layout/nav_header.xml | 30 ++ .../thread_network_settings_fragment.xml | 71 ++++ thread/demoapp/res/menu/nav_menu.xml | 24 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../demoapp/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1319 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2540 bytes .../demoapp/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 945 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1852 bytes .../demoapp/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1610 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3164 bytes .../demoapp/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 2176 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 4344 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 2816 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 5650 bytes thread/demoapp/res/navigation/nav_graph.xml | 35 ++ thread/demoapp/res/values/colors.xml | 25 ++ thread/demoapp/res/values/dimens.xml | 24 ++ thread/demoapp/res/values/themes.xml | 32 ++ 32 files changed, 1386 insertions(+) create mode 100644 thread/demoapp/Android.bp create mode 100644 thread/demoapp/AndroidManifest.xml create mode 100644 thread/demoapp/java/com/android/threadnetwork/demoapp/ConnectivityToolsFragment.java create mode 100644 thread/demoapp/java/com/android/threadnetwork/demoapp/MainActivity.java create mode 100644 thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java create mode 100644 thread/demoapp/java/com/android/threadnetwork/demoapp/concurrent/BackgroundExecutorProvider.java create mode 100644 thread/demoapp/res/drawable/ic_launcher_foreground.xml create mode 100644 thread/demoapp/res/drawable/ic_menu_24dp.xml create mode 100644 thread/demoapp/res/drawable/ic_thread_wordmark.xml create mode 100644 thread/demoapp/res/layout/connectivity_tools_fragment.xml create mode 100644 thread/demoapp/res/layout/list_server_ip_address_view.xml create mode 100644 thread/demoapp/res/layout/list_server_port_view.xml create mode 100644 thread/demoapp/res/layout/main_activity.xml create mode 100644 thread/demoapp/res/layout/nav_header.xml create mode 100644 thread/demoapp/res/layout/thread_network_settings_fragment.xml create mode 100644 thread/demoapp/res/menu/nav_menu.xml create mode 100644 thread/demoapp/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 thread/demoapp/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 thread/demoapp/res/mipmap-hdpi/ic_launcher.png create mode 100644 thread/demoapp/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 thread/demoapp/res/mipmap-mdpi/ic_launcher.png create mode 100644 thread/demoapp/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 thread/demoapp/res/mipmap-xhdpi/ic_launcher.png create mode 100644 thread/demoapp/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 thread/demoapp/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 thread/demoapp/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 thread/demoapp/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 thread/demoapp/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 thread/demoapp/res/navigation/nav_graph.xml create mode 100644 thread/demoapp/res/values/colors.xml create mode 100644 thread/demoapp/res/values/dimens.xml create mode 100644 thread/demoapp/res/values/themes.xml diff --git a/thread/demoapp/Android.bp b/thread/demoapp/Android.bp new file mode 100644 index 0000000000..da7a5f81a4 --- /dev/null +++ b/thread/demoapp/Android.bp @@ -0,0 +1,39 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_app { + name: "ThreadNetworkDemoApp", + srcs: ["java/**/*.java"], + min_sdk_version: "34", + resource_dirs: ["res"], + static_libs: [ + "androidx-constraintlayout_constraintlayout", + "androidx.appcompat_appcompat", + "androidx.navigation_navigation-common", + "androidx.navigation_navigation-fragment", + "androidx.navigation_navigation-ui", + "com.google.android.material_material", + "guava", + ], + libs: [ + "framework-connectivity-t", + ], + certificate: "platform", + privileged: true, + platform_apis: true, +} diff --git a/thread/demoapp/AndroidManifest.xml b/thread/demoapp/AndroidManifest.xml new file mode 100644 index 0000000000..c31bb712a9 --- /dev/null +++ b/thread/demoapp/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/thread/demoapp/java/com/android/threadnetwork/demoapp/ConnectivityToolsFragment.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/ConnectivityToolsFragment.java new file mode 100644 index 0000000000..6f616eb7c5 --- /dev/null +++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/ConnectivityToolsFragment.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.threadnetwork.demoapp; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import com.android.threadnetwork.demoapp.concurrent.BackgroundExecutorProvider; + +import com.google.android.material.switchmaterial.SwitchMaterial; +import com.google.android.material.textfield.TextInputEditText; +import com.google.common.io.CharStreams; +import com.google.common.net.InetAddresses; +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningScheduledExecutorService; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.CancellationException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +public final class ConnectivityToolsFragment extends Fragment { + private static final String TAG = "ConnectivityTools"; + + // This is a mirror of NetworkCapabilities#NET_CAPABILITY_LOCAL_NETWORK which is @hide for now + private static final int NET_CAPABILITY_LOCAL_NETWORK = 36; + + private static final Duration PING_TIMEOUT = Duration.ofSeconds(10L); + private static final Duration UDP_TIMEOUT = Duration.ofSeconds(10L); + private final ListeningScheduledExecutorService mBackgroundExecutor = + BackgroundExecutorProvider.getBackgroundExecutor(); + private final ArrayList mServerIpCandidates = new ArrayList<>(); + private final ArrayList mServerPortCandidates = new ArrayList<>(); + private Executor mMainExecutor; + + private ListenableFuture mPingFuture; + private ListenableFuture mUdpFuture; + private ArrayAdapter mPingServerIpAdapter; + private ArrayAdapter mUdpServerIpAdapter; + private ArrayAdapter mUdpServerPortAdapter; + + private Network mThreadNetwork; + private boolean mBindThreadNetwork = false; + + private void subscribeToThreadNetwork() { + ConnectivityManager cm = getActivity().getSystemService(ConnectivityManager.class); + cm.registerNetworkCallback( + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .addCapability(NET_CAPABILITY_LOCAL_NETWORK) + .build(), + new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + mThreadNetwork = network; + } + + @Override + public void onLost(Network network) { + mThreadNetwork = network; + } + }, + new Handler(Looper.myLooper())); + } + + private static String getPingCommand(String serverIp) { + try { + InetAddress serverAddress = InetAddresses.forString(serverIp); + return (serverAddress instanceof Inet6Address) + ? "/system/bin/ping6" + : "/system/bin/ping"; + } catch (IllegalArgumentException e) { + // The ping command can handle the illegal argument and output error message + return "/system/bin/ping6"; + } + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.connectivity_tools_fragment, container, false); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mMainExecutor = ContextCompat.getMainExecutor(getActivity()); + + subscribeToThreadNetwork(); + + AutoCompleteTextView pingServerIpText = view.findViewById(R.id.ping_server_ip_address_text); + mPingServerIpAdapter = + new ArrayAdapter( + getActivity(), R.layout.list_server_ip_address_view, mServerIpCandidates); + pingServerIpText.setAdapter(mPingServerIpAdapter); + TextView pingOutputText = view.findViewById(R.id.ping_output_text); + Button pingButton = view.findViewById(R.id.ping_button); + + pingButton.setOnClickListener( + v -> { + if (mPingFuture != null) { + mPingFuture.cancel(/* mayInterruptIfRunning= */ true); + mPingFuture = null; + } + + String serverIp = pingServerIpText.getText().toString().strip(); + updateServerIpCandidates(serverIp); + pingOutputText.setText("Sending ping message to " + serverIp + "\n"); + + mPingFuture = sendPing(serverIp); + Futures.addCallback( + mPingFuture, + new FutureCallback() { + @Override + public void onSuccess(String result) { + pingOutputText.append(result + "\n"); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof CancellationException) { + // Ignore the cancellation error + return; + } + pingOutputText.append("Failed: " + t.getMessage() + "\n"); + } + }, + mMainExecutor); + }); + + AutoCompleteTextView udpServerIpText = view.findViewById(R.id.udp_server_ip_address_text); + mUdpServerIpAdapter = + new ArrayAdapter( + getActivity(), R.layout.list_server_ip_address_view, mServerIpCandidates); + udpServerIpText.setAdapter(mUdpServerIpAdapter); + AutoCompleteTextView udpServerPortText = view.findViewById(R.id.udp_server_port_text); + mUdpServerPortAdapter = + new ArrayAdapter( + getActivity(), R.layout.list_server_port_view, mServerPortCandidates); + udpServerPortText.setAdapter(mUdpServerPortAdapter); + TextInputEditText udpMsgText = view.findViewById(R.id.udp_message_text); + TextView udpOutputText = view.findViewById(R.id.udp_output_text); + + SwitchMaterial switchBindThreadNetwork = view.findViewById(R.id.switch_bind_thread_network); + switchBindThreadNetwork.setChecked(mBindThreadNetwork); + switchBindThreadNetwork.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + if (isChecked) { + Log.i(TAG, "Binding to the Thread network"); + + if (mThreadNetwork == null) { + Log.e(TAG, "Thread network is not available"); + Toast.makeText( + getActivity().getApplicationContext(), + "Thread network is not available", + Toast.LENGTH_LONG); + switchBindThreadNetwork.setChecked(false); + } else { + mBindThreadNetwork = true; + } + } else { + mBindThreadNetwork = false; + } + }); + + Button sendUdpButton = view.findViewById(R.id.send_udp_button); + sendUdpButton.setOnClickListener( + v -> { + if (mUdpFuture != null) { + mUdpFuture.cancel(/* mayInterruptIfRunning= */ true); + mUdpFuture = null; + } + + String serverIp = udpServerIpText.getText().toString().strip(); + String serverPort = udpServerPortText.getText().toString().strip(); + String udpMsg = udpMsgText.getText().toString().strip(); + updateServerIpCandidates(serverIp); + updateServerPortCandidates(serverPort); + udpOutputText.setText( + String.format( + "Sending UDP message \"%s\" to [%s]:%s", + udpMsg, serverIp, serverPort)); + + mUdpFuture = sendUdpMessage(serverIp, serverPort, udpMsg); + Futures.addCallback( + mUdpFuture, + new FutureCallback() { + @Override + public void onSuccess(String result) { + udpOutputText.append("\n" + result); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof CancellationException) { + // Ignore the cancellation error + return; + } + udpOutputText.append("\nFailed: " + t.getMessage()); + } + }, + mMainExecutor); + }); + } + + private void updateServerIpCandidates(String newServerIp) { + if (!mServerIpCandidates.contains(newServerIp)) { + mServerIpCandidates.add(0, newServerIp); + mPingServerIpAdapter.notifyDataSetChanged(); + mUdpServerIpAdapter.notifyDataSetChanged(); + } + } + + private void updateServerPortCandidates(String newServerPort) { + if (!mServerPortCandidates.contains(newServerPort)) { + mServerPortCandidates.add(0, newServerPort); + mUdpServerPortAdapter.notifyDataSetChanged(); + } + } + + private ListenableFuture sendPing(String serverIp) { + return FluentFuture.from(Futures.submit(() -> doSendPing(serverIp), mBackgroundExecutor)) + .withTimeout(PING_TIMEOUT.getSeconds(), TimeUnit.SECONDS, mBackgroundExecutor); + } + + private String doSendPing(String serverIp) throws IOException { + String pingCommand = getPingCommand(serverIp); + Process process = + new ProcessBuilder() + .command(pingCommand, "-c 1", serverIp) + .redirectErrorStream(true) + .start(); + + return CharStreams.toString(new InputStreamReader(process.getInputStream())); + } + + private ListenableFuture sendUdpMessage( + String serverIp, String serverPort, String msg) { + return FluentFuture.from( + Futures.submit( + () -> doSendUdpMessage(serverIp, serverPort, msg), + mBackgroundExecutor)) + .withTimeout(UDP_TIMEOUT.getSeconds(), TimeUnit.SECONDS, mBackgroundExecutor); + } + + private String doSendUdpMessage(String serverIp, String serverPort, String msg) + throws IOException { + SocketAddress serverAddr = new InetSocketAddress(serverIp, Integer.parseInt(serverPort)); + + try (DatagramSocket socket = new DatagramSocket()) { + if (mBindThreadNetwork && mThreadNetwork != null) { + mThreadNetwork.bindSocket(socket); + Log.i(TAG, "Successfully bind the socket to the Thread network"); + } + + socket.connect(serverAddr); + Log.d(TAG, "connected " + serverAddr); + + byte[] msgBytes = msg.getBytes(); + DatagramPacket packet = new DatagramPacket(msgBytes, msgBytes.length); + + Log.d(TAG, String.format("Sending message to server %s: %s", serverAddr, msg)); + socket.send(packet); + Log.d(TAG, "Send done"); + + Log.d(TAG, "Waiting for server reply"); + socket.receive(packet); + return new String(packet.getData(), packet.getOffset(), packet.getLength(), UTF_8); + } + } +} diff --git a/thread/demoapp/java/com/android/threadnetwork/demoapp/MainActivity.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/MainActivity.java new file mode 100644 index 0000000000..ef97a6c411 --- /dev/null +++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/MainActivity.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.threadnetwork.demoapp; + +import android.content.Intent; +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.navigation.NavController; +import androidx.navigation.fragment.NavHostFragment; +import androidx.navigation.ui.AppBarConfiguration; +import androidx.navigation.ui.NavigationUI; + +import com.google.android.material.navigation.NavigationView; + +public final class MainActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_activity); + + NavHostFragment navHostFragment = + (NavHostFragment) + getSupportFragmentManager().findFragmentById(R.id.nav_host_fragment); + + NavController navController = navHostFragment.getNavController(); + + DrawerLayout drawerLayout = findViewById(R.id.drawer_layout); + Toolbar topAppBar = findViewById(R.id.top_app_bar); + AppBarConfiguration appBarConfig = + new AppBarConfiguration.Builder(navController.getGraph()) + .setOpenableLayout(drawerLayout) + .build(); + + NavigationUI.setupWithNavController(topAppBar, navController, appBarConfig); + + NavigationView navView = findViewById(R.id.nav_view); + NavigationUI.setupWithNavController(navView, navController); + } + + @Override + protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + } +} diff --git a/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java new file mode 100644 index 0000000000..e95feafd3e --- /dev/null +++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.threadnetwork.demoapp; + +import static com.google.common.io.BaseEncoding.base16; + +import android.net.ConnectivityManager; +import android.net.LinkAddress; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.net.RouteInfo; +import android.net.thread.ActiveOperationalDataset; +import android.net.thread.OperationalDatasetTimestamp; +import android.net.thread.PendingOperationalDataset; +import android.net.thread.ThreadNetworkController; +import android.net.thread.ThreadNetworkException; +import android.net.thread.ThreadNetworkManager; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.OutcomeReceiver; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Executor; + +public final class ThreadNetworkSettingsFragment extends Fragment { + private static final String TAG = "ThreadNetworkSettings"; + + // This is a mirror of NetworkCapabilities#NET_CAPABILITY_LOCAL_NETWORK which is @hide for now + private static final int NET_CAPABILITY_LOCAL_NETWORK = 36; + + private ThreadNetworkController mThreadController; + private TextView mTextState; + private TextView mTextNetworkInfo; + private TextView mMigrateNetworkState; + private Executor mMainExecutor; + + private int mDeviceRole; + private long mPartitionId; + private ActiveOperationalDataset mActiveDataset; + + private static final byte[] DEFAULT_ACTIVE_DATASET_TLVS = + base16().lowerCase() + .decode( + "0e080000000000010000000300001235060004001fffe00208dae21bccb8c321c40708fdc376ead74396bb0510c52f56cd2d38a9eb7a716954f8efd939030f4f70656e5468726561642d646231390102db190410fcb737e6fd6bb1b0fed524a4496363110c0402a0f7f8"); + private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET = + ActiveOperationalDataset.fromThreadTlvs(DEFAULT_ACTIVE_DATASET_TLVS); + + private static String deviceRoleToString(int mDeviceRole) { + switch (mDeviceRole) { + case ThreadNetworkController.DEVICE_ROLE_STOPPED: + return "Stopped"; + case ThreadNetworkController.DEVICE_ROLE_DETACHED: + return "Detached"; + case ThreadNetworkController.DEVICE_ROLE_CHILD: + return "Child"; + case ThreadNetworkController.DEVICE_ROLE_ROUTER: + return "Router"; + case ThreadNetworkController.DEVICE_ROLE_LEADER: + return "Leader"; + default: + return "Unknown"; + } + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.thread_network_settings_fragment, container, false); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + ConnectivityManager cm = getActivity().getSystemService(ConnectivityManager.class); + cm.registerNetworkCallback( + new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_THREAD) + .addCapability(NET_CAPABILITY_LOCAL_NETWORK) + .build(), + new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + Log.i(TAG, "New Thread network is available"); + } + + @Override + public void onLinkPropertiesChanged( + Network network, LinkProperties linkProperties) { + updateNetworkInfo(linkProperties); + } + + @Override + public void onLost(Network network) { + Log.i(TAG, "Thread network " + network + " is lost"); + updateNetworkInfo(null /* linkProperties */); + } + }, + new Handler(Looper.myLooper())); + + mMainExecutor = ContextCompat.getMainExecutor(getActivity()); + ThreadNetworkManager threadManager = + getActivity().getSystemService(ThreadNetworkManager.class); + if (threadManager != null) { + mThreadController = threadManager.getAllThreadNetworkControllers().get(0); + mThreadController.registerStateCallback( + mMainExecutor, + new ThreadNetworkController.StateCallback() { + @Override + public void onDeviceRoleChanged(int mDeviceRole) { + ThreadNetworkSettingsFragment.this.mDeviceRole = mDeviceRole; + updateState(); + } + + @Override + public void onPartitionIdChanged(long mPartitionId) { + ThreadNetworkSettingsFragment.this.mPartitionId = mPartitionId; + updateState(); + } + }); + mThreadController.registerOperationalDatasetCallback( + mMainExecutor, + newActiveDataset -> { + this.mActiveDataset = newActiveDataset; + updateState(); + }); + } + + mTextState = (TextView) view.findViewById(R.id.text_state); + mTextNetworkInfo = (TextView) view.findViewById(R.id.text_network_info); + + if (mThreadController == null) { + mTextState.setText("Thread not supported!"); + return; + } + + ((Button) view.findViewById(R.id.button_join_network)).setOnClickListener(v -> doJoin()); + ((Button) view.findViewById(R.id.button_leave_network)).setOnClickListener(v -> doLeave()); + + mMigrateNetworkState = view.findViewById(R.id.text_migrate_network_state); + ((Button) view.findViewById(R.id.button_migrate_network)) + .setOnClickListener(v -> doMigration()); + + updateState(); + } + + private void doJoin() { + mThreadController.join( + DEFAULT_ACTIVE_DATASET, + mMainExecutor, + new OutcomeReceiver() { + @Override + public void onError(ThreadNetworkException error) { + Log.e(TAG, "Failed to join network " + DEFAULT_ACTIVE_DATASET, error); + } + + @Override + public void onResult(Void v) { + Log.i(TAG, "Successfully Joined"); + } + }); + } + + private void doLeave() { + mThreadController.leave( + mMainExecutor, + new OutcomeReceiver<>() { + @Override + public void onError(ThreadNetworkException error) { + Log.e(TAG, "Failed to leave network " + DEFAULT_ACTIVE_DATASET, error); + } + + @Override + public void onResult(Void v) { + Log.i(TAG, "Successfully Left"); + } + }); + } + + private void doMigration() { + var newActiveDataset = + new ActiveOperationalDataset.Builder(DEFAULT_ACTIVE_DATASET) + .setNetworkName("NewThreadNet") + .setActiveTimestamp(OperationalDatasetTimestamp.fromInstant(Instant.now())) + .build(); + var pendingDataset = + new PendingOperationalDataset( + newActiveDataset, + OperationalDatasetTimestamp.fromInstant(Instant.now()), + Duration.ofSeconds(30)); + mThreadController.scheduleMigration( + pendingDataset, + mMainExecutor, + new OutcomeReceiver() { + @Override + public void onResult(Void v) { + mMigrateNetworkState.setText( + "Scheduled migration to network \"NewThreadNet\" in 30s"); + // TODO: update Pending Dataset state + } + + @Override + public void onError(ThreadNetworkException e) { + mMigrateNetworkState.setText( + "Failed to schedule migration: " + e.getMessage()); + } + }); + } + + private void updateState() { + Log.i( + TAG, + String.format( + "Updating Thread states (mDeviceRole: %s)", + deviceRoleToString(mDeviceRole))); + + String state = + String.format( + "Role %s\n" + + "Partition ID %d\n" + + "Network Name %s\n" + + "Extended PAN ID %s", + deviceRoleToString(mDeviceRole), + mPartitionId, + mActiveDataset != null ? mActiveDataset.getNetworkName() : null, + mActiveDataset != null + ? base16().encode(mActiveDataset.getExtendedPanId()) + : null); + mTextState.setText(state); + } + + private void updateNetworkInfo(LinkProperties linProperties) { + if (linProperties == null) { + mTextNetworkInfo.setText(""); + return; + } + + StringBuilder sb = new StringBuilder("Interface name:\n"); + sb.append(linProperties.getInterfaceName() + "\n"); + sb.append("Addresses:\n"); + for (LinkAddress la : linProperties.getLinkAddresses()) { + sb.append(la + "\n"); + } + sb.append("Routes:\n"); + for (RouteInfo route : linProperties.getRoutes()) { + sb.append(route + "\n"); + } + mTextNetworkInfo.setText(sb.toString()); + } +} diff --git a/thread/demoapp/java/com/android/threadnetwork/demoapp/concurrent/BackgroundExecutorProvider.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/concurrent/BackgroundExecutorProvider.java new file mode 100644 index 0000000000..d05ba73055 --- /dev/null +++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/concurrent/BackgroundExecutorProvider.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * 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.android.threadnetwork.demoapp.concurrent; + +import androidx.annotation.GuardedBy; + +import com.google.common.util.concurrent.ListeningScheduledExecutorService; +import com.google.common.util.concurrent.MoreExecutors; + +import java.util.concurrent.Executors; + +/** Provides executors for executing tasks in background. */ +public final class BackgroundExecutorProvider { + private static final int CONCURRENCY = 4; + + @GuardedBy("BackgroundExecutorProvider.class") + private static ListeningScheduledExecutorService backgroundExecutor; + + private BackgroundExecutorProvider() {} + + public static synchronized ListeningScheduledExecutorService getBackgroundExecutor() { + if (backgroundExecutor == null) { + backgroundExecutor = + MoreExecutors.listeningDecorator( + Executors.newScheduledThreadPool(/* maxConcurrency= */ CONCURRENCY)); + } + return backgroundExecutor; + } +} diff --git a/thread/demoapp/res/drawable/ic_launcher_foreground.xml b/thread/demoapp/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..4dd8163f22 --- /dev/null +++ b/thread/demoapp/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/thread/demoapp/res/drawable/ic_menu_24dp.xml b/thread/demoapp/res/drawable/ic_menu_24dp.xml new file mode 100644 index 0000000000..8a4cf80ada --- /dev/null +++ b/thread/demoapp/res/drawable/ic_menu_24dp.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/thread/demoapp/res/drawable/ic_thread_wordmark.xml b/thread/demoapp/res/drawable/ic_thread_wordmark.xml new file mode 100644 index 0000000000..babaf54598 --- /dev/null +++ b/thread/demoapp/res/drawable/ic_thread_wordmark.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + diff --git a/thread/demoapp/res/layout/connectivity_tools_fragment.xml b/thread/demoapp/res/layout/connectivity_tools_fragment.xml new file mode 100644 index 0000000000..a1aa0d4815 --- /dev/null +++ b/thread/demoapp/res/layout/connectivity_tools_fragment.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + +