diff options
author | 2023-07-12 19:32:11 +0000 | |
---|---|---|
committer | 2023-07-13 16:06:40 +0000 | |
commit | 0c72a321d288593386c6ae05572dd88541ef4dc3 (patch) | |
tree | 3d7667fa840cbcd9f7f8a011cb30b1725e19e20b | |
parent | 25846953ce0a976435972f928e94506538152998 (diff) |
Revert^2 "Define FeatureFlagsService."
This revert contains one small fix: The prior change included
changes in core/java/Android.bp and a corresponding line in
services/flags/Android.bp that causes problems in
StrictJavaPackagesTest
#testBootClasspathAndSystemServerClasspath_nonDuplicateClasses
Specifically resulting in this bug being filed: http://b/290929382
de960774a08274e68a4021e706f7611f0c0a880e
Bug: 279054964
Change-Id: Ia2ac95bb22144ff8be84c711604db72d039a814d
22 files changed, 1937 insertions, 0 deletions
diff --git a/core/java/android/content/Context.java b/core/java/android/content/Context.java index 6d82922484bc..2a6d84b1acc6 100644 --- a/core/java/android/content/Context.java +++ b/core/java/android/content/Context.java @@ -5298,6 +5298,13 @@ public abstract class Context { public static final String APP_PREDICTION_SERVICE = "app_prediction"; /** + * Used for reading system-wide, overridable flags. + * + * @hide + */ + public static final String FEATURE_FLAGS_SERVICE = "feature_flags"; + + /** * Official published name of the search ui service. * * <p><b>NOTE: </b> this service is optional; callers of diff --git a/core/java/android/flags/IFeatureFlags.aidl b/core/java/android/flags/IFeatureFlags.aidl new file mode 100644 index 000000000000..1eef47feaa8b --- /dev/null +++ b/core/java/android/flags/IFeatureFlags.aidl @@ -0,0 +1,65 @@ +/* + * 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 android.flags; + +import android.flags.IFeatureFlagsCallback; +import android.flags.SyncableFlag; + +/** + * Binder interface for communicating with {@link com.android.server.flags.FeatureFlagsService}. + * + * This interface is used by {@link android.flags.FeatureFlags} and developers should use that to + * interface with the service. FeatureFlags is the "client" in this documentation. + * + * The methods allow client apps to communicate what flags they care about, and receive back + * current values for those flags. For stable flags, this is the finalized value until the device + * restarts. For {@link DynamicFlag}s, this is the last known value, though it may change in the + * future. Clients can listen for changes to flag values so that it can react accordingly. + * @hide + */ +interface IFeatureFlags { + /** + * Synchronize with the {@link com.android.server.flags.FeatureFlagsService} about flags of + * interest. + * + * The client should pass in a list of flags that it is using as {@link SyncableFlag}s, which + * includes what it thinks the default values of the flags are. + * + * The response will contain a list of matching SyncableFlags, whose values are set to what the + * value of the flags actually are. The client should update its internal state flag data to + * match. + * + * Generally speaking, if a flag that is passed in is new to the FeatureFlagsService, the + * service will cache the passed-in value, and return it back out. If, however, a different + * client has synced that flag with the service previously, FeatureFlagsService will return the + * existing cached value, which may or may not be what the current client passed in. This allows + * FeatureFlagsService to keep clients in agreement with one another. + */ + List<SyncableFlag> syncFlags(in List<SyncableFlag> flagList); + + /** + * Pass in an {@link IFeatureFlagsCallback} that will be called whenever a {@link DymamicFlag} + * changes. + */ + void registerCallback(IFeatureFlagsCallback callback); + + /** + * Remove a {@link IFeatureFlagsCallback} that was previously registered with + * {@link #registerCallback}. + */ + void unregisterCallback(IFeatureFlagsCallback callback); +}
\ No newline at end of file diff --git a/core/java/android/flags/IFeatureFlagsCallback.aidl b/core/java/android/flags/IFeatureFlagsCallback.aidl new file mode 100644 index 000000000000..f708667ea4c4 --- /dev/null +++ b/core/java/android/flags/IFeatureFlagsCallback.aidl @@ -0,0 +1,31 @@ +/* + * 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 android.flags; + +import android.flags.SyncableFlag; + +/** + * Callback for {@link IFeatureFlags#registerCallback} to get alerts when a {@link DynamicFlag} + * changes. + * + * DynamicFlags can change at run time. Stable flags will never result in a call to this method. + * + * @hide + */ +oneway interface IFeatureFlagsCallback { + void onFlagChange(in SyncableFlag flag); +}
\ No newline at end of file diff --git a/core/java/android/flags/SyncableFlag.aidl b/core/java/android/flags/SyncableFlag.aidl new file mode 100644 index 000000000000..1526ec148967 --- /dev/null +++ b/core/java/android/flags/SyncableFlag.aidl @@ -0,0 +1,22 @@ +/* + * 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 android.flags; + +/** + * A parcelable data class for serializing {@link Flag} across a Binder. + */ +parcelable SyncableFlag;
\ No newline at end of file diff --git a/core/java/android/flags/SyncableFlag.java b/core/java/android/flags/SyncableFlag.java new file mode 100644 index 000000000000..54c4c6d5fdf9 --- /dev/null +++ b/core/java/android/flags/SyncableFlag.java @@ -0,0 +1,99 @@ +/* + * 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 android.flags; + +import android.annotation.NonNull; +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * @hide + */ +public final class SyncableFlag implements Parcelable { + private final String mNamespace; + private final String mName; + private String mValue; + private final boolean mDynamic; + + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) + public SyncableFlag( + @NonNull String namespace, + @NonNull String name, + @NonNull String value, + boolean dynamic) { + mNamespace = namespace; + mName = name; + mValue = value; + mDynamic = dynamic; + } + + public void setValue(@NonNull String value) { + mValue = value; + } + + @NonNull + public String getNamespace() { + return mNamespace; + } + + @NonNull + public String getName() { + return mName; + } + + @NonNull + public String getValue() { + return mValue; + } + + @NonNull + public boolean isDynamic() { + return mDynamic; + } + + @NonNull + public static final Parcelable.Creator<SyncableFlag> CREATOR = new Parcelable.Creator<>() { + public SyncableFlag createFromParcel(Parcel in) { + return new SyncableFlag( + in.readString(), in.readString(), in.readString(), in.readBoolean()); + } + + public SyncableFlag[] newArray(int size) { + return new SyncableFlag[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(mNamespace); + dest.writeString(mName); + dest.writeString(mValue); + dest.writeBoolean(mDynamic); + } + + @Override + public String toString() { + return getNamespace() + "." + getName() + "[" + getValue() + "]"; + } +} diff --git a/services/Android.bp b/services/Android.bp index b0a0e5e44a8c..453f57234145 100644 --- a/services/Android.bp +++ b/services/Android.bp @@ -159,6 +159,7 @@ java_library { "services.coverage", "services.credentials", "services.devicepolicy", + "services.flags", "services.midi", "services.musicsearch", "services.net", diff --git a/services/flags/Android.bp b/services/flags/Android.bp new file mode 100644 index 000000000000..2d0337dce74f --- /dev/null +++ b/services/flags/Android.bp @@ -0,0 +1,17 @@ +package { + // See: http://go/android-license-faq + // A large-scale-change added 'default_applicable_licenses' to import + // all of the 'license_kinds' from "frameworks_base_license" + // to get the below license kinds: + // SPDX-license-identifier-Apache-2.0 + default_applicable_licenses: ["frameworks_base_license"], +} + +java_library_static { + name: "services.flags", + defaults: ["platform_service_defaults"], + srcs: [ + "java/**/*.java", + ], + libs: ["services.core"], +} diff --git a/services/flags/OWNERS b/services/flags/OWNERS new file mode 100644 index 000000000000..3925b5c13c2d --- /dev/null +++ b/services/flags/OWNERS @@ -0,0 +1,6 @@ +# Bug component: 1306523 + +mankoff@google.com + +pixel@google.com +dsandler@android.com diff --git a/services/flags/java/com/android/server/flags/DynamicFlagBinderDelegate.java b/services/flags/java/com/android/server/flags/DynamicFlagBinderDelegate.java new file mode 100644 index 000000000000..322a52bf01cc --- /dev/null +++ b/services/flags/java/com/android/server/flags/DynamicFlagBinderDelegate.java @@ -0,0 +1,273 @@ +/* + * 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.server.flags; + +import android.annotation.NonNull; +import android.flags.IFeatureFlagsCallback; +import android.flags.SyncableFlag; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteException; +import android.provider.DeviceConfig; +import android.util.Slog; + +import com.android.internal.os.BackgroundThread; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * Handles DynamicFlags for {@link FeatureFlagsBinder}. + * + * Dynamic flags are simultaneously simpler and more complicated than process stable flags. We can + * return whatever value is last known for a flag is, without too much worry about the flags + * changing (they are dynamic after all). However, we have to alert all the relevant clients + * about those flag changes, and need to be able to restore to a default value if the flag gets + * reset/erased during runtime. + */ +class DynamicFlagBinderDelegate { + + private final FlagOverrideStore mFlagStore; + private final FlagCache<DynamicFlagData> mDynamicFlags = new FlagCache<>(); + private final Map<Integer, Set<IFeatureFlagsCallback>> mCallbacks = new HashMap<>(); + private static final Function<Integer, Set<IFeatureFlagsCallback>> NEW_CALLBACK_SET = + k -> new HashSet<>(); + + private final DeviceConfig.OnPropertiesChangedListener mDeviceConfigListener = + new DeviceConfig.OnPropertiesChangedListener() { + @Override + public void onPropertiesChanged(@NonNull DeviceConfig.Properties properties) { + String ns = properties.getNamespace(); + for (String name : properties.getKeyset()) { + // Don't alert for flags we don't care about. + // Don't alert for flags that have been overridden locally. + if (!mDynamicFlags.contains(ns, name) || mFlagStore.contains(ns, name)) { + continue; + } + mFlagChangeCallback.onFlagChanged( + ns, name, properties.getString(name, null)); + } + } + }; + + private final FlagOverrideStore.FlagChangeCallback mFlagChangeCallback = + (namespace, name, value) -> { + // Don't bother with callbacks for non-dynamic flags. + if (!mDynamicFlags.contains(namespace, name)) { + return; + } + + // Don't bother with callbacks if nothing changed. + // Handling erasure (null) is special, as we may be restoring back to a value + // we were already at. + DynamicFlagData data = mDynamicFlags.getOrNull(namespace, name); + if (data == null) { + return; // shouldn't happen, but better safe than sorry. + } + if (value == null) { + if (data.getValue().equals(data.getDefaultValue())) { + return; + } + value = data.getDefaultValue(); + } else if (data.getValue().equals(value)) { + return; + } + data.setValue(value); + + final Set<IFeatureFlagsCallback> cbCopy; + synchronized (mCallbacks) { + cbCopy = new HashSet<>(); + + for (Integer pid : mCallbacks.keySet()) { + if (data.containsPid(pid)) { + cbCopy.addAll(mCallbacks.get(pid)); + } + } + } + SyncableFlag sFlag = new SyncableFlag(namespace, name, value, true); + cbCopy.forEach(cb -> { + try { + cb.onFlagChange(sFlag); + } catch (RemoteException e) { + Slog.w( + FeatureFlagsService.TAG, + "Failed to communicate flag change to client."); + } + }); + }; + + DynamicFlagBinderDelegate(FlagOverrideStore flagStore) { + mFlagStore = flagStore; + mFlagStore.setChangeCallback(mFlagChangeCallback); + } + + void syncDynamicFlag(int pid, SyncableFlag sf) { + if (!sf.isDynamic()) { + return; + } + + String ns = sf.getNamespace(); + String name = sf.getName(); + + // Dynamic flags don't need any special threading or synchronization considerations. + // We simply give them whatever the current value is. + // However, we do need to keep track of dynamic flags, so that we can alert + // about changes coming in from adb, DeviceConfig, or other sources. + // And also so that we can keep flags relatively consistent across processes. + + // If we already have a value cached, just use that. + String value = null; + DynamicFlagData data = mDynamicFlags.getOrNull(ns, name); + if (data != null) { + value = data.getValue(); + } else { + // Put the value in the cache for future reference. + data = new DynamicFlagData(ns, name); + mDynamicFlags.setIfChanged(ns, name, data); + } + // If we're not in a release build, flags can be overridden locally on device. + if (!Build.IS_USER && value == null) { + value = mFlagStore.get(ns, name); + } + // If we still don't have a value, maybe DeviceConfig does? + // Fallback to sf.getValue() here as well. + if (value == null) { + value = DeviceConfig.getString(ns, name, sf.getValue()); + } + // DeviceConfig listeners are per-namespace. + if (!mDynamicFlags.containsNamespace(ns)) { + DeviceConfig.addOnPropertiesChangedListener( + ns, BackgroundThread.getExecutor(), mDeviceConfigListener); + } + data.addClientPid(pid); + data.setValue(value); + // Store the default value so that if an override gets erased, we can restore + // to something. + data.setDefaultValue(sf.getValue()); + + sf.setValue(value); + } + + + void registerCallback(int pid, IFeatureFlagsCallback callback) { + // Always add callback so that we don't end up with a possible race/leak. + // We remove the callback directly if we fail to call #linkToDeath. + // If we tried to add the callback after we linked, then we could end up in a + // scenario where we link, then the binder dies, firing our BinderGriever which tries + // to remove the callback (which has not yet been added), then finally we add the + // callback, creating a leak. + Set<IFeatureFlagsCallback> callbacks; + synchronized (mCallbacks) { + callbacks = mCallbacks.computeIfAbsent(pid, NEW_CALLBACK_SET); + callbacks.add(callback); + } + try { + callback.asBinder().linkToDeath(new BinderGriever(pid), 0); + } catch (RemoteException e) { + Slog.e( + FeatureFlagsService.TAG, + "Failed to link to binder death. Callback not registered."); + synchronized (mCallbacks) { + callbacks.remove(callback); + } + } + } + + void unregisterCallback(int pid, IFeatureFlagsCallback callback) { + // No need to unlink, since the BinderGriever will essentially be a no-op. + // We would have to track our BinderGriever's in a map otherwise. + synchronized (mCallbacks) { + Set<IFeatureFlagsCallback> callbacks = + mCallbacks.computeIfAbsent(pid, NEW_CALLBACK_SET); + callbacks.remove(callback); + } + } + + private static class DynamicFlagData { + private final String mNamespace; + private final String mName; + private final Set<Integer> mPids = new HashSet<>(); + private String mValue; + private String mDefaultValue; + + private DynamicFlagData(String namespace, String name) { + mNamespace = namespace; + mName = name; + } + + String getValue() { + return mValue; + } + + void setValue(String value) { + mValue = value; + } + + String getDefaultValue() { + return mDefaultValue; + } + + void setDefaultValue(String value) { + mDefaultValue = value; + } + + void addClientPid(int pid) { + mPids.add(pid); + } + + boolean containsPid(int pid) { + return mPids.contains(pid); + } + + @Override + public boolean equals(Object other) { + if (other == null || !(other instanceof DynamicFlagData)) { + return false; + } + + DynamicFlagData o = (DynamicFlagData) other; + + return mName.equals(o.mName) && mNamespace.equals(o.mNamespace) + && mValue.equals(o.mValue) && mDefaultValue.equals(o.mDefaultValue); + } + + @Override + public int hashCode() { + return mName.hashCode() + mNamespace.hashCode() + + mValue.hashCode() + mDefaultValue.hashCode(); + } + } + + + private class BinderGriever implements IBinder.DeathRecipient { + private final int mPid; + + private BinderGriever(int pid) { + mPid = pid; + } + + @Override + public void binderDied() { + synchronized (mCallbacks) { + mCallbacks.remove(mPid); + } + } + } +} diff --git a/services/flags/java/com/android/server/flags/FeatureFlagsBinder.java b/services/flags/java/com/android/server/flags/FeatureFlagsBinder.java new file mode 100644 index 000000000000..dc97fde758dc --- /dev/null +++ b/services/flags/java/com/android/server/flags/FeatureFlagsBinder.java @@ -0,0 +1,86 @@ +/* + * 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.server.flags; + +import android.annotation.NonNull; +import android.annotation.SystemApi; +import android.flags.IFeatureFlags; +import android.flags.IFeatureFlagsCallback; +import android.flags.SyncableFlag; +import android.os.Build; +import android.os.ParcelFileDescriptor; + +import java.io.FileOutputStream; +import java.util.List; + +class FeatureFlagsBinder extends IFeatureFlags.Stub { + private final FlagOverrideStore mFlagStore; + private final FlagsShellCommand mShellCommand; + private final FlagCache<String> mFlagCache = new FlagCache<>(); + private final DynamicFlagBinderDelegate mDynamicFlagDelegate; + + FeatureFlagsBinder(FlagOverrideStore flagStore, FlagsShellCommand shellCommand) { + mFlagStore = flagStore; + mShellCommand = shellCommand; + mDynamicFlagDelegate = new DynamicFlagBinderDelegate(flagStore); + } + + @Override + public void registerCallback(IFeatureFlagsCallback callback) { + mDynamicFlagDelegate.registerCallback(getCallingPid(), callback); + } + + @Override + public void unregisterCallback(IFeatureFlagsCallback callback) { + mDynamicFlagDelegate.unregisterCallback(getCallingPid(), callback); + } + + @Override + public List<SyncableFlag> syncFlags(List<SyncableFlag> incomingFlags) { + int pid = getCallingPid(); + for (SyncableFlag sf : incomingFlags) { + String ns = sf.getNamespace(); + String name = sf.getName(); + if (sf.isDynamic()) { + mDynamicFlagDelegate.syncDynamicFlag(pid, sf); + } else { + synchronized (mFlagCache) { + String value = mFlagCache.getOrNull(ns, name); + if (value == null) { + String overrideValue = Build.IS_USER ? null : mFlagStore.get(ns, name); + value = overrideValue != null ? overrideValue : sf.getValue(); + mFlagCache.setIfChanged(ns, name, value); + } + sf.setValue(value); + } + } + } + return incomingFlags; + } + + @SystemApi + public int handleShellCommand( + @NonNull ParcelFileDescriptor in, + @NonNull ParcelFileDescriptor out, + @NonNull ParcelFileDescriptor err, + @NonNull String[] args) { + FileOutputStream fout = new FileOutputStream(out.getFileDescriptor()); + FileOutputStream ferr = new FileOutputStream(err.getFileDescriptor()); + + return mShellCommand.process(args, fout, ferr); + } +} diff --git a/services/flags/java/com/android/server/flags/FeatureFlagsService.java b/services/flags/java/com/android/server/flags/FeatureFlagsService.java new file mode 100644 index 000000000000..111fad0ce3b0 --- /dev/null +++ b/services/flags/java/com/android/server/flags/FeatureFlagsService.java @@ -0,0 +1,59 @@ +/* + * 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.server.flags; + +import android.content.Context; +import android.util.Slog; + +import com.android.server.SystemService; + +/** + * A service that manages syncing {@link android.flags.FeatureFlags} across processes. + * + * This service holds flags stable for at least the lifetime of a process, meaning that if + * a process comes online with a flag set to true, any other process that connects here and + * tries to read the same flag will also receive the flag as true. The flag will remain stable + * until either all of the interested processes have died, or the device restarts. + * + * TODO(279054964): Add to dumpsys + * @hide + */ +public class FeatureFlagsService extends SystemService { + + static final String TAG = "FeatureFlagsService"; + private final FlagOverrideStore mFlagStore; + private final FlagsShellCommand mShellCommand; + + /** + * Initializes the system service. + * + * @param context The system server context. + */ + public FeatureFlagsService(Context context) { + super(context); + mFlagStore = new FlagOverrideStore( + new GlobalSettingsProxy(context.getContentResolver())); + mShellCommand = new FlagsShellCommand(mFlagStore); + } + + @Override + public void onStart() { + Slog.d(TAG, "Started Feature Flag Service"); + publishBinderService( + Context.FEATURE_FLAGS_SERVICE, new FeatureFlagsBinder(mFlagStore, mShellCommand)); + } + +} diff --git a/services/flags/java/com/android/server/flags/FlagCache.java b/services/flags/java/com/android/server/flags/FlagCache.java new file mode 100644 index 000000000000..cee1578a5dde --- /dev/null +++ b/services/flags/java/com/android/server/flags/FlagCache.java @@ -0,0 +1,103 @@ +/* + * 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.server.flags; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +/** + * Threadsafe cache of values that stores the supplied default on cache miss. + * + * @param <V> The type of value to store. + */ +public class FlagCache<V> { + private final Function<String, HashMap<String, V>> mNewHashMap = k -> new HashMap<>(); + + // Cache is organized first by namespace, then by name. All values are stored as strings. + final Map<String, Map<String, V>> mCache = new HashMap<>(); + + FlagCache() { + } + + /** + * Returns true if the namespace exists in the cache already. + */ + boolean containsNamespace(String namespace) { + synchronized (mCache) { + return mCache.containsKey(namespace); + } + } + + /** + * Returns true if the value is stored in the cache. + */ + boolean contains(String namespace, String name) { + synchronized (mCache) { + Map<String, V> nsCache = mCache.get(namespace); + return nsCache != null && nsCache.containsKey(name); + } + } + + /** + * Sets the value if it is different from what is currently stored. + * + * If the value is not set, or the current value is null, it will store the value and + * return true. + * + * @return True if the value was set. False if the value is the same. + */ + boolean setIfChanged(String namespace, String name, V value) { + synchronized (mCache) { + Map<String, V> nsCache = mCache.computeIfAbsent(namespace, mNewHashMap); + V curValue = nsCache.get(name); + if (curValue == null || !curValue.equals(value)) { + nsCache.put(name, value); + return true; + } + return false; + } + } + + /** + * Gets the current value from the cache, setting it if it is currently absent. + * + * @return The value that is now in the cache after the call to the method. + */ + V getOrSet(String namespace, String name, V defaultValue) { + synchronized (mCache) { + Map<String, V> nsCache = mCache.computeIfAbsent(namespace, mNewHashMap); + V value = nsCache.putIfAbsent(name, defaultValue); + return value == null ? defaultValue : value; + } + } + + /** + * Gets the current value from the cache, returning null if not present. + * + * @return The value that is now in the cache if there is one. + */ + V getOrNull(String namespace, String name) { + synchronized (mCache) { + Map<String, V> nsCache = mCache.get(namespace); + if (nsCache == null) { + return null; + } + return nsCache.get(name); + } + } +} diff --git a/services/flags/java/com/android/server/flags/FlagOverrideStore.java b/services/flags/java/com/android/server/flags/FlagOverrideStore.java new file mode 100644 index 000000000000..9866b1c7bb1c --- /dev/null +++ b/services/flags/java/com/android/server/flags/FlagOverrideStore.java @@ -0,0 +1,120 @@ +/* + * 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.server.flags; + +import android.database.Cursor; +import android.provider.Settings; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.HashMap; +import java.util.Map; + +/** + * Persistent storage for the {@link FeatureFlagsService}. + * + * The implementation stores data in Settings.<store> (generally {@link Settings.Global} + * is expected). + */ +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +public class FlagOverrideStore { + private static final String KEYNAME_PREFIX = "flag|"; + private static final String NAMESPACE_NAME_SEPARATOR = "."; + + private final SettingsProxy mSettingsProxy; + + private FlagChangeCallback mCallback; + + FlagOverrideStore(SettingsProxy settingsProxy) { + mSettingsProxy = settingsProxy; + } + + void setChangeCallback(FlagChangeCallback callback) { + mCallback = callback; + } + + /** Returns true if a non-null value is in the store. */ + boolean contains(String namespace, String name) { + return get(namespace, name) != null; + } + + /** Put a value in the store. */ + void set(String namespace, String name, String value) { + mSettingsProxy.putString(getPropName(namespace, name), value); + mCallback.onFlagChanged(namespace, name, value); + } + + /** Read a value out of the store. */ + @VisibleForTesting + public String get(String namespace, String name) { + return mSettingsProxy.getString(getPropName(namespace, name)); + } + + /** Erase a value from the store. */ + void erase(String namespace, String name) { + set(namespace, name, null); + } + + Map<String, Map<String, String>> getFlags() { + return getFlagsForNamespace(null); + } + + Map<String, Map<String, String>> getFlagsForNamespace(String namespace) { + Cursor c = mSettingsProxy.getContentResolver().query( + Settings.Global.CONTENT_URI, + new String[]{Settings.NameValueTable.NAME, Settings.NameValueTable.VALUE}, + null, // Doesn't support a "LIKE" query + null, + null + ); + + if (c == null) { + return Map.of(); + } + int keynamePrefixLength = KEYNAME_PREFIX.length(); + Map<String, Map<String, String>> results = new HashMap<>(); + while (c.moveToNext()) { + String key = c.getString(0); + if (!key.startsWith(KEYNAME_PREFIX) + || key.indexOf(NAMESPACE_NAME_SEPARATOR, keynamePrefixLength) < 0) { + continue; + } + String value = c.getString(1); + if (value == null || value.isEmpty()) { + continue; + } + String ns = key.substring(keynamePrefixLength, key.indexOf(NAMESPACE_NAME_SEPARATOR)); + if (namespace != null && !namespace.equals(ns)) { + continue; + } + String name = key.substring(key.indexOf(NAMESPACE_NAME_SEPARATOR) + 1); + results.putIfAbsent(ns, new HashMap<>()); + results.get(ns).put(name, value); + } + c.close(); + return results; + } + + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) + static String getPropName(String namespace, String name) { + return KEYNAME_PREFIX + namespace + NAMESPACE_NAME_SEPARATOR + name; + } + + interface FlagChangeCallback { + void onFlagChanged(String namespace, String name, String value); + } +} diff --git a/services/flags/java/com/android/server/flags/FlagsShellCommand.java b/services/flags/java/com/android/server/flags/FlagsShellCommand.java new file mode 100644 index 000000000000..b7896ee18714 --- /dev/null +++ b/services/flags/java/com/android/server/flags/FlagsShellCommand.java @@ -0,0 +1,214 @@ +/* + * 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.server.flags; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.FastPrintWriter; + +import java.io.OutputStream; +import java.io.PrintWriter; +import java.util.Locale; +import java.util.Map; + +/** + * Process command line input for the flags service. + */ +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) +public class FlagsShellCommand { + private final FlagOverrideStore mFlagStore; + + FlagsShellCommand(FlagOverrideStore flagStore) { + mFlagStore = flagStore; + } + + /** + * Interpret the command supplied in the constructor. + * + * @return Zero on success or non-zero on error. + */ + public int process( + String[] args, + OutputStream out, + OutputStream err) { + PrintWriter outPw = new FastPrintWriter(out); + PrintWriter errPw = new FastPrintWriter(err); + + if (args.length == 0) { + return printHelp(outPw); + } + switch (args[0].toLowerCase(Locale.ROOT)) { + case "help": + return printHelp(outPw); + case "list": + return listCmd(args, outPw, errPw); + case "set": + return setCmd(args, outPw, errPw); + case "get": + return getCmd(args, outPw, errPw); + case "erase": + return eraseCmd(args, outPw, errPw); + default: + return unknownCmd(outPw); + } + } + + private int printHelp(PrintWriter outPw) { + outPw.println("Feature Flags command, allowing listing, setting, getting, and erasing of"); + outPw.println("local flag overrides on a device."); + outPw.println(); + outPw.println("Commands:"); + outPw.println(" list [namespace]"); + outPw.println(" List all flag overrides. Namespace is optional."); + outPw.println(); + outPw.println(" get <namespace> <name>"); + outPw.println(" Return the string value of a specific flag, or <unset>"); + outPw.println(); + outPw.println(" set <namespace> <name> <value>"); + outPw.println(" Set a specific flag"); + outPw.println(); + outPw.println(" erase <namespace> <name>"); + outPw.println(" Unset a specific flag"); + outPw.flush(); + return 0; + } + + private int listCmd(String[] args, PrintWriter outPw, PrintWriter errPw) { + if (!validateNumArguments(args, 0, 1, args[0], errPw)) { + errPw.println("Expected `" + args[0] + " [namespace]`"); + errPw.flush(); + return -1; + } + Map<String, Map<String, String>> overrides; + if (args.length == 2) { + overrides = mFlagStore.getFlagsForNamespace(args[1]); + } else { + overrides = mFlagStore.getFlags(); + } + if (overrides.isEmpty()) { + outPw.println("No overrides set"); + } else { + int longestNamespaceLen = "namespace".length(); + int longestFlagLen = "flag".length(); + int longestValLen = "value".length(); + for (Map.Entry<String, Map<String, String>> namespace : overrides.entrySet()) { + longestNamespaceLen = Math.max(longestNamespaceLen, namespace.getKey().length()); + for (Map.Entry<String, String> flag : namespace.getValue().entrySet()) { + longestFlagLen = Math.max(longestFlagLen, flag.getKey().length()); + longestValLen = Math.max(longestValLen, flag.getValue().length()); + } + } + outPw.print(String.format("%-" + longestNamespaceLen + "s", "namespace")); + outPw.print(' '); + outPw.print(String.format("%-" + longestFlagLen + "s", "flag")); + outPw.print(' '); + outPw.println("value"); + for (int i = 0; i < longestNamespaceLen; i++) { + outPw.print('='); + } + outPw.print(' '); + for (int i = 0; i < longestFlagLen; i++) { + outPw.print('='); + } + outPw.print(' '); + for (int i = 0; i < longestValLen; i++) { + outPw.print('='); + } + outPw.println(); + for (Map.Entry<String, Map<String, String>> namespace : overrides.entrySet()) { + for (Map.Entry<String, String> flag : namespace.getValue().entrySet()) { + outPw.print( + String.format("%-" + longestNamespaceLen + "s", namespace.getKey())); + outPw.print(' '); + outPw.print(String.format("%-" + longestFlagLen + "s", flag.getKey())); + outPw.print(' '); + outPw.println(flag.getValue()); + } + } + } + outPw.flush(); + return 0; + } + + private int setCmd(String[] args, PrintWriter outPw, PrintWriter errPw) { + if (!validateNumArguments(args, 3, args[0], errPw)) { + errPw.println("Expected `" + args[0] + " <namespace> <name> <value>`"); + errPw.flush(); + return -1; + } + mFlagStore.set(args[1], args[2], args[3]); + outPw.println("Flag " + args[1] + "." + args[2] + " is now " + args[3]); + outPw.flush(); + return 0; + } + + private int getCmd(String[] args, PrintWriter outPw, PrintWriter errPw) { + if (!validateNumArguments(args, 2, args[0], errPw)) { + errPw.println("Expected `" + args[0] + " <namespace> <name>`"); + errPw.flush(); + return -1; + } + + String value = mFlagStore.get(args[1], args[2]); + outPw.print(args[1] + "." + args[2] + " is "); + if (value == null || value.isEmpty()) { + outPw.println("<unset>"); + } else { + outPw.println("\"" + value.translateEscapes() + "\""); + } + outPw.flush(); + return 0; + } + + private int eraseCmd(String[] args, PrintWriter outPw, PrintWriter errPw) { + if (!validateNumArguments(args, 2, args[0], errPw)) { + errPw.println("Expected `" + args[0] + " <namespace> <name>`"); + errPw.flush(); + return -1; + } + mFlagStore.erase(args[1], args[2]); + outPw.println("Erased " + args[1] + "." + args[2]); + return 0; + } + + private int unknownCmd(PrintWriter outPw) { + outPw.println("This command is unknown."); + printHelp(outPw); + outPw.flush(); + return -1; + } + + private boolean validateNumArguments( + String[] args, int exactly, String cmdName, PrintWriter errPw) { + return validateNumArguments(args, exactly, exactly, cmdName, errPw); + } + + private boolean validateNumArguments( + String[] args, int min, int max, String cmdName, PrintWriter errPw) { + int len = args.length - 1; // Discount the command itself. + if (len < min) { + errPw.println( + "Less than " + min + " arguments provided for \"" + cmdName + "\" command."); + return false; + } else if (len > max) { + errPw.println( + "More than " + max + " arguments provided for \"" + cmdName + "\" command."); + return false; + } + + return true; + } +} diff --git a/services/flags/java/com/android/server/flags/GlobalSettingsProxy.java b/services/flags/java/com/android/server/flags/GlobalSettingsProxy.java new file mode 100644 index 000000000000..acb7bb5a49db --- /dev/null +++ b/services/flags/java/com/android/server/flags/GlobalSettingsProxy.java @@ -0,0 +1,68 @@ +/* + * 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.server.flags; + +import android.content.ContentResolver; +import android.net.Uri; +import android.provider.Settings; + +class GlobalSettingsProxy implements SettingsProxy { + private final ContentResolver mContentResolver; + + GlobalSettingsProxy(ContentResolver contentResolver) { + mContentResolver = contentResolver; + } + + @Override + public ContentResolver getContentResolver() { + return mContentResolver; + } + + @Override + public Uri getUriFor(String name) { + return Settings.Global.getUriFor(name); + } + + @Override + public String getStringForUser(String name, int userHandle) { + return Settings.Global.getStringForUser(mContentResolver, name, userHandle); + } + + @Override + public boolean putString(String name, String value, boolean overrideableByRestore) { + throw new UnsupportedOperationException( + "This method only exists publicly for Settings.System and Settings.Secure"); + } + + @Override + public boolean putStringForUser(String name, String value, int userHandle) { + return Settings.Global.putStringForUser(mContentResolver, name, value, userHandle); + } + + @Override + public boolean putStringForUser(String name, String value, String tag, boolean makeDefault, + int userHandle, boolean overrideableByRestore) { + return Settings.Global.putStringForUser( + mContentResolver, name, value, tag, makeDefault, userHandle, + overrideableByRestore); + } + + @Override + public boolean putString(String name, String value, String tag, boolean makeDefault) { + return Settings.Global.putString(mContentResolver, name, value, tag, makeDefault); + } +} diff --git a/services/flags/java/com/android/server/flags/SettingsProxy.java b/services/flags/java/com/android/server/flags/SettingsProxy.java new file mode 100644 index 000000000000..c6e85d5d1dc8 --- /dev/null +++ b/services/flags/java/com/android/server/flags/SettingsProxy.java @@ -0,0 +1,381 @@ +/* + * 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.server.flags; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.UserIdInt; +import android.content.ContentResolver; +import android.database.ContentObserver; +import android.net.Uri; +import android.provider.Settings; + +/** + * Wrapper class meant to enable hermetic testing of {@link Settings}. + * + * Implementations of this class are expected to be constructed with a {@link ContentResolver} or, + * otherwise have access to an implicit one. All the proxy methods in this class exclude + * {@link ContentResolver} from their signature and rely on an internally defined one instead. + * + * Most methods in the {@link Settings} classes have default implementations defined. + * Implementations of this interfac need only concern themselves with getting and putting Strings. + * They should also override any methods for a class they are proxying that _are not_ defined, and + * throw an appropriate {@link UnsupportedOperationException}. For instance, {@link Settings.Global} + * does not define {@link #putString(String, String, boolean)}, so an implementation of this + * interface that proxies through to it should throw an exception when that method is called. + * + * This class adds in the following helpers as well: + * - {@link #getBool(String)} + * - {@link #putBool(String, boolean)} + * - {@link #registerContentObserver(Uri, ContentObserver)} + * + * ... and similar variations for all of those. + */ +public interface SettingsProxy { + + /** + * Returns the {@link ContentResolver} this instance uses. + */ + ContentResolver getContentResolver(); + + /** + * Construct the content URI for a particular name/value pair, + * useful for monitoring changes with a ContentObserver. + * @param name to look up in the table + * @return the corresponding content URI, or null if not present + */ + Uri getUriFor(String name); + + /**See {@link Settings.Secure#getString(ContentResolver, String)} */ + String getStringForUser(String name, int userHandle); + + /**See {@link Settings.Secure#putString(ContentResolver, String, String, boolean)} */ + boolean putString(String name, String value, boolean overrideableByRestore); + + /** See {@link Settings.Secure#putStringForUser(ContentResolver, String, String, int)} */ + boolean putStringForUser(String name, String value, int userHandle); + + /** + * See {@link Settings.Secure#putStringForUser(ContentResolver, String, String, String, boolean, + * int, boolean)} + */ + boolean putStringForUser(@NonNull String name, @Nullable String value, @Nullable String tag, + boolean makeDefault, @UserIdInt int userHandle, boolean overrideableByRestore); + + /** See {@link Settings.Secure#putString(ContentResolver, String, String, String, boolean)} */ + boolean putString(@NonNull String name, @Nullable String value, @Nullable String tag, + boolean makeDefault); + + /** + * Returns the user id for the associated {@link ContentResolver}. + */ + default int getUserId() { + return getContentResolver().getUserId(); + } + + /** See {@link Settings.Secure#getString(ContentResolver, String)} */ + default String getString(String name) { + return getStringForUser(name, getUserId()); + } + + /** See {@link Settings.Secure#putString(ContentResolver, String, String)} */ + default boolean putString(String name, String value) { + return putStringForUser(name, value, getUserId()); + } + /** See {@link Settings.Secure#getIntForUser(ContentResolver, String, int, int)} */ + default int getIntForUser(String name, int def, int userHandle) { + String v = getStringForUser(name, userHandle); + try { + return v != null ? Integer.parseInt(v) : def; + } catch (NumberFormatException e) { + return def; + } + } + + /** See {@link Settings.Secure#getInt(ContentResolver, String)} */ + default int getInt(String name) throws Settings.SettingNotFoundException { + return getIntForUser(name, getUserId()); + } + + /** See {@link Settings.Secure#getIntForUser(ContentResolver, String, int)} */ + default int getIntForUser(String name, int userHandle) + throws Settings.SettingNotFoundException { + String v = getStringForUser(name, userHandle); + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + throw new Settings.SettingNotFoundException(name); + } + } + + /** See {@link Settings.Secure#putInt(ContentResolver, String, int)} */ + default boolean putInt(String name, int value) { + return putIntForUser(name, value, getUserId()); + } + + /** See {@link Settings.Secure#putIntForUser(ContentResolver, String, int, int)} */ + default boolean putIntForUser(String name, int value, int userHandle) { + return putStringForUser(name, Integer.toString(value), userHandle); + } + + /** + * Convenience function for retrieving a single settings value + * as a boolean. Note that internally setting values are always + * stored as strings; this function converts the string to a boolean + * for you. The default value will be returned if the setting is + * not defined or not a boolean. + * + * @param name The name of the setting to retrieve. + * @param def Value to return if the setting is not defined. + * + * @return The setting's current value, or 'def' if it is not defined + * or not a valid boolean. + */ + default boolean getBool(String name, boolean def) { + return getBoolForUser(name, def, getUserId()); + } + + /** See {@link #getBool(String, boolean)}. */ + default boolean getBoolForUser(String name, boolean def, int userHandle) { + return getIntForUser(name, def ? 1 : 0, userHandle) != 0; + } + + /** + * Convenience function for retrieving a single settings value + * as a boolean. Note that internally setting values are always + * stored as strings; this function converts the string to a boolean + * for you. + * <p> + * This version does not take a default value. If the setting has not + * been set, or the string value is not a number, + * it throws {@link Settings.SettingNotFoundException}. + * + * @param name The name of the setting to retrieve. + * + * @throws Settings.SettingNotFoundException Thrown if a setting by the given + * name can't be found or the setting value is not a boolean. + * + * @return The setting's current value. + */ + default boolean getBool(String name) throws Settings.SettingNotFoundException { + return getBoolForUser(name, getUserId()); + } + + /** See {@link #getBool(String)}. */ + default boolean getBoolForUser(String name, int userHandle) + throws Settings.SettingNotFoundException { + return getIntForUser(name, userHandle) != 0; + } + + /** + * Convenience function for updating a single settings value as a + * boolean. This will either create a new entry in the table if the + * given name does not exist, or modify the value of the existing row + * with that name. Note that internally setting values are always + * stored as strings, so this function converts the given value to a + * string before storing it. + * + * @param name The name of the setting to modify. + * @param value The new value for the setting. + * @return true if the value was set, false on database errors + */ + default boolean putBool(String name, boolean value) { + return putBoolForUser(name, value, getUserId()); + } + + /** See {@link #putBool(String, boolean)}. */ + default boolean putBoolForUser(String name, boolean value, int userHandle) { + return putIntForUser(name, value ? 1 : 0, userHandle); + } + + /** See {@link Settings.Secure#getLong(ContentResolver, String, long)} */ + default long getLong(String name, long def) { + return getLongForUser(name, def, getUserId()); + } + + /** See {@link Settings.Secure#getLongForUser(ContentResolver, String, long, int)} */ + default long getLongForUser(String name, long def, int userHandle) { + String valString = getStringForUser(name, userHandle); + long value; + try { + value = valString != null ? Long.parseLong(valString) : def; + } catch (NumberFormatException e) { + value = def; + } + return value; + } + + /** See {@link Settings.Secure#getLong(ContentResolver, String)} */ + default long getLong(String name) throws Settings.SettingNotFoundException { + return getLongForUser(name, getUserId()); + } + + /** See {@link Settings.Secure#getLongForUser(ContentResolver, String, int)} */ + default long getLongForUser(String name, int userHandle) + throws Settings.SettingNotFoundException { + String valString = getStringForUser(name, userHandle); + try { + return Long.parseLong(valString); + } catch (NumberFormatException e) { + throw new Settings.SettingNotFoundException(name); + } + } + + /** See {@link Settings.Secure#putLong(ContentResolver, String, long)} */ + default boolean putLong(String name, long value) { + return putLongForUser(name, value, getUserId()); + } + + /** See {@link Settings.Secure#putLongForUser(ContentResolver, String, long, int)} */ + default boolean putLongForUser(String name, long value, int userHandle) { + return putStringForUser(name, Long.toString(value), userHandle); + } + + /** See {@link Settings.Secure#getFloat(ContentResolver, String, float)} */ + default float getFloat(String name, float def) { + return getFloatForUser(name, def, getUserId()); + } + + /** See {@link Settings.Secure#getFloatForUser(ContentResolver, String, int)} */ + default float getFloatForUser(String name, float def, int userHandle) { + String v = getStringForUser(name, userHandle); + try { + return v != null ? Float.parseFloat(v) : def; + } catch (NumberFormatException e) { + return def; + } + } + + + /** See {@link Settings.Secure#getFloat(ContentResolver, String)} */ + default float getFloat(String name) throws Settings.SettingNotFoundException { + return getFloatForUser(name, getUserId()); + } + + /** See {@link Settings.Secure#getFloatForUser(ContentResolver, String, int)} */ + default float getFloatForUser(String name, int userHandle) + throws Settings.SettingNotFoundException { + String v = getStringForUser(name, userHandle); + if (v == null) { + throw new Settings.SettingNotFoundException(name); + } + try { + return Float.parseFloat(v); + } catch (NumberFormatException e) { + throw new Settings.SettingNotFoundException(name); + } + } + + /** See {@link Settings.Secure#putFloat(ContentResolver, String, float)} */ + default boolean putFloat(String name, float value) { + return putFloatForUser(name, value, getUserId()); + } + + /** See {@link Settings.Secure#putFloatForUser(ContentResolver, String, float, int)} */ + default boolean putFloatForUser(String name, float value, int userHandle) { + return putStringForUser(name, Float.toString(value), userHandle); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver)}.' + * + * Implicitly calls {@link #getUriFor(String)} on the passed in name. + */ + default void registerContentObserver(String name, ContentObserver settingsObserver) { + registerContentObserver(getUriFor(name), settingsObserver); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver)}.' + */ + default void registerContentObserver(Uri uri, ContentObserver settingsObserver) { + registerContentObserverForUser(uri, settingsObserver, getUserId()); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver)}. + * + * Implicitly calls {@link #getUriFor(String)} on the passed in name. + */ + default void registerContentObserver(String name, boolean notifyForDescendants, + ContentObserver settingsObserver) { + registerContentObserver(getUriFor(name), notifyForDescendants, settingsObserver); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver)}.' + */ + default void registerContentObserver(Uri uri, boolean notifyForDescendants, + ContentObserver settingsObserver) { + registerContentObserverForUser(uri, notifyForDescendants, settingsObserver, getUserId()); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver, int)} + * + * Implicitly calls {@link #getUriFor(String)} on the passed in name. + */ + default void registerContentObserverForUser( + String name, ContentObserver settingsObserver, int userHandle) { + registerContentObserverForUser( + getUriFor(name), settingsObserver, userHandle); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver, int)} + */ + default void registerContentObserverForUser( + Uri uri, ContentObserver settingsObserver, int userHandle) { + registerContentObserverForUser( + uri, false, settingsObserver, userHandle); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver, int)} + * + * Implicitly calls {@link #getUriFor(String)} on the passed in name. + */ + default void registerContentObserverForUser( + String name, boolean notifyForDescendants, ContentObserver settingsObserver, + int userHandle) { + registerContentObserverForUser( + getUriFor(name), notifyForDescendants, settingsObserver, userHandle); + } + + /** + * Convenience wrapper around + * {@link ContentResolver#registerContentObserver(Uri, boolean, ContentObserver, int)} + */ + default void registerContentObserverForUser( + Uri uri, boolean notifyForDescendants, ContentObserver settingsObserver, + int userHandle) { + getContentResolver().registerContentObserver( + uri, notifyForDescendants, settingsObserver, userHandle); + } + + /** See {@link ContentResolver#unregisterContentObserver(ContentObserver)}. */ + default void unregisterContentObserver(ContentObserver settingsObserver) { + getContentResolver().unregisterContentObserver(settingsObserver); + } +} diff --git a/services/java/com/android/server/SystemServer.java b/services/java/com/android/server/SystemServer.java index 6960da74dd1a..5f45485d36f4 100644 --- a/services/java/com/android/server/SystemServer.java +++ b/services/java/com/android/server/SystemServer.java @@ -132,6 +132,7 @@ import com.android.server.display.DisplayManagerService; import com.android.server.display.color.ColorDisplayService; import com.android.server.dreams.DreamManagerService; import com.android.server.emergency.EmergencyAffordanceService; +import com.android.server.flags.FeatureFlagsService; import com.android.server.gpu.GpuService; import com.android.server.grammaticalinflection.GrammaticalInflectionService; import com.android.server.graphics.fonts.FontManagerService; @@ -1115,6 +1116,12 @@ public final class SystemServer implements Dumpable { mSystemServiceManager.startService(DeviceIdentifiersPolicyService.class); t.traceEnd(); + // Starts a service for reading runtime flag overrides, and keeping processes + // in sync with one another. + t.traceBegin("StartFeatureFlagsService"); + mSystemServiceManager.startService(FeatureFlagsService.class); + t.traceEnd(); + // Uri Grants Manager. t.traceBegin("UriGrantsManagerService"); mSystemServiceManager.startService(UriGrantsManagerService.Lifecycle.class); diff --git a/services/tests/servicestests/Android.bp b/services/tests/servicestests/Android.bp index ee3a773580a3..0530f892a48e 100644 --- a/services/tests/servicestests/Android.bp +++ b/services/tests/servicestests/Android.bp @@ -34,6 +34,7 @@ android_test { "services.core", "services.credentials", "services.devicepolicy", + "services.flags", "services.net", "services.people", "services.usage", diff --git a/services/tests/servicestests/src/com/android/server/flags/FeatureFlagsServiceTest.java b/services/tests/servicestests/src/com/android/server/flags/FeatureFlagsServiceTest.java new file mode 100644 index 000000000000..8455b88ae9af --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/flags/FeatureFlagsServiceTest.java @@ -0,0 +1,179 @@ +/* + * 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.server.flags; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.flags.IFeatureFlagsCallback; +import android.flags.SyncableFlag; +import android.os.IBinder; +import android.os.RemoteException; +import android.platform.test.annotations.Presubmit; + +import androidx.test.filters.SmallTest; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.util.List; + +@Presubmit +@SmallTest +public class FeatureFlagsServiceTest { + private static final String NS = "ns"; + private static final String NAME = "name"; + private static final String PROP_NAME = FlagOverrideStore.getPropName(NS, NAME); + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private FlagOverrideStore mFlagStore; + @Mock + private FlagsShellCommand mFlagCommand; + @Mock + private IFeatureFlagsCallback mIFeatureFlagsCallback; + @Mock + private IBinder mIFeatureFlagsCallbackAsBinder; + + private FeatureFlagsBinder mFeatureFlagsService; + + @Before + public void setup() { + when(mIFeatureFlagsCallback.asBinder()).thenReturn(mIFeatureFlagsCallbackAsBinder); + mFeatureFlagsService = new FeatureFlagsBinder(mFlagStore, mFlagCommand); + } + + @Test + public void testRegisterCallback() { + mFeatureFlagsService.registerCallback(mIFeatureFlagsCallback); + try { + verify(mIFeatureFlagsCallbackAsBinder).linkToDeath(any(), eq(0)); + } catch (RemoteException e) { + fail("Our mock threw a Remote Exception?"); + } + } + + @Test + public void testSyncFlags_noOverrides() { + List<SyncableFlag> inputFlags = List.of( + new SyncableFlag(NS, "a", "false", false), + new SyncableFlag(NS, "b", "true", false), + new SyncableFlag(NS, "c", "false", false) + ); + + List<SyncableFlag> outputFlags = mFeatureFlagsService.syncFlags(inputFlags); + + assertThat(inputFlags.size()).isEqualTo(outputFlags.size()); + + for (SyncableFlag inpF: inputFlags) { + boolean found = false; + for (SyncableFlag outF : outputFlags) { + if (compareSyncableFlagsNames(inpF, outF)) { + found = true; + break; + } + } + assertWithMessage("Failed to find input flag " + inpF + " in the output") + .that(found).isTrue(); + } + } + + @Test + public void testSyncFlags_withSomeOverrides() { + List<SyncableFlag> inputFlags = List.of( + new SyncableFlag(NS, "a", "false", false), + new SyncableFlag(NS, "b", "true", false), + new SyncableFlag(NS, "c", "false", false) + ); + + assertThat(mFlagStore).isNotNull(); + when(mFlagStore.get(NS, "c")).thenReturn("true"); + List<SyncableFlag> outputFlags = mFeatureFlagsService.syncFlags(inputFlags); + + assertThat(inputFlags.size()).isEqualTo(outputFlags.size()); + + for (SyncableFlag inpF: inputFlags) { + boolean found = false; + for (SyncableFlag outF : outputFlags) { + if (compareSyncableFlagsNames(inpF, outF)) { + found = true; + + // Once we've found "c", do an extra check + if (outF.getName().equals("c")) { + assertWithMessage("Flag " + outF + "was not returned with an override") + .that(outF.getValue()).isEqualTo("true"); + } + break; + } + } + assertWithMessage("Failed to find input flag " + inpF + " in the output") + .that(found).isTrue(); + } + } + + + @Test + public void testSyncFlags_twoCallsWithDifferentDefaults() { + List<SyncableFlag> inputFlagsFirst = List.of( + new SyncableFlag(NS, "a", "false", false) + ); + List<SyncableFlag> inputFlagsSecond = List.of( + new SyncableFlag(NS, "a", "true", false), + new SyncableFlag(NS, "b", "false", false) + ); + + List<SyncableFlag> outputFlagsFirst = mFeatureFlagsService.syncFlags(inputFlagsFirst); + List<SyncableFlag> outputFlagsSecond = mFeatureFlagsService.syncFlags(inputFlagsSecond); + + assertThat(inputFlagsFirst.size()).isEqualTo(outputFlagsFirst.size()); + assertThat(inputFlagsSecond.size()).isEqualTo(outputFlagsSecond.size()); + + // This test only cares that the "a" flag passed in the second time came out with the + // same value that was passed in the first time. + + boolean found = false; + for (SyncableFlag second : outputFlagsSecond) { + if (compareSyncableFlagsNames(second, inputFlagsFirst.get(0))) { + found = true; + assertThat(second.getValue()).isEqualTo(inputFlagsFirst.get(0).getValue()); + break; + } + } + + assertWithMessage( + "Failed to find flag " + inputFlagsFirst.get(0) + " in the second calls output") + .that(found).isTrue(); + } + + private static boolean compareSyncableFlagsNames(SyncableFlag a, SyncableFlag b) { + return a.getNamespace().equals(b.getNamespace()) + && a.getName().equals(b.getName()) + && a.isDynamic() == b.isDynamic(); + } +} diff --git a/services/tests/servicestests/src/com/android/server/flags/FlagCacheTest.java b/services/tests/servicestests/src/com/android/server/flags/FlagCacheTest.java new file mode 100644 index 000000000000..c2cf540d1d62 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/flags/FlagCacheTest.java @@ -0,0 +1,86 @@ +/* + * 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.server.flags; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FlagCacheTest { + private static final String NS = "ns"; + private static final String NAME = "name"; + + FlagCache mFlagCache = new FlagCache(); + + @Test + public void testGetOrNull_unset() { + assertThat(mFlagCache.getOrNull(NS, NAME)).isNull(); + } + + @Test + public void testGetOrSet_unset() { + assertThat(mFlagCache.getOrSet(NS, NAME, "value")).isEqualTo("value"); + } + + @Test + public void testGetOrSet_alreadySet() { + mFlagCache.setIfChanged(NS, NAME, "value"); + assertThat(mFlagCache.getOrSet(NS, NAME, "newvalue")).isEqualTo("value"); + } + + @Test + public void testSetIfChanged_unset() { + assertThat(mFlagCache.setIfChanged(NS, NAME, "value")).isTrue(); + } + + @Test + public void testSetIfChanged_noChange() { + mFlagCache.setIfChanged(NS, NAME, "value"); + assertThat(mFlagCache.setIfChanged(NS, NAME, "value")).isFalse(); + } + + @Test + public void testSetIfChanged_changing() { + mFlagCache.setIfChanged(NS, NAME, "value"); + assertThat(mFlagCache.setIfChanged(NS, NAME, "newvalue")).isTrue(); + } + + @Test + public void testContainsNamespace_unset() { + assertThat(mFlagCache.containsNamespace(NS)).isFalse(); + } + + @Test + public void testContainsNamespace_set() { + mFlagCache.setIfChanged(NS, NAME, "value"); + assertThat(mFlagCache.containsNamespace(NS)).isTrue(); + } + + @Test + public void testContains_unset() { + assertThat(mFlagCache.contains(NS, NAME)).isFalse(); + } + + @Test + public void testContains_set() { + mFlagCache.setIfChanged(NS, NAME, "value"); + assertThat(mFlagCache.contains(NS, NAME)).isTrue(); + } +} diff --git a/services/tests/servicestests/src/com/android/server/flags/FlagOverrideStoreTest.java b/services/tests/servicestests/src/com/android/server/flags/FlagOverrideStoreTest.java new file mode 100644 index 000000000000..6cc3acfb6125 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/flags/FlagOverrideStoreTest.java @@ -0,0 +1,111 @@ +/* + * 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.server.flags; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.platform.test.annotations.Presubmit; + +import androidx.test.filters.SmallTest; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@Presubmit +@SmallTest +public class FlagOverrideStoreTest { + private static final String NS = "ns"; + private static final String NAME = "name"; + private static final String PROP_NAME = FlagOverrideStore.getPropName(NS, NAME); + + @Rule + public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock + private SettingsProxy mSettingsProxy; + @Mock + private FlagOverrideStore.FlagChangeCallback mCallback; + + private FlagOverrideStore mFlagStore; + + @Before + public void setup() { + mFlagStore = new FlagOverrideStore(mSettingsProxy); + mFlagStore.setChangeCallback(mCallback); + } + + @Test + public void testSet_unset() { + mFlagStore.set(NS, NAME, "value"); + verify(mSettingsProxy).putString(PROP_NAME, "value"); + } + + @Test + public void testSet_setTwice() { + mFlagStore.set(NS, NAME, "value"); + mFlagStore.set(NS, NAME, "newvalue"); + verify(mSettingsProxy).putString(PROP_NAME, "value"); + verify(mSettingsProxy).putString(PROP_NAME, "newvalue"); + } + + @Test + public void testGet_unset() { + assertThat(mFlagStore.get(NS, NAME)).isNull(); + } + + @Test + public void testGet_set() { + when(mSettingsProxy.getString(PROP_NAME)).thenReturn("value"); + assertThat(mFlagStore.get(NS, NAME)).isEqualTo("value"); + } + + @Test + public void testErase() { + mFlagStore.erase(NS, NAME); + verify(mSettingsProxy).putString(PROP_NAME, null); + } + + @Test + public void testContains_unset() { + assertThat(mFlagStore.contains(NS, NAME)).isFalse(); + } + + @Test + public void testContains_set() { + when(mSettingsProxy.getString(PROP_NAME)).thenReturn("value"); + assertThat(mFlagStore.contains(NS, NAME)).isTrue(); + } + + @Test + public void testCallback_onSet() { + mFlagStore.set(NS, NAME, "value"); + verify(mCallback).onFlagChanged(NS, NAME, "value"); + } + + @Test + public void testCallback_onErase() { + mFlagStore.erase(NS, NAME); + verify(mCallback).onFlagChanged(NS, NAME, null); + } +} diff --git a/services/tests/servicestests/src/com/android/server/flags/OWNERS b/services/tests/servicestests/src/com/android/server/flags/OWNERS new file mode 100644 index 000000000000..7ed369e37106 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/flags/OWNERS @@ -0,0 +1 @@ +include /services/flags/OWNERS |