Merge "[Thread] add demoapp for Thread network APIs" into main
diff --git a/thread/demoapp/Android.bp b/thread/demoapp/Android.bp
new file mode 100644
index 0000000..da7a5f8
--- /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 0000000..c31bb71
--- /dev/null
+++ b/thread/demoapp/AndroidManifest.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.threadnetwork.demoapp">
+
+    <uses-sdk android:minSdkVersion="34" android:targetSdkVersion="35"/>
+    <uses-feature android:name="android.hardware.threadnetwork" android:required="true" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.THREAD_NETWORK_PRIVILEGED" />
+
+    <application
+        android:label="ThreadNetworkDemoApp"
+        android:theme="@style/Theme.ThreadNetworkDemoApp"
+        android:icon="@mipmap/ic_launcher"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:testOnly="true">
+        <activity android:name=".MainActivity" android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
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 0000000..6f616eb
--- /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<String> mServerIpCandidates = new ArrayList<>();
+    private final ArrayList<String> mServerPortCandidates = new ArrayList<>();
+    private Executor mMainExecutor;
+
+    private ListenableFuture<String> mPingFuture;
+    private ListenableFuture<String> mUdpFuture;
+    private ArrayAdapter<String> mPingServerIpAdapter;
+    private ArrayAdapter<String> mUdpServerIpAdapter;
+    private ArrayAdapter<String> 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<String>(
+                        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<String>() {
+                                @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<String>(
+                        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<String>(
+                        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<String>() {
+                                @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<String> 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<String> 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 0000000..ef97a6c
--- /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 0000000..e95feaf
--- /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<Void, ThreadNetworkException>() {
+                    @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<Void, ThreadNetworkException>() {
+                    @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 0000000..d05ba73
--- /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 0000000..4dd8163
--- /dev/null
+++ b/thread/demoapp/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+  <group android:scaleX="0.0612"
+      android:scaleY="0.0612"
+      android:translateX="23.4"
+      android:translateY="23.683332">
+    <path
+        android:pathData="M0,0h1000v1000h-1000z"
+        android:fillColor="#00FFCEC7"/>
+    <path
+        android:pathData="m630.6,954.5l-113.5,0l0,-567.2l-170.5,0c-50.6,0 -92,41.2 -92,91.9c0,50.6 41.4,91.8 92,91.8l0,113.5c-113.3,0 -205.5,-92.1 -205.5,-205.4c0,-113.3 92.2,-205.5 205.5,-205.5l170.5,0l0,-57.5c0,-94.2 76.7,-171 171.1,-171c94.2,0 170.8,76.7 170.8,171c0,94.2 -76.6,171 -170.8,171l-57.6,0l0,567.2zM630.6,273.9l57.6,0c31.7,0 57.3,-25.8 57.3,-57.5c0,-31.7 -25.7,-57.5 -57.3,-57.5c-31.8,0 -57.6,25.8 -57.6,57.5l0,57.5z"
+        android:strokeLineJoin="miter"
+        android:strokeWidth="0"
+        android:fillColor="#000000"
+        android:fillType="nonZero"
+        android:strokeColor="#00000000"
+        android:strokeLineCap="butt"/>
+  </group>
+</vector>
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 0000000..8a4cf80
--- /dev/null
+++ b/thread/demoapp/res/drawable/ic_menu_24dp.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24.0dp"
+        android:height="24.0dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?android:attr/colorControlNormal">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M3.0,18.0l18.0,0.0l0.0,-2.0L3.0,16.0l0.0,2.0zm0.0,-5.0l18.0,0.0l0.0,-2.0L3.0,11.0l0.0,2.0zm0.0,-7.0l0.0,2.0l18.0,0.0L21.0,6.0L3.0,6.0z"/>
+</vector>
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 0000000..babaf54
--- /dev/null
+++ b/thread/demoapp/res/drawable/ic_thread_wordmark.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="167dp"
+    android:height="31dp"
+    android:viewportWidth="167"
+    android:viewportHeight="31">
+  <path
+      android:pathData="m32.413,7.977 l3.806,0 0,9.561 11.48,0 0,-9.561 3.837,0 0,22.957 -3.837,0 0,-9.558 -11.48,0 0,9.558 -3.806,0 0,-22.957z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="m76.761,30.934 l-4.432,-7.641 -6.483,0 0,7.641 -3.807,0 0,-22.957 11.48,0c2.095,0 3.894,0.75 5.392,2.246 1.501,1.504 2.249,3.298 2.249,5.392 0,1.591 -0.453,3.034 -1.356,4.335 -0.885,1.279 -2.006,2.193 -3.376,2.747l4.732,8.236 -4.4,0zM73.519,11.812l-7.673,0 0,7.645 7.673,0c1.034,0 1.928,-0.379 2.678,-1.124 0.75,-0.752 1.126,-1.657 1.126,-2.717 0,-1.034 -0.376,-1.926 -1.126,-2.678C75.448,12.188 74.554,11.812 73.519,11.812Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="m106.945,7.977 l0,3.835 -11.478,0 0,5.757 11.478,0 0,3.807 -11.478,0 0,5.722 11.478,0 0,3.836 -15.277,0 0,-22.957 15.277,0z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="m132.325,27.08 l-10.586,0 -1.958,3.854 -4.283,0 11.517,-23.519 11.522,23.519 -4.283,0 -1.928,-3.854zM123.627,23.267 L130.404,23.267 127.014,16.013 123.627,23.267z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="m146.606,7.977 l7.638,0c1.569,0 3.044,0.304 4.436,0.907 1.387,0.609 2.608,1.437 3.656,2.485 1.047,1.047 1.869,2.266 2.479,3.653 0.609,1.391 0.909,2.866 0.909,4.435 0,1.563 -0.299,3.041 -0.909,4.432 -0.61,1.391 -1.425,2.608 -2.464,3.654 -1.037,1.05 -2.256,1.874 -3.656,2.48 -1.401,0.607 -2.882,0.91 -4.451,0.91l-7.638,0 0,-22.956zM154.244,27.098c1.06,0 2.054,-0.199 2.978,-0.599 0.925,-0.394 1.737,-0.945 2.432,-1.654 0.696,-0.702 1.241,-1.521 1.638,-2.446 0.397,-0.925 0.597,-1.907 0.597,-2.942 0,-1.037 -0.201,-2.02 -0.597,-2.948 -0.397,-0.925 -0.946,-1.737 -1.651,-2.447 -0.709,-0.703 -1.524,-1.256 -2.45,-1.653 -0.925,-0.397 -1.907,-0.597 -2.948,-0.597l-3.834,0 0,15.286 3.834,0z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="m16.491,30.934 l-3.828,0 0,-19.128 -5.749,0c-1.705,0 -3.102,1.391 -3.102,3.1 0,1.706 1.397,3.097 3.102,3.097l0,3.83c-3.821,0 -6.931,-3.106 -6.931,-6.926 0,-3.822 3.111,-6.929 6.931,-6.929l5.749,0 0,-1.938c0,-3.179 2.587,-5.766 5.77,-5.766 3.175,0 5.76,2.588 5.76,5.766 0,3.179 -2.584,5.766 -5.76,5.766l-1.942,0 0,19.128zM16.491,7.977 L18.433,7.977c1.069,0 1.934,-0.869 1.934,-1.938 0,-1.069 -0.865,-1.938 -1.934,-1.938 -1.072,0 -1.942,0.869 -1.942,1.938l0,1.938z"
+      android:fillColor="#ffffff"/>
+</vector>
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 0000000..a1aa0d4
--- /dev/null
+++ b/thread/demoapp/res/layout/connectivity_tools_fragment.xml
@@ -0,0 +1,137 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ConnectivityToolsFragment" >
+
+<LinearLayout
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="8dp"
+    android:paddingBottom="16dp"
+    android:orientation="vertical">
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/ping_server_ip_address_layout"
+        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:hint="Server IP Address">
+        <AutoCompleteTextView
+            android:id="@+id/ping_server_ip_address_text"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="fdde:ad00:beef::ff:fe00:7400"
+            android:textSize="14sp"/>
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <Button
+        android:id="@+id/ping_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:text="Ping"
+        android:textSize="20dp"/>
+
+    <TextView
+        android:id="@+id/ping_output_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:scrollbars="vertical"
+        android:textIsSelectable="true"/>
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="16dp"
+        android:orientation="horizontal" >
+
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/udp_server_ip_address_layout"
+            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:hint="Server IP Address">
+            <AutoCompleteTextView
+                android:id="@+id/udp_server_ip_address_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="fdde:ad00:beef::ff:fe00:7400"
+                android:textSize="14sp"/>
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/udp_server_port_layout"
+            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="2dp"
+            android:hint="Server Port">
+            <AutoCompleteTextView
+                android:id="@+id/udp_server_port_text"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:inputType="number"
+                android:text="12345"
+                android:textSize="14sp"/>
+        </com.google.android.material.textfield.TextInputLayout>
+    </LinearLayout>
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:id="@+id/udp_message_layout"
+        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:hint="UDP Message">
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/udp_message_text"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Hello Thread!"
+            android:textSize="14sp"/>
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <com.google.android.material.switchmaterial.SwitchMaterial
+        android:id="@+id/switch_bind_thread_network"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:checked="true"
+        android:text="Bind to Thread network" />
+
+    <Button
+        android:id="@+id/send_udp_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:text="Send UDP Message"
+        android:textSize="20dp"/>
+
+    <TextView
+        android:id="@+id/udp_output_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:scrollbars="vertical"
+        android:textIsSelectable="true"/>
+</LinearLayout>
+</ScrollView>
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 0000000..1a8f02e
--- /dev/null
+++ b/thread/demoapp/res/layout/list_server_ip_address_view.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<TextView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="8dp"
+    android:ellipsize="end"
+    android:maxLines="1"
+    android:textAppearance="?attr/textAppearanceBody2"
+    />
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 0000000..1a8f02e
--- /dev/null
+++ b/thread/demoapp/res/layout/list_server_port_view.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<TextView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:padding="8dp"
+    android:ellipsize="end"
+    android:maxLines="1"
+    android:textAppearance="?attr/textAppearanceBody2"
+    />
diff --git a/thread/demoapp/res/layout/main_activity.xml b/thread/demoapp/res/layout/main_activity.xml
new file mode 100644
index 0000000..12072e5
--- /dev/null
+++ b/thread/demoapp/res/layout/main_activity.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<androidx.drawerlayout.widget.DrawerLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/drawer_layout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".MainActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <com.google.android.material.appbar.AppBarLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <com.google.android.material.appbar.MaterialToolbar
+                android:id="@+id/top_app_bar"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                app:navigationIcon="@drawable/ic_menu_24dp" />
+
+        </com.google.android.material.appbar.AppBarLayout>
+
+        <androidx.fragment.app.FragmentContainerView
+            android:id="@+id/nav_host_fragment"
+            android:name="androidx.navigation.fragment.NavHostFragment"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            app:defaultNavHost="true"
+            app:navGraph="@navigation/nav_graph" />
+
+    </LinearLayout>
+
+    <com.google.android.material.navigation.NavigationView
+        android:id="@+id/nav_view"
+        android:layout_width="wrap_content"
+        android:layout_height="match_parent"
+        android:padding="16dp"
+        android:layout_gravity="start"
+        android:fitsSystemWindows="true"
+        app:headerLayout="@layout/nav_header"
+        app:menu="@menu/nav_menu" />
+
+</androidx.drawerlayout.widget.DrawerLayout>
diff --git a/thread/demoapp/res/layout/nav_header.xml b/thread/demoapp/res/layout/nav_header.xml
new file mode 100644
index 0000000..b91fb9c
--- /dev/null
+++ b/thread/demoapp/res/layout/nav_header.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:gravity="bottom"
+    android:orientation="vertical" >
+
+  <ImageView
+      android:id="@+id/nav_header_image"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:paddingTop="@dimen/nav_header_vertical_spacing"
+      android:src="@drawable/ic_thread_wordmark" />
+</LinearLayout>
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 0000000..cae46a3
--- /dev/null
+++ b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:padding="8dp"
+    android:orientation="vertical"
+    tools:context=".ThreadNetworkSettingsFragment" >
+
+    <Button android:id="@+id/button_join_network"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="Join Network" />
+    <Button android:id="@+id/button_leave_network"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="Leave Network" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="16dp"
+        android:textStyle="bold"
+        android:text="State" />
+    <TextView
+        android:id="@+id/text_state"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="12dp"
+        android:typeface="monospace" />
+
+    <TextView
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="10dp"
+        android:textSize="16dp"
+        android:textStyle="bold"
+        android:text="Network Info" />
+    <TextView
+        android:id="@+id/text_network_info"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="12dp" />
+
+    <Button android:id="@+id/button_migrate_network"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="Migrate Network" />
+    <TextView
+        android:id="@+id/text_migrate_network_state"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="12dp" />
+</LinearLayout>
diff --git a/thread/demoapp/res/menu/nav_menu.xml b/thread/demoapp/res/menu/nav_menu.xml
new file mode 100644
index 0000000..8d036c2
--- /dev/null
+++ b/thread/demoapp/res/menu/nav_menu.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item
+        android:id="@+id/thread_network_settings"
+        android:title="Thread Network Settings" />
+    <item
+        android:id="@+id/connectivity_tools"
+        android:title="Connectivity Tools" />
+</menu>
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 0000000..b111e91
--- /dev/null
+++ b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+    <background android:drawable="@color/white"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>
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 0000000..b111e91
--- /dev/null
+++ b/thread/demoapp/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+    <background android:drawable="@color/white"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>
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 0000000..94e778f
--- /dev/null
+++ b/thread/demoapp/res/mipmap-hdpi/ic_launcher.png
Binary files 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 0000000..074a671
--- /dev/null
+++ b/thread/demoapp/res/mipmap-hdpi/ic_launcher_round.png
Binary files 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 0000000..674e51f
--- /dev/null
+++ b/thread/demoapp/res/mipmap-mdpi/ic_launcher.png
Binary files 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 0000000..4e35c29
--- /dev/null
+++ b/thread/demoapp/res/mipmap-mdpi/ic_launcher_round.png
Binary files 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 0000000..2ee5d92
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xhdpi/ic_launcher.png
Binary files 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 0000000..78a3b7d
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xhdpi/ic_launcher_round.png
Binary files 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 0000000..ffb6261
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher.png
Binary files 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 0000000..80fa037
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xxhdpi/ic_launcher_round.png
Binary files 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 0000000..5ca1bfe
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher.png
Binary files 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 0000000..2fd92e3
--- /dev/null
+++ b/thread/demoapp/res/mipmap-xxxhdpi/ic_launcher_round.png
Binary files differ
diff --git a/thread/demoapp/res/navigation/nav_graph.xml b/thread/demoapp/res/navigation/nav_graph.xml
new file mode 100644
index 0000000..472d1bb
--- /dev/null
+++ b/thread/demoapp/res/navigation/nav_graph.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+            xmlns:tools="http://schemas.android.com/tools"
+            xmlns:app="http://schemas.android.com/apk/res-auto"
+            android:id="@+id/nav_graph"
+            app:startDestination="@+id/thread_network_settings" >
+    <fragment
+        android:id="@+id/thread_network_settings"
+        android:name=".ThreadNetworkSettingsFragment"
+        android:label="Thread Network Settings"
+        tools:layout="@layout/thread_network_settings_fragment">
+    </fragment>
+
+    <fragment
+        android:id="@+id/connectivity_tools"
+        android:name=".ConnectivityToolsFragment"
+        android:label="Connectivity Tools"
+        tools:layout="@layout/connectivity_tools_fragment">
+    </fragment>
+</navigation>
diff --git a/thread/demoapp/res/values/colors.xml b/thread/demoapp/res/values/colors.xml
new file mode 100644
index 0000000..6a65937
--- /dev/null
+++ b/thread/demoapp/res/values/colors.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<resources>
+    <color name="purple_200">#FFBB86FC</color>
+    <color name="purple_500">#FF6200EE</color>
+    <color name="purple_700">#FF3700B3</color>
+    <color name="teal_200">#FF03DAC5</color>
+    <color name="teal_700">#FF018786</color>
+    <color name="black">#FF000000</color>
+    <color name="white">#FFFFFFFF</color>
+</resources>
diff --git a/thread/demoapp/res/values/dimens.xml b/thread/demoapp/res/values/dimens.xml
new file mode 100644
index 0000000..5165951
--- /dev/null
+++ b/thread/demoapp/res/values/dimens.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<resources>
+  <!-- Default screen margins, per the Android Design guidelines. -->
+  <dimen name="activity_horizontal_margin">16dp</dimen>
+  <dimen name="activity_vertical_margin">16dp</dimen>
+  <dimen name="nav_header_vertical_spacing">8dp</dimen>
+  <dimen name="nav_header_height">176dp</dimen>
+  <dimen name="fab_margin">16dp</dimen>
+</resources>
diff --git a/thread/demoapp/res/values/themes.xml b/thread/demoapp/res/values/themes.xml
new file mode 100644
index 0000000..9cb3403
--- /dev/null
+++ b/thread/demoapp/res/values/themes.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <!-- Base application theme. -->
+    <style name="Theme.ThreadNetworkDemoApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
+        <!-- Primary brand color. -->
+        <item name="colorPrimary">@color/purple_500</item>
+        <item name="colorPrimaryVariant">@color/purple_700</item>
+        <item name="colorOnPrimary">@color/white</item>
+        <!-- Secondary brand color. -->
+        <item name="colorSecondary">@color/teal_200</item>
+        <item name="colorSecondaryVariant">@color/teal_700</item>
+        <item name="colorOnSecondary">@color/black</item>
+        <!-- Status bar color. -->
+        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
+        <!-- Customize your theme here. -->
+    </style>
+</resources>