From 95f40d0c55d00be3c20863b224cb58ff17dac2d3 Mon Sep 17 00:00:00 2001 From: Yang Sun Date: Wed, 3 Jan 2024 11:40:35 +0800 Subject: [PATCH] [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 --- .../thread/ThreadPersistentSettings.java | 243 ++++++++++++++++++ thread/tests/unit/Android.bp | 1 + .../thread/ThreadPersistentSettingsTest.java | 127 +++++++++ 3 files changed, 371 insertions(+) create mode 100644 thread/service/java/com/android/server/thread/ThreadPersistentSettings.java create mode 100644 thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java 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 0000000000..d32f0bf028 --- /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 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 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 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 get(Key key) { + return getObject(key.key, key.defaultValue); + } + + /** + * Base class to store string key and its default value. + * + * @param Type of the value. + */ + public static class Key { + 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 80926936dc..5116db53af 100644 --- a/thread/tests/unit/Android.bp +++ b/thread/tests/unit/Android.bp @@ -40,6 +40,7 @@ android_test { "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 0000000000..11aabb803e --- /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()); + } +}