[Thread] Add support for Thread persistent setting

The ThreadPersistentSetting class can be used to read/store key value
pairs in ThreadPersistentSetting.xml file.

Bug: 299243765

Test: atest ThreadNetworkUnitTests:com.android.server.thread.ThreadPersistentSettingTest
Change-Id: I564ce8373e6af8f5cdf066a579bec46bfffecb72
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
new file mode 100644
index 0000000..d32f0bf
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2024 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.server.thread;
+
+import android.annotation.Nullable;
+import android.os.PersistableBundle;
+import android.util.AtomicFile;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Store persistent data for Thread network settings. These are key (string) / value pairs that are
+ * stored in ThreadPersistentSetting.xml file. The values allowed are those that can be serialized
+ * via {@link PersistableBundle}.
+ */
+public class ThreadPersistentSettings {
+    private static final String TAG = "ThreadPersistentSettings";
+    /** File name used for storing settings. */
+    public static final String FILE_NAME = "ThreadPersistentSettings.xml";
+    /** Current config store data version. This will be incremented for any additions. */
+    private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
+    /**
+     * Stores the version of the data. This can be used to handle migration of data if some
+     * non-backward compatible change introduced.
+     */
+    private static final String VERSION_KEY = "version";
+
+    /******** Thread persistent setting keys ***************/
+    /** Stores the Thread feature toggle state, true for enabled and false for disabled. */
+    public static final Key<Boolean> THREAD_ENABLED = new Key<>("Thread_enabled", true);
+
+    /******** Thread persistent setting keys ***************/
+
+    @GuardedBy("mLock")
+    private final AtomicFile mAtomicFile;
+
+    private final Object mLock = new Object();
+
+    @GuardedBy("mLock")
+    private final PersistableBundle mSettings = new PersistableBundle();
+
+    public ThreadPersistentSettings(AtomicFile atomicFile) {
+        mAtomicFile = atomicFile;
+    }
+
+    /** Initialize the settings by reading from the settings file. */
+    public void initialize() {
+        readFromStoreFile();
+        synchronized (mLock) {
+            if (mSettings.isEmpty()) {
+                put(THREAD_ENABLED.key, THREAD_ENABLED.defaultValue);
+            }
+        }
+    }
+
+    private void putObject(String key, @Nullable Object value) {
+        synchronized (mLock) {
+            if (value == null) {
+                mSettings.putString(key, null);
+            } else if (value instanceof Boolean) {
+                mSettings.putBoolean(key, (Boolean) value);
+            } else if (value instanceof Integer) {
+                mSettings.putInt(key, (Integer) value);
+            } else if (value instanceof Long) {
+                mSettings.putLong(key, (Long) value);
+            } else if (value instanceof Double) {
+                mSettings.putDouble(key, (Double) value);
+            } else if (value instanceof String) {
+                mSettings.putString(key, (String) value);
+            } else {
+                throw new IllegalArgumentException("Unsupported type " + value.getClass());
+            }
+        }
+    }
+
+    private <T> T getObject(String key, T defaultValue) {
+        Object value;
+        synchronized (mLock) {
+            if (defaultValue instanceof Boolean) {
+                value = mSettings.getBoolean(key, (Boolean) defaultValue);
+            } else if (defaultValue instanceof Integer) {
+                value = mSettings.getInt(key, (Integer) defaultValue);
+            } else if (defaultValue instanceof Long) {
+                value = mSettings.getLong(key, (Long) defaultValue);
+            } else if (defaultValue instanceof Double) {
+                value = mSettings.getDouble(key, (Double) defaultValue);
+            } else if (defaultValue instanceof String) {
+                value = mSettings.getString(key, (String) defaultValue);
+            } else {
+                throw new IllegalArgumentException("Unsupported type " + defaultValue.getClass());
+            }
+        }
+        return (T) value;
+    }
+
+    /**
+     * Store a value to the stored settings.
+     *
+     * @param key One of the settings keys.
+     * @param value Value to be stored.
+     */
+    public <T> void put(String key, @Nullable T value) {
+        putObject(key, value);
+        writeToStoreFile();
+    }
+
+    /**
+     * Retrieve a value from the stored settings.
+     *
+     * @param key One of the settings keys.
+     * @return value stored in settings, defValue if the key does not exist.
+     */
+    public <T> T get(Key<T> key) {
+        return getObject(key.key, key.defaultValue);
+    }
+
+    /**
+     * Base class to store string key and its default value.
+     *
+     * @param <T> Type of the value.
+     */
+    public static class Key<T> {
+        public final String key;
+        public final T defaultValue;
+
+        private Key(String key, T defaultValue) {
+            this.key = key;
+            this.defaultValue = defaultValue;
+        }
+
+        @Override
+        public String toString() {
+            return "[Key: " + key + ", DefaultValue: " + defaultValue + "]";
+        }
+    }
+
+    private void writeToStoreFile() {
+        try {
+            final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            final PersistableBundle bundleToWrite;
+            synchronized (mLock) {
+                bundleToWrite = new PersistableBundle(mSettings);
+            }
+            bundleToWrite.putInt(VERSION_KEY, CURRENT_SETTINGS_STORE_DATA_VERSION);
+            bundleToWrite.writeToStream(outputStream);
+            synchronized (mLock) {
+                writeToAtomicFile(mAtomicFile, outputStream.toByteArray());
+            }
+        } catch (IOException e) {
+            Log.wtf(TAG, "Write to store file failed", e);
+        }
+    }
+
+    private void readFromStoreFile() {
+        try {
+            final byte[] readData;
+            synchronized (mLock) {
+                Log.i(TAG, "Reading from store file: " + mAtomicFile.getBaseFile());
+                readData = readFromAtomicFile(mAtomicFile);
+            }
+            final ByteArrayInputStream inputStream = new ByteArrayInputStream(readData);
+            final PersistableBundle bundleRead = PersistableBundle.readFromStream(inputStream);
+            // Version unused for now. May be needed in the future for handling migrations.
+            bundleRead.remove(VERSION_KEY);
+            synchronized (mLock) {
+                mSettings.putAll(bundleRead);
+            }
+        } catch (FileNotFoundException e) {
+            Log.e(TAG, "No store file to read", e);
+        } catch (IOException e) {
+            Log.e(TAG, "Read from store file failed", e);
+        }
+    }
+
+    /**
+     * Read raw data from the atomic file. Note: This is a copy of {@link AtomicFile#readFully()}
+     * modified to use the passed in {@link InputStream} which was returned using {@link
+     * AtomicFile#openRead()}.
+     */
+    private static byte[] readFromAtomicFile(AtomicFile file) throws IOException {
+        FileInputStream stream = null;
+        try {
+            stream = file.openRead();
+            int pos = 0;
+            int avail = stream.available();
+            byte[] data = new byte[avail];
+            while (true) {
+                int amt = stream.read(data, pos, data.length - pos);
+                if (amt <= 0) {
+                    return data;
+                }
+                pos += amt;
+                avail = stream.available();
+                if (avail > data.length - pos) {
+                    byte[] newData = new byte[pos + avail];
+                    System.arraycopy(data, 0, newData, 0, pos);
+                    data = newData;
+                }
+            }
+        } finally {
+            if (stream != null) stream.close();
+        }
+    }
+
+    /** Write the raw data to the atomic file. */
+    private static void writeToAtomicFile(AtomicFile file, byte[] data) throws IOException {
+        // Write the data to the atomic file.
+        FileOutputStream out = null;
+        try {
+            out = file.startWrite();
+            out.write(data);
+            file.finishWrite(out);
+        } catch (IOException e) {
+            if (out != null) {
+                file.failWrite(out);
+            }
+            throw e;
+        }
+    }
+}
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index 8092693..5116db5 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -40,6 +40,7 @@
         "mockito-target-minus-junit4",
         "net-tests-utils",
         "truth",
