diff options
5 files changed, 503 insertions, 1 deletions
diff --git a/core/java/android/appwidget/flags.aconfig b/core/java/android/appwidget/flags.aconfig index 18cfca686107..4e0379e3dc3a 100644 --- a/core/java/android/appwidget/flags.aconfig +++ b/core/java/android/appwidget/flags.aconfig @@ -50,3 +50,10 @@ flag { purpose: PURPOSE_BUGFIX } } + +flag { + name: "remote_views_proto" + namespace: "app_widgets" + description: "Enable support for persisting RemoteViews previews to Protobuf" + bug: "306546610" +} diff --git a/core/java/android/widget/RemoteViews.java b/core/java/android/widget/RemoteViews.java index 5430f8f6add3..060c7dedf08a 100644 --- a/core/java/android/widget/RemoteViews.java +++ b/core/java/android/widget/RemoteViews.java @@ -17,8 +17,10 @@ package android.widget; import static android.appwidget.flags.Flags.FLAG_DRAW_DATA_PARCEL; +import static android.appwidget.flags.Flags.FLAG_REMOTE_VIEWS_PROTO; import static android.appwidget.flags.Flags.drawDataParcel; import static android.appwidget.flags.Flags.remoteAdapterConversion; +import static android.util.proto.ProtoInputStream.NO_MORE_FIELDS; import static android.view.inputmethod.Flags.FLAG_HOME_SCREEN_HANDWRITING_DELEGATOR; import android.annotation.AttrRes; @@ -94,6 +96,9 @@ import android.util.SparseArray; import android.util.SparseIntArray; import android.util.TypedValue; import android.util.TypedValue.ComplexDimensionUnit; +import android.util.proto.ProtoInputStream; +import android.util.proto.ProtoOutputStream; +import android.util.proto.ProtoUtils; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.LayoutInflater.Filter; @@ -7860,4 +7865,278 @@ public class RemoteViews implements Parcelable, Filter { mClassCookies = classCookies; } } + + /** + * Write this RemoteViews to proto. + * @hide + */ + @FlaggedApi(FLAG_REMOTE_VIEWS_PROTO) + public void writePreviewToProto(@NonNull Context context, @NonNull ProtoOutputStream out) { + if (mApplication != null) { + // mApplication may be null if this was created with DrawInstructions constructor. + out.write(RemoteViewsProto.PACKAGE_NAME, mApplication.packageName); + } + Resources appResources = getContextForResourcesEnsuringCorrectCachedApkPaths( + context).getResources(); + if (mLayoutId != 0) { + out.write(RemoteViewsProto.LAYOUT_ID, appResources.getResourceName(mLayoutId)); + } + if (mLightBackgroundLayoutId != 0) { + out.write(RemoteViewsProto.LIGHT_BACKGROUND_LAYOUT_ID, + appResources.getResourceName(mLightBackgroundLayoutId)); + } + if (mViewId != 0 && mViewId != -1) { + out.write(RemoteViewsProto.VIEW_ID, appResources.getResourceName(mViewId)); + } + out.write(RemoteViewsProto.IS_ROOT, mIsRoot); + out.write(RemoteViewsProto.APPLY_FLAGS, mApplyFlags); + out.write(RemoteViewsProto.HAS_DRAW_INSTRUCTIONS, mHasDrawInstructions); + if (mProviderInstanceId != -1) { + out.write(RemoteViewsProto.PROVIDER_INSTANCE_ID, mProviderInstanceId); + } + + if (!hasMultipleLayouts()) { + out.write(RemoteViewsProto.MODE, MODE_NORMAL); + if (mIdealSize != null) { + final long token = out.start(RemoteViewsProto.IDEAL_SIZE); + out.write(SizeFProto.WIDTH, mIdealSize.getWidth()); + out.write(SizeFProto.HEIGHT, mIdealSize.getHeight()); + out.end(token); + } + } else if (hasSizedRemoteViews()) { + out.write(RemoteViewsProto.MODE, MODE_HAS_SIZED_REMOTEVIEWS); + for (RemoteViews view : mSizedRemoteViews) { + final long sizedViewToken = out.start(RemoteViewsProto.SIZED_REMOTEVIEWS); + view.writePreviewToProto(context, out); + out.end(sizedViewToken); + } + } else { + out.write(RemoteViewsProto.MODE, MODE_HAS_LANDSCAPE_AND_PORTRAIT); + final long landscapeViewToken = out.start(RemoteViewsProto.LANDSCAPE_REMOTEVIEWS); + mLandscape.writePreviewToProto(context, out); + out.end(landscapeViewToken); + final long portraitViewToken = out.start(RemoteViewsProto.PORTRAIT_REMOTEVIEWS); + mPortrait.writePreviewToProto(context, out); + out.end(portraitViewToken); + } + } + + /** + * Create a RemoteViews from proto input. + * @hide + */ + @FlaggedApi(FLAG_REMOTE_VIEWS_PROTO) + public static RemoteViews createPreviewFromProto(Context context, ProtoInputStream in) + throws Exception { + return createFromProto(in).create(context, context.getResources(), /* rootData= */ null, + /* depth= */ 0); + } + + private static PendingResources<RemoteViews> createFromProto(ProtoInputStream in) + throws Exception { + // Grouping these variables into an anonymous object allows us to access them through `ref` + // (which is final) later in the lambda. + final var ref = new Object() { + final RemoteViews mRv = new RemoteViews(); + int mMode = 0; + int mApplyFlags = 0; + long mProviderInstanceId = -1; + String mPackageName = null; + SizeF mIdealSize = null; + String mLayoutResName = null; + String mLightBackgroundResName = null; + String mViewResName = null; + final List<PendingResources<RemoteViews>> mSizedRemoteViews = new ArrayList<>(); + PendingResources<RemoteViews> mLandscapeViews = null; + PendingResources<RemoteViews> mPortraitViews = null; + boolean mIsRoot = false; + boolean mHasDrawInstructions = false; + }; + + try { + while (in.nextField() != NO_MORE_FIELDS) { + switch (in.getFieldNumber()) { + case (int) RemoteViewsProto.MODE: + ref.mMode = in.readInt(RemoteViewsProto.MODE); + break; + case (int) RemoteViewsProto.PACKAGE_NAME: + ref.mPackageName = in.readString(RemoteViewsProto.PACKAGE_NAME); + break; + case (int) RemoteViewsProto.IDEAL_SIZE: + final long idealSizeToken = in.start(RemoteViewsProto.IDEAL_SIZE); + ref.mIdealSize = createSizeFFromProto(in); + in.end(idealSizeToken); + break; + case (int) RemoteViewsProto.LAYOUT_ID: + ref.mLayoutResName = in.readString(RemoteViewsProto.LAYOUT_ID); + break; + case (int) RemoteViewsProto.LIGHT_BACKGROUND_LAYOUT_ID: + ref.mLightBackgroundResName = in.readString( + RemoteViewsProto.LIGHT_BACKGROUND_LAYOUT_ID); + break; + case (int) RemoteViewsProto.VIEW_ID: + ref.mViewResName = in.readString(RemoteViewsProto.VIEW_ID); + break; + case (int) RemoteViewsProto.APPLY_FLAGS: + ref.mApplyFlags = in.readInt(RemoteViewsProto.APPLY_FLAGS); + break; + case (int) RemoteViewsProto.PROVIDER_INSTANCE_ID: + ref.mProviderInstanceId = in.readInt(RemoteViewsProto.PROVIDER_INSTANCE_ID); + break; + case (int) RemoteViewsProto.SIZED_REMOTEVIEWS: + final long sizedToken = in.start(RemoteViewsProto.SIZED_REMOTEVIEWS); + ref.mSizedRemoteViews.add(createFromProto(in)); + in.end(sizedToken); + break; + case (int) RemoteViewsProto.LANDSCAPE_REMOTEVIEWS: + final long landscapeToken = in.start( + RemoteViewsProto.LANDSCAPE_REMOTEVIEWS); + ref.mLandscapeViews = createFromProto(in); + in.end(landscapeToken); + break; + case (int) RemoteViewsProto.PORTRAIT_REMOTEVIEWS: + final long portraitToken = in.start(RemoteViewsProto.PORTRAIT_REMOTEVIEWS); + ref.mPortraitViews = createFromProto(in); + in.end(portraitToken); + break; + case (int) RemoteViewsProto.IS_ROOT: + ref.mIsRoot = in.readBoolean(RemoteViewsProto.IS_ROOT); + break; + case (int) RemoteViewsProto.HAS_DRAW_INSTRUCTIONS: + ref.mHasDrawInstructions = in.readBoolean( + RemoteViewsProto.HAS_DRAW_INSTRUCTIONS); + break; + default: + Log.w(LOG_TAG, "Unhandled field while reading RemoteViews proto!\n" + + ProtoUtils.currentFieldToString(in)); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + return (context, resources, rootData, depth) -> { + if (depth > MAX_NESTED_VIEWS && (UserHandle.getAppId(Binder.getCallingUid()) + != Process.SYSTEM_UID)) { + throw new IllegalArgumentException("Too many nested views."); + } + depth++; + + RemoteViews rv = ref.mRv; + rv.mApplyFlags = ref.mApplyFlags; + rv.mIsRoot = ref.mIsRoot; + rv.mHasDrawInstructions = ref.mHasDrawInstructions; + + // The root view will read its HierarchyRootData (bitmap cache, collection cache) from + // proto; all nested views will instead get it through the rootData parameter. + if (rootData == null) { + if (!rv.mIsRoot || depth != 1) { + throw new IllegalStateException( + "A nested view did not receive HierarchyRootData"); + } + rootData = rv.getHierarchyRootData(); + } else { + rv.configureAsChild(rootData); + } + + Context appContext = null; + Resources appResources = null; + if (!ref.mHasDrawInstructions) { + checkProtoResultNotNull(ref.mPackageName, "No application info"); + rv.mApplication = context.getPackageManager().getApplicationInfo(ref.mPackageName, + /* flags= */ 0); + appContext = rv.getContextForResourcesEnsuringCorrectCachedApkPaths(context); + appResources = appContext.getResources(); + + checkProtoResultNotNull(ref.mLayoutResName, "No layout id"); + rv.mLayoutId = appResources.getIdentifier(ref.mLayoutResName, /* defType= */ null, + /* defPackage= */ null); + checkValidResource(rv.mLayoutId, "Invalid layout id", ref.mLayoutResName); + + if (ref.mViewResName != null) { + rv.mViewId = appResources.getIdentifier(ref.mViewResName, /* defType= */ null, + /* defPackage= */ null); + checkValidResource(rv.mViewId, "Invalid view id", ref.mViewResName); + } + + if (ref.mLightBackgroundResName != null) { + int lightBackgroundLayoutId = appResources.getIdentifier( + ref.mLightBackgroundResName, + /* defType= */ null, /* defPackage= */ null); + checkValidResource(lightBackgroundLayoutId, + "Invalid light background layout id", ref.mLightBackgroundResName); + rv.setLightBackgroundLayoutId(lightBackgroundLayoutId); + } + } + if (ref.mProviderInstanceId != -1) { + rv.mProviderInstanceId = ref.mProviderInstanceId; + } + if (ref.mMode == MODE_NORMAL) { + rv.setIdealSize(ref.mIdealSize); + return rv; + } else if (ref.mMode == MODE_HAS_SIZED_REMOTEVIEWS) { + List<RemoteViews> sizedViews = new ArrayList<>(); + for (RemoteViews.PendingResources<RemoteViews> pendingViews : + ref.mSizedRemoteViews) { + RemoteViews views = pendingViews.create(context, resources, rootData, depth); + sizedViews.add(views); + } + rv.initializeSizedRemoteViews(sizedViews.iterator()); + return rv; + } else if (ref.mMode == MODE_HAS_LANDSCAPE_AND_PORTRAIT) { + checkProtoResultNotNull(ref.mLandscapeViews, "Missing landscape views"); + checkProtoResultNotNull(ref.mPortraitViews, "Missing portrait views"); + RemoteViews parentRv = new RemoteViews( + ref.mLandscapeViews.create(context, resources, rootData, depth), + ref.mPortraitViews.create(context, resources, rootData, depth)); + parentRv.initializeFrom(/* src= */ rv, /* hierarchyRoot= */ rv); + return parentRv; + } else { + throw new InvalidProtoException(ref.mMode + " is not a valid mode."); + } + }; + } + + private static class InvalidProtoException extends Exception { + InvalidProtoException(String message) { + super(message); + } + } + + private interface PendingResources<T> { + T create(Context context, Resources appResources, HierarchyRootData rootData, int depth) + throws Exception; + } + + private static void checkValidResource(int id, String message, String resName) + throws Exception { + if (id == 0) throw new Exception(message + ": " + resName); + } + + private static void checkProtoResultNotNull(Object o, String message) + throws InvalidProtoException { + if (o == null) { + throw new InvalidProtoException(message); + } + } + + private static SizeF createSizeFFromProto(ProtoInputStream in) throws Exception { + float width = 0; + float height = 0; + while (in.nextField() != NO_MORE_FIELDS) { + switch (in.getFieldNumber()) { + case (int) SizeFProto.WIDTH: + width = in.readFloat(SizeFProto.WIDTH); + break; + case (int) SizeFProto.HEIGHT: + height = in.readFloat(SizeFProto.HEIGHT); + break; + default: + Log.w(LOG_TAG, "Unhandled field while reading SizeF proto!\n" + + ProtoUtils.currentFieldToString(in)); + } + } + + return new SizeF(width, height); + } } diff --git a/core/proto/android/widget/remoteviews.proto b/core/proto/android/widget/remoteviews.proto new file mode 100644 index 000000000000..d24da0362e46 --- /dev/null +++ b/core/proto/android/widget/remoteviews.proto @@ -0,0 +1,60 @@ +/* + * 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 optional 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. + */ + +syntax = "proto2"; + +option java_multiple_files = true; + +package android.widget; + +import "frameworks/base/core/proto/android/privacy.proto"; + +/** + * An android.widget.RemoteViews object. This is used by RemoteViews.createPreviewFromProto + * and RemoteViews.writePreviewToProto. + * + * Any addition of fields here will require an update to the parsing code in RemoteViews.java. + * Otherwise the field will be ignored when parsing (with a logged warning). + * + * Do not change the tag number or type of any fields in order to maintain compatibility with + * previous versions. If a field is deleted, use `reserved` to mark its tag number. + */ +message RemoteViewsProto { + option (android.msg_privacy).dest = DEST_AUTOMATIC; + optional int32 mode = 1; + optional string package_name = 2; + optional string layout_id = 3; + optional string light_background_layout_id = 4; + optional string view_id = 5; + optional SizeFProto ideal_size = 6; + optional int32 apply_flags = 7; + optional int64 provider_instance_id = 8; + // RemoteViews for different sizes (created with RemoteViews(Map<SizeF, RemoteViews) + // constructor). + repeated RemoteViewsProto sized_remoteviews = 9; + // RemoteViews for portrait/landscape (created with RemoteViews(RemoteViews, RemoteViews)i + // constructor). + optional RemoteViewsProto portrait_remoteviews = 10; + optional RemoteViewsProto landscape_remoteviews = 11; + optional bool is_root = 12; + optional bool has_draw_instructions = 13; +} + + +message SizeFProto { + optional float width = 1; + optional float height = 2; +} diff --git a/core/tests/coretests/src/android/widget/RemoteViewsProtoTest.java b/core/tests/coretests/src/android/widget/RemoteViewsProtoTest.java new file mode 100644 index 000000000000..8e9ba7b008cd --- /dev/null +++ b/core/tests/coretests/src/android/widget/RemoteViewsProtoTest.java @@ -0,0 +1,155 @@ +/* + * 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 android.widget; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.content.Context; +import android.util.SizeF; +import android.util.proto.ProtoInputStream; +import android.util.proto.ProtoOutputStream; +import android.view.View; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.frameworks.coretests.R; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; + +import java.util.Map; + +/** + * Tests for RemoteViews. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RemoteViewsProtoTest { + + // This can point to any other package which exists on the device. + private static final String OTHER_PACKAGE = "com.android.systemui"; + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + private Context mContext; + private String mPackage; + private LinearLayout mContainer; + + @Before + public void setup() { + mContext = InstrumentationRegistry.getContext(); + mPackage = mContext.getPackageName(); + mContainer = new LinearLayout(mContext); + } + + @Test + public void copy_canStillBeApplied() { + RemoteViews original = new RemoteViews(mPackage, R.layout.remote_views_test); + + RemoteViews clone = recreateFromProto(original); + + clone.apply(mContext, mContainer); + } + + @SuppressWarnings("ReturnValueIgnored") + @Test + public void clone_repeatedly() { + RemoteViews original = new RemoteViews(mPackage, R.layout.remote_views_test); + + recreateFromProto(original); + recreateFromProto(original); + + original.apply(mContext, mContainer); + } + + @Test + public void clone_chained() { + RemoteViews original = new RemoteViews(mPackage, R.layout.remote_views_test); + + RemoteViews clone = recreateFromProto(recreateFromProto(original)); + + + clone.apply(mContext, mContainer); + } + + @Test + public void landscapePortraitViews_lightBackgroundLayoutFlag() { + RemoteViews inner = new RemoteViews(mPackage, R.layout.remote_views_text); + inner.setLightBackgroundLayoutId(R.layout.remote_views_light_background_text); + + RemoteViews parent = new RemoteViews(inner, inner); + parent.addFlags(RemoteViews.FLAG_USE_LIGHT_BACKGROUND_LAYOUT); + + View view = recreateFromProto(parent).apply(mContext, mContainer); + assertNull(view.findViewById(R.id.text)); + assertNotNull(view.findViewById(R.id.light_background_text)); + } + + @Test + public void sizedViews_lightBackgroundLayoutFlag() { + RemoteViews inner = new RemoteViews(mPackage, R.layout.remote_views_text); + inner.setLightBackgroundLayoutId(R.layout.remote_views_light_background_text); + + RemoteViews parent = new RemoteViews( + Map.of(new SizeF(0, 0), inner, new SizeF(100, 100), inner)); + parent.addFlags(RemoteViews.FLAG_USE_LIGHT_BACKGROUND_LAYOUT); + + View view = recreateFromProto(parent).apply(mContext, mContainer); + assertNull(view.findViewById(R.id.text)); + assertNotNull(view.findViewById(R.id.light_background_text)); + } + + @Test + public void nestedLandscapeViews() throws Exception { + RemoteViews views = new RemoteViews(mPackage, R.layout.remote_views_test); + for (int i = 0; i < 10; i++) { + views = new RemoteViews(views, new RemoteViews(mPackage, R.layout.remote_views_test)); + } + // writeTo/createFromProto works + recreateFromProto(views); + + views = new RemoteViews(mPackage, R.layout.remote_views_test); + for (int i = 0; i < 11; i++) { + views = new RemoteViews(views, new RemoteViews(mPackage, R.layout.remote_views_test)); + } + // writeTo/createFromProto fails + exception.expect(IllegalArgumentException.class); + recreateFromProtoNoRethrow(views); + } + + private RemoteViews recreateFromProto(RemoteViews views) { + try { + return recreateFromProtoNoRethrow(views); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private RemoteViews recreateFromProtoNoRethrow(RemoteViews views) throws Exception { + ProtoOutputStream out = new ProtoOutputStream(); + views.writePreviewToProto(mContext, out); + ProtoInputStream in = new ProtoInputStream(out.getBytes()); + return RemoteViews.createPreviewFromProto(mContext, in); + } +} diff --git a/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java b/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java index 863cda4905f1..c2a923ed236d 100644 --- a/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java +++ b/services/tests/uiservicestests/src/com/android/server/notification/NotificationVisitUrisTest.java @@ -36,6 +36,7 @@ import android.os.IBinder; import android.os.Parcel; import android.platform.test.flag.junit.SetFlagsRule; import android.util.Log; +import android.util.proto.ProtoOutputStream; import android.view.LayoutInflater; import android.view.View; import android.widget.RemoteViews; @@ -103,7 +104,7 @@ public class NotificationVisitUrisTest extends UiServiceTestCase { private static final ImmutableSet<Class<?>> UNUSABLE_TYPES = ImmutableSet.of(Consumer.class, IBinder.class, MediaSession.Token.class, Parcel.class, PrintWriter.class, Resources.Theme.class, View.class, - LayoutInflater.Factory2.class); + LayoutInflater.Factory2.class, ProtoOutputStream.class); // Maximum number of times we allow generating the same class recursively. // E.g. new RemoteViews.addView(new RemoteViews()) but stop there. |