Add binder implementation to support device config shell commands from the
command line.
Test: atest SettingsProviderTest:DeviceConfigServiceTest
Further tested manually from the command line
Bug:109919982
Bug:113101834
Change-Id: I62da11f8be9d24a2e304c10592d689ae007eb7ec
diff --git a/cmds/device_config/Android.mk b/cmds/device_config/Android.mk
new file mode 100644
index 0000000..4041e01
--- /dev/null
+++ b/cmds/device_config/Android.mk
@@ -0,0 +1,10 @@
+# Copyright 2018 The Android Open Source Project
+#
+LOCAL_PATH:= $(call my-dir)
+
+include $(CLEAR_VARS)
+LOCAL_MODULE := device_config
+LOCAL_SRC_FILES := device_config
+LOCAL_MODULE_CLASS := EXECUTABLES
+LOCAL_MODULE_TAGS := optional
+include $(BUILD_PREBUILT)
diff --git a/cmds/device_config/device_config b/cmds/device_config/device_config
new file mode 100755
index 0000000..a949bd5
--- /dev/null
+++ b/cmds/device_config/device_config
@@ -0,0 +1,2 @@
+#!/system/bin/sh
+cmd device_config "$@"
diff --git a/packages/SettingsProvider/src/com/android/providers/settings/DeviceConfigService.java b/packages/SettingsProvider/src/com/android/providers/settings/DeviceConfigService.java
new file mode 100644
index 0000000..3520918
--- /dev/null
+++ b/packages/SettingsProvider/src/com/android/providers/settings/DeviceConfigService.java
@@ -0,0 +1,355 @@
+/*
+ * Copyright (C) 2018 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.providers.settings;
+
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.app.ActivityManager;
+import android.content.IContentProvider;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.ResultReceiver;
+import android.os.ShellCallback;
+import android.os.ShellCommand;
+import android.provider.Settings;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Receives shell commands from the command line related to device config flags, and dispatches them
+ * to the SettingsProvider.
+ *
+ * @hide
+ */
+@SystemApi
+public final class DeviceConfigService extends Binder {
+ /**
+ * TODO(b/113100523): Move this to DeviceConfig.java when it is added, and expose it as a System
+ * API.
+ */
+ private static final Uri CONFIG_CONTENT_URI =
+ Uri.parse("content://" + Settings.AUTHORITY + "/config");
+
+ final SettingsProvider mProvider;
+
+ public DeviceConfigService(SettingsProvider provider) {
+ mProvider = provider;
+ }
+
+ @Override
+ public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
+ String[] args, ShellCallback callback, ResultReceiver resultReceiver) {
+ (new MyShellCommand(mProvider)).exec(this, in, out, err, args, callback, resultReceiver);
+ }
+
+ static final class MyShellCommand extends ShellCommand {
+ final SettingsProvider mProvider;
+
+ enum CommandVerb {
+ UNSPECIFIED,
+ GET,
+ PUT,
+ DELETE,
+ LIST,
+ RESET,
+ }
+
+ MyShellCommand(SettingsProvider provider) {
+ mProvider = provider;
+ }
+
+ @Override
+ public int onCommand(String cmd) {
+ if (cmd == null || "help".equals(cmd) || "-h".equals(cmd)) {
+ onHelp();
+ return -1;
+ }
+
+ final PrintWriter perr = getErrPrintWriter();
+ boolean isValid = false;
+ CommandVerb verb;
+ if ("get".equalsIgnoreCase(cmd)) {
+ verb = CommandVerb.GET;
+ } else if ("put".equalsIgnoreCase(cmd)) {
+ verb = CommandVerb.PUT;
+ } else if ("delete".equalsIgnoreCase(cmd)) {
+ verb = CommandVerb.DELETE;
+ } else if ("list".equalsIgnoreCase(cmd)) {
+ verb = CommandVerb.LIST;
+ if (peekNextArg() == null) {
+ isValid = true;
+ }
+ } else if ("reset".equalsIgnoreCase(cmd)) {
+ verb = CommandVerb.RESET;
+ } else {
+ // invalid
+ perr.println("Invalid command: " + cmd);
+ return -1;
+ }
+
+ int resetMode = -1;
+ boolean makeDefault = false;
+ String namespace = null;
+ String key = null;
+ String value = null;
+ String arg = null;
+ while ((arg = getNextArg()) != null) {
+ if (verb == CommandVerb.RESET) {
+ if (resetMode == -1) {
+ if ("untrusted_defaults".equalsIgnoreCase(arg)) {
+ resetMode = Settings.RESET_MODE_UNTRUSTED_DEFAULTS;
+ } else if ("untrusted_clear".equalsIgnoreCase(arg)) {
+ resetMode = Settings.RESET_MODE_UNTRUSTED_CHANGES;
+ } else if ("trusted_defaults".equalsIgnoreCase(arg)) {
+ resetMode = Settings.RESET_MODE_TRUSTED_DEFAULTS;
+ } else {
+ // invalid
+ perr.println("Invalid reset mode: " + arg);
+ return -1;
+ }
+ if (peekNextArg() == null) {
+ isValid = true;
+ }
+ } else {
+ namespace = arg;
+ if (peekNextArg() == null) {
+ isValid = true;
+ } else {
+ // invalid
+ perr.println("Too many arguments");
+ return -1;
+ }
+ }
+ } else if (namespace == null) {
+ namespace = arg;
+ if (verb == CommandVerb.LIST) {
+ if (peekNextArg() == null) {
+ isValid = true;
+ } else {
+ // invalid
+ perr.println("Too many arguments");
+ return -1;
+ }
+ }
+ } else if (key == null) {
+ key = arg;
+ if ((verb == CommandVerb.GET || verb == CommandVerb.DELETE)) {
+ if (peekNextArg() == null) {
+ isValid = true;
+ } else {
+ // invalid
+ perr.println("Too many arguments");
+ return -1;
+ }
+ }
+ } else if (value == null) {
+ value = arg;
+ if (verb == CommandVerb.PUT && peekNextArg() == null) {
+ isValid = true;
+ }
+ } else if ("default".equalsIgnoreCase(arg)) {
+ makeDefault = true;
+ if (verb == CommandVerb.PUT && peekNextArg() == null) {
+ isValid = true;
+ } else {
+ // invalid
+ perr.println("Too many arguments");
+ return -1;
+ }
+ }
+ }
+
+ if (!isValid) {
+ perr.println("Bad arguments");
+ return -1;
+ }
+
+ final IContentProvider iprovider = mProvider.getIContentProvider();
+ final PrintWriter pout = getOutPrintWriter();
+ switch (verb) {
+ case GET:
+ pout.println(get(iprovider, namespace, key));
+ break;
+ case PUT:
+ put(iprovider, namespace, key, value, makeDefault);
+ break;
+ case DELETE:
+ pout.println(delete(iprovider, namespace, key)
+ ? "Successfully deleted " + key + " from " + namespace
+ : "Failed to delete " + key + " from " + namespace);
+ break;
+ case LIST:
+ for (String line : list(iprovider, namespace)) {
+ pout.println(line);
+ }
+ break;
+ case RESET:
+ reset(iprovider, resetMode, namespace);
+ break;
+ default:
+ perr.println("Unspecified command");
+ return -1;
+ }
+ return 0;
+ }
+
+ @Override
+ public void onHelp() {
+ PrintWriter pw = getOutPrintWriter();
+ pw.println("Device Config (device_config) commands:");
+ pw.println(" help");
+ pw.println(" Print this help text.");
+ pw.println(" get NAMESPACE KEY");
+ pw.println(" Retrieve the current value of KEY from the given NAMESPACE.");
+ pw.println(" put NAMESPACE KEY VALUE [default]");
+ pw.println(" Change the contents of KEY to VALUE for the given NAMESPACE.");
+ pw.println(" {default} to set as the default value.");
+ pw.println(" delete NAMESPACE KEY");
+ pw.println(" Delete the entry for KEY for the given NAMESPACE.");
+ pw.println(" list [NAMESPACE]");
+ pw.println(" Print all keys and values defined, optionally for the given "
+ + "NAMESPACE.");
+ pw.println(" reset RESET_MODE [NAMESPACE]");
+ pw.println(" Reset all flag values, optionally for a NAMESPACE, according to "
+ + "RESET_MODE.");
+ pw.println(" RESET_MODE is one of {untrusted_defaults, untrusted_clear, "
+ + "trusted_defaults}");
+ pw.println(" NAMESPACE limits which flags are reset if provided, otherwise all "
+ + "flags are reset");
+ }
+
+ private String get(IContentProvider provider, String namespace, String key) {
+ String compositeKey = namespace + "/" + key;
+ String result = null;
+ try {
+ Bundle args = new Bundle();
+ args.putInt(Settings.CALL_METHOD_USER_KEY,
+ ActivityManager.getService().getCurrentUser().id);
+ Bundle b = provider.call(resolveCallingPackage(), Settings.CALL_METHOD_GET_CONFIG,
+ compositeKey, args);
+ if (b != null) {
+ result = b.getPairValue();
+ }
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failed in IPC", e);
+ }
+ return result;
+ }
+
+ private void put(IContentProvider provider, String namespace, String key, String value,
+ boolean makeDefault) {
+ String compositeKey = namespace + "/" + key;
+
+ try {
+ Bundle args = new Bundle();
+ args.putString(Settings.NameValueTable.VALUE, value);
+ args.putInt(Settings.CALL_METHOD_USER_KEY,
+ ActivityManager.getService().getCurrentUser().id);
+ if (makeDefault) {
+ args.putBoolean(Settings.CALL_METHOD_MAKE_DEFAULT_KEY, true);
+ }
+ provider.call(resolveCallingPackage(), Settings.CALL_METHOD_PUT_CONFIG,
+ compositeKey, args);
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failed in IPC", e);
+ }
+ }
+
+ private boolean delete(IContentProvider provider, String namespace, String key) {
+ String compositeKey = namespace + "/" + key;
+ boolean success;
+
+ try {
+ Bundle args = new Bundle();
+ args.putInt(Settings.CALL_METHOD_USER_KEY,
+ ActivityManager.getService().getCurrentUser().id);
+ Bundle b = provider.call(resolveCallingPackage(),
+ Settings.CALL_METHOD_DELETE_CONFIG, compositeKey, args);
+ success = (b != null && b.getInt(SettingsProvider.RESULT_ROWS_DELETED) == 1);
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failed in IPC", e);
+ }
+ return success;
+ }
+
+ private List<String> list(IContentProvider provider, @Nullable String namespace) {
+ final ArrayList<String> lines = new ArrayList<>();
+
+ try {
+ Bundle args = new Bundle();
+ args.putInt(Settings.CALL_METHOD_USER_KEY,
+ ActivityManager.getService().getCurrentUser().id);
+ if (namespace != null) {
+ args.putString(Settings.CALL_METHOD_PREFIX_KEY, namespace);
+ }
+ Bundle b = provider.call(resolveCallingPackage(),
+ Settings.CALL_METHOD_LIST_CONFIG, null, args);
+ if (b != null) {
+ Map<String, String> flagsToValues =
+ (HashMap) b.getSerializable(Settings.NameValueTable.VALUE);
+ for (String key : flagsToValues.keySet()) {
+ lines.add(key + "=" + flagsToValues.get(key));
+ }
+ }
+
+ Collections.sort(lines);
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failed in IPC", e);
+ }
+ return lines;
+ }
+
+ private void reset(IContentProvider provider, int resetMode, @Nullable String namespace) {
+ try {
+ Bundle args = new Bundle();
+ args.putInt(Settings.CALL_METHOD_USER_KEY,
+ ActivityManager.getService().getCurrentUser().id);
+ args.putInt(Settings.CALL_METHOD_RESET_MODE_KEY, resetMode);
+ args.putString(Settings.CALL_METHOD_PREFIX_KEY, namespace);
+ provider.call(
+ resolveCallingPackage(), Settings.CALL_METHOD_RESET_CONFIG, null, args);
+ } catch (RemoteException e) {
+ throw new RuntimeException("Failed in IPC", e);
+ }
+ }
+
+ private static String resolveCallingPackage() {
+ switch (Binder.getCallingUid()) {
+ case Process.ROOT_UID: {
+ return "root";
+ }
+
+ case Process.SHELL_UID: {
+ return "com.android.shell";
+ }
+
+ default: {
+ return null;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
index 140a5a3..424368d 100644
--- a/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
+++ b/packages/SettingsProvider/src/com/android/providers/settings/SettingsProvider.java
@@ -335,6 +335,7 @@
startWatchingUserRestrictionChanges();
});
ServiceManager.addService("settings", new SettingsService(this));
+ ServiceManager.addService("device_config", new DeviceConfigService(this));
return true;
}
diff --git a/packages/SettingsProvider/test/src/com/android/providers/settings/DeviceConfigServiceTest.java b/packages/SettingsProvider/test/src/com/android/providers/settings/DeviceConfigServiceTest.java
new file mode 100644
index 0000000..59de6a7e
--- /dev/null
+++ b/packages/SettingsProvider/test/src/com/android/providers/settings/DeviceConfigServiceTest.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2018 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.providers.settings;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNull;
+
+import static org.junit.Assert.assertNotNull;
+
+import android.content.ContentResolver;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.runner.AndroidJUnit4;
+
+import libcore.io.Streams;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Tests for {@link DeviceConfigService}.
+ */
+@RunWith(AndroidJUnit4.class)
+public class DeviceConfigServiceTest {
+ /**
+ * TODO(b/113100523): Move this to DeviceConfig.java when it is added, and expose it as a System
+ * API.
+ */
+ private static final Uri CONFIG_CONTENT_URI =
+ Uri.parse("content://" + Settings.AUTHORITY + "/config");
+ private static final String sNamespace = "namespace1";
+ private static final String sKey = "key1";
+ private static final String sValue = "value1";
+
+ private ContentResolver mContentResolver;
+
+ @Before
+ public void setUp() {
+ mContentResolver = InstrumentationRegistry.getContext().getContentResolver();
+ }
+
+ @After
+ public void cleanUp() {
+ deleteFromContentProvider(mContentResolver, sNamespace, sKey);
+ }
+
+ @Test
+ public void testPut() throws Exception {
+ final String newNamespace = "namespace2";
+ final String newValue = "value2";
+
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertNull(result);
+
+ try {
+ executeShellCommand("device_config put " + sNamespace + " " + sKey + " " + sValue);
+ executeShellCommand("device_config put " + newNamespace + " " + sKey + " " + newValue);
+
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertEquals(sValue, result);
+ result = getFromContentProvider(mContentResolver, newNamespace, sKey);
+ assertEquals(newValue, result);
+ } finally {
+ deleteFromContentProvider(mContentResolver, newNamespace, sKey);
+ }
+ }
+
+ @Test
+ public void testPut_invalidArgs() throws Exception {
+ // missing sNamespace
+ executeShellCommand("device_config put " + sKey + " " + sValue);
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // still null
+ assertNull(result);
+
+ // too many arguments
+ executeShellCommand(
+ "device_config put " + sNamespace + " " + sKey + " " + sValue + " extra_arg");
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // still null
+ assertNull(result);
+ }
+
+ @Test
+ public void testDelete() throws Exception {
+ final String newNamespace = "namespace2";
+
+ putWithContentProvider(mContentResolver, sNamespace, sKey, sValue);
+ putWithContentProvider(mContentResolver, newNamespace, sKey, sValue);
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertEquals(sValue, result);
+ result = getFromContentProvider(mContentResolver, newNamespace, sKey);
+ assertEquals(sValue, result);
+
+ try {
+ executeShellCommand("device_config delete " + sNamespace + " " + sKey);
+ // sKey is deleted from sNamespace
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertNull(result);
+ // sKey is not deleted from newNamespace
+ result = getFromContentProvider(mContentResolver, newNamespace, sKey);
+ assertEquals(sValue, result);
+ } finally {
+ deleteFromContentProvider(mContentResolver, newNamespace, sKey);
+ }
+ }
+
+ @Test
+ public void testDelete_invalidArgs() throws Exception {
+ putWithContentProvider(mContentResolver, sNamespace, sKey, sValue);
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertEquals(sValue, result);
+
+ // missing sNamespace
+ executeShellCommand("device_config delete " + sKey);
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // sValue was not deleted
+ assertEquals(sValue, result);
+
+ // too many arguments
+ executeShellCommand("device_config delete " + sNamespace + " " + sKey + " extra_arg");
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // sValue was not deleted
+ assertEquals(sValue, result);
+ }
+
+ @Test
+ public void testReset_setUntrustedDefault() throws Exception {
+ String newValue = "value2";
+
+ // make sValue the untrusted default (set by root)
+ executeShellCommand(
+ "device_config put " + sNamespace + " " + sKey + " " + sValue + " default");
+ // make newValue the current value
+ executeShellCommand(
+ "device_config put " + sNamespace + " " + sKey + " " + newValue);
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertEquals(newValue, result);
+
+ executeShellCommand("device_config reset untrusted_defaults " + sNamespace);
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // back to the default
+ assertEquals(sValue, result);
+
+ executeShellCommand("device_config reset trusted_defaults " + sNamespace);
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // not trusted default was set
+ assertNull(result);
+ }
+
+ @Test
+ public void testReset_setTrustedDefault() throws Exception {
+ String newValue = "value2";
+
+ // make sValue the trusted default (set by system)
+ putWithContentProvider(mContentResolver, sNamespace, sKey, sValue, true);
+ // make newValue the current value
+ executeShellCommand(
+ "device_config put " + sNamespace + " " + sKey + " " + newValue);
+ String result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ assertEquals(newValue, result);
+
+ executeShellCommand("device_config reset untrusted_defaults " + sNamespace);
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // back to the default
+ assertEquals(sValue, result);
+
+ executeShellCommand("device_config reset trusted_defaults " + sNamespace);
+ result = getFromContentProvider(mContentResolver, sNamespace, sKey);
+ // our trusted default is still set
+ assertEquals(sValue, result);
+ }
+
+ private static void executeShellCommand(String command) throws IOException {
+ InputStream is = new FileInputStream(InstrumentationRegistry.getInstrumentation()
+ .getUiAutomation().executeShellCommand(command).getFileDescriptor());
+ Streams.readFully(is);
+ }
+
+ private static void putWithContentProvider(ContentResolver resolver, String namespace,
+ String key, String value) {
+ putWithContentProvider(resolver, namespace, key, value, false);
+ }
+
+ private static void putWithContentProvider(ContentResolver resolver, String namespace,
+ String key, String value, boolean makeDefault) {
+ String compositeName = namespace + "/" + key;
+ Bundle args = new Bundle();
+ args.putString(Settings.NameValueTable.VALUE, value);
+ if (makeDefault) {
+ args.putBoolean(Settings.CALL_METHOD_MAKE_DEFAULT_KEY, true);
+ }
+ resolver.call(
+ CONFIG_CONTENT_URI, Settings.CALL_METHOD_PUT_CONFIG, compositeName, args);
+ }
+
+ private static String getFromContentProvider(ContentResolver resolver, String namespace,
+ String key) {
+ String compositeName = namespace + "/" + key;
+ Bundle result = resolver.call(
+ CONFIG_CONTENT_URI, Settings.CALL_METHOD_GET_CONFIG, compositeName, null);
+ assertNotNull(result);
+ return result.getString(Settings.NameValueTable.VALUE);
+ }
+
+ private static boolean deleteFromContentProvider(ContentResolver resolver, String namespace,
+ String key) {
+ String compositeName = namespace + "/" + key;
+ Bundle result = resolver.call(
+ CONFIG_CONTENT_URI, Settings.CALL_METHOD_DELETE_CONFIG, compositeName, null);
+ assertNotNull(result);
+ return compositeName.equals(result.getString(Settings.NameValueTable.VALUE));
+ }
+}