+        "service-thread-pre-jarjar",
     ],
     libs: [
         "android.test.base",
diff --git a/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
new file mode 100644
index 0000000..11aabb8
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2024 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.server.thread;
+
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.validateMockitoUsage;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.os.PersistableBundle;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.AtomicFile;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+
+/** Unit tests for {@link ThreadPersistentSettings}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ThreadPersistentSettingsTest {
+    @Mock private AtomicFile mAtomicFile;
+
+    private ThreadPersistentSettings mThreadPersistentSetting;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        FileOutputStream fos = mock(FileOutputStream.class);
+        when(mAtomicFile.startWrite()).thenReturn(fos);
+        mThreadPersistentSetting = new ThreadPersistentSettings(mAtomicFile);
+    }
+
+    /** Called after each test */
+    @After
+    public void tearDown() {
+        validateMockitoUsage();
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
+        mThreadPersistentSetting.put(THREAD_ENABLED.key, true);
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isTrue();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
+        mThreadPersistentSetting.put(THREAD_ENABLED.key, false);
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void initialize_readsFromFile() throws Exception {
+        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
+        setupAtomicFileMockForRead(data);
+
+        // Trigger file read.
+        mThreadPersistentSetting.initialize();
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
+        verify(mAtomicFile, never()).startWrite();
+    }
+
+    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
+        PersistableBundle bundle = new PersistableBundle();
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        bundle.putBoolean(key, value);
+        bundle.writeToStream(outputStream);
+        return outputStream.toByteArray();
+    }
+
+    private void setupAtomicFileMockForRead(byte[] dataToRead) throws Exception {
+        FileInputStream is = mock(FileInputStream.class);
+        when(mAtomicFile.openRead()).thenReturn(is);
+        when(is.available()).thenReturn(dataToRead.length).thenReturn(0);
+        doAnswer(
+                        invocation -> {
+                            byte[] data = invocation.getArgument(0);
+                            int pos = invocation.getArgument(1);
+                            if (pos == dataToRead.length) return 0; // read complete.
+                            System.arraycopy(dataToRead, 0, data, 0, dataToRead.length);
+                            return dataToRead.length;
+                        })
+                .when(is)
+                .read(any(), anyInt(), anyInt());
+    }
+}