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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thread/demoapp/res/layout/list_server_ip_address_view.xml b/thread/demoapp/res/layout/list_server_ip_address_view.xml
new file mode 100644
index 0000000000..1a8f02e009
--- /dev/null
+++ b/thread/demoapp/res/layout/list_server_ip_address_view.xml
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/thread/demoapp/res/layout/list_server_port_view.xml b/thread/demoapp/res/layout/list_server_port_view.xml
new file mode 100644
index 0000000000..1a8f02e009
--- /dev/null
+++ b/thread/demoapp/res/layout/list_server_port_view.xml
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/thread/demoapp/res/layout/main_activity.xml b/thread/demoapp/res/layout/main_activity.xml
new file mode 100644
index 0000000000..12072e5242
--- /dev/null
+++ b/thread/demoapp/res/layout/main_activity.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thread/demoapp/res/layout/nav_header.xml b/thread/demoapp/res/layout/nav_header.xml
new file mode 100644
index 0000000000..b91fb9c942
--- /dev/null
+++ b/thread/demoapp/res/layout/nav_header.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/thread/demoapp/res/layout/thread_network_settings_fragment.xml b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
new file mode 100644
index 0000000000..cae46a3a1e
--- /dev/null
+++ b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/thread/demoapp/res/menu/nav_menu.xml b/thread/demoapp/res/menu/nav_menu.xml
new file mode 100644
index 0000000000..8d036c27b9
--- /dev/null
+++ b/thread/demoapp/res/menu/nav_menu.xml
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher.xml b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..b111e91d0f
--- /dev/null
+++ b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher_round.xml b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..b111e91d0f
--- /dev/null
+++ b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/thread/demoapp/res/mipmap-hdpi/ic_launcher.png b/thread/demoapp/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..94e778f40b
Binary files /dev/null and b/thread/demoapp/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/thread/demoapp/res/mipmap-hdpi/ic_launcher_round.png b/thread/demoapp/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..074a6718b5
Binary files /dev/null and b/thread/demoapp/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/thread/demoapp/res/mipmap-mdpi/ic_launcher.png b/thread/demoapp/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..674e51fc47
Binary files /dev/null and b/thread/demoapp/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/thread/demoapp/res/mipmap-mdpi/ic_launcher_round.png b/thread/demoapp/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..4e35c29034
Binary files /dev/null and b/thread/demoapp/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/thread/demoapp/res/mipmap-xhdpi/ic_launcher.png b/thread/demoapp/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2ee5d92698
Binary files /dev/null and b/thread/demoapp/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/thread/demoapp/res/mipmap-xhdpi/ic_launcher_round.png b/thread/demoapp/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..78a3b7d8d0
Binary files /dev/null and b/thread/demoapp/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/thread/demoapp/res/mipmap-xxhdpi/ic_launcher.png b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..ffb6261a71
Binary files /dev/null and b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/thread/demoapp/res/mipmap-xxhdpi/ic_launcher_round.png b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..80fa0378da
Binary files /dev/null and b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher.png b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..5ca1bfe012
Binary files /dev/null and b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher_round.png b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000000..2fd92e35e1
Binary files /dev/null and b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/thread/demoapp/res/navigation/nav_graph.xml b/thread/demoapp/res/navigation/nav_graph.xml
new file mode 100644
index 0000000000..472d1bb7ac
--- /dev/null
+++ b/thread/demoapp/res/navigation/nav_graph.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/thread/demoapp/res/values/colors.xml b/thread/demoapp/res/values/colors.xml
new file mode 100644
index 0000000000..6a65937dff
--- /dev/null
+++ b/thread/demoapp/res/values/colors.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/thread/demoapp/res/values/dimens.xml b/thread/demoapp/res/values/dimens.xml
new file mode 100644
index 0000000000..51659519ad
--- /dev/null
+++ b/thread/demoapp/res/values/dimens.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+ 16dp
+ 16dp
+ 8dp
+ 176dp
+ 16dp
+
diff --git a/thread/demoapp/res/values/themes.xml b/thread/demoapp/res/values/themes.xml
new file mode 100644
index 0000000000..9cb340322f
--- /dev/null
+++ b/thread/demoapp/res/values/themes.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+