1 /*
2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.wear.protolayout.renderer.inflater;
18 
19 import static android.util.TypedValue.COMPLEX_UNIT_SP;
20 import static android.view.View.INVISIBLE;
21 import static android.view.View.LAYOUT_DIRECTION_LTR;
22 import static android.view.View.LAYOUT_DIRECTION_RTL;
23 import static android.view.View.VISIBLE;
24 
25 import static androidx.core.util.Preconditions.checkNotNull;
26 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
27 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID;
28 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.getParentNodePosId;
29 import static androidx.wear.protolayout.renderer.inflater.PropHelpers.handleProp;
30 
31 import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
32 import static com.google.common.util.concurrent.Futures.immediateFuture;
33 
34 import static java.lang.Math.max;
35 import static java.lang.Math.min;
36 import static java.lang.Math.round;
37 import static java.nio.charset.StandardCharsets.US_ASCII;
38 
39 import android.annotation.SuppressLint;
40 import android.content.Context;
41 import android.content.Intent;
42 import android.content.pm.ActivityInfo;
43 import android.content.res.ColorStateList;
44 import android.content.res.Resources;
45 import android.content.res.Resources.Theme;
46 import android.graphics.Color;
47 import android.graphics.Outline;
48 import android.graphics.Paint.Cap;
49 import android.graphics.PorterDuff.Mode;
50 import android.graphics.Rect;
51 import android.graphics.Typeface;
52 import android.graphics.drawable.AnimatedVectorDrawable;
53 import android.graphics.drawable.BitmapDrawable;
54 import android.graphics.drawable.ColorDrawable;
55 import android.graphics.drawable.Drawable;
56 import android.text.SpannableStringBuilder;
57 import android.text.Spanned;
58 import android.text.TextPaint;
59 import android.text.TextUtils;
60 import android.text.TextUtils.TruncateAt;
61 import android.text.method.LinkMovementMethod;
62 import android.text.style.AbsoluteSizeSpan;
63 import android.text.style.ClickableSpan;
64 import android.text.style.ForegroundColorSpan;
65 import android.text.style.ImageSpan;
66 import android.text.style.StyleSpan;
67 import android.text.style.UnderlineSpan;
68 import android.util.Log;
69 import android.util.TypedValue;
70 import android.view.ContextThemeWrapper;
71 import android.view.Gravity;
72 import android.view.View;
73 import android.view.ViewGroup;
74 import android.view.ViewGroup.LayoutParams;
75 import android.view.ViewOutlineProvider;
76 import android.view.ViewParent;
77 import android.view.animation.AlphaAnimation;
78 import android.view.animation.AnimationSet;
79 import android.view.animation.TranslateAnimation;
80 import android.widget.FrameLayout;
81 import android.widget.ImageView;
82 import android.widget.ImageView.ScaleType;
83 import android.widget.LinearLayout;
84 import android.widget.Scroller;
85 import android.widget.Space;
86 import android.widget.TextView;
87 
88 import androidx.annotation.UiThread;
89 import androidx.annotation.VisibleForTesting;
90 import androidx.core.content.ContextCompat;
91 import androidx.core.view.AccessibilityDelegateCompat;
92 import androidx.core.view.ViewCompat;
93 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
94 import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
95 import androidx.wear.protolayout.expression.pipeline.AnimationsHelper;
96 import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
97 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
98 import androidx.wear.protolayout.proto.ActionProto.Action;
99 import androidx.wear.protolayout.proto.ActionProto.AndroidActivity;
100 import androidx.wear.protolayout.proto.ActionProto.AndroidExtra;
101 import androidx.wear.protolayout.proto.ActionProto.LaunchAction;
102 import androidx.wear.protolayout.proto.ActionProto.LoadAction;
103 import androidx.wear.protolayout.proto.AlignmentProto.AngularAlignment;
104 import androidx.wear.protolayout.proto.AlignmentProto.ArcAnchorType;
105 import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignment;
106 import androidx.wear.protolayout.proto.AlignmentProto.TextAlignment;
107 import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignment;
108 import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignmentProp;
109 import androidx.wear.protolayout.proto.DimensionProto.AngularDimension;
110 import androidx.wear.protolayout.proto.DimensionProto.ArcLineLength;
111 import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension;
112 import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension.InnerCase;
113 import androidx.wear.protolayout.proto.DimensionProto.DegreesProp;
114 import androidx.wear.protolayout.proto.DimensionProto.DpProp;
115 import androidx.wear.protolayout.proto.DimensionProto.ExpandedAngularDimensionProp;
116 import androidx.wear.protolayout.proto.DimensionProto.ExpandedDimensionProp;
117 import androidx.wear.protolayout.proto.DimensionProto.ExtensionDimension;
118 import androidx.wear.protolayout.proto.DimensionProto.ImageDimension;
119 import androidx.wear.protolayout.proto.DimensionProto.PivotDimension;
120 import androidx.wear.protolayout.proto.DimensionProto.ProportionalDimensionProp;
121 import androidx.wear.protolayout.proto.DimensionProto.SpProp;
122 import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension;
123 import androidx.wear.protolayout.proto.DimensionProto.WrappedDimensionProp;
124 import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint;
125 import androidx.wear.protolayout.proto.LayoutElementProto.Arc;
126 import androidx.wear.protolayout.proto.LayoutElementProto.ArcDirection;
127 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
128 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLine;
129 import androidx.wear.protolayout.proto.LayoutElementProto.ArcSpacer;
130 import androidx.wear.protolayout.proto.LayoutElementProto.ArcText;
131 import androidx.wear.protolayout.proto.LayoutElementProto.Box;
132 import androidx.wear.protolayout.proto.LayoutElementProto.Column;
133 import androidx.wear.protolayout.proto.LayoutElementProto.ContentScaleMode;
134 import androidx.wear.protolayout.proto.LayoutElementProto.DashedArcLine;
135 import androidx.wear.protolayout.proto.LayoutElementProto.DashedLinePattern;
136 import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
137 import androidx.wear.protolayout.proto.LayoutElementProto.FontFeatureSetting;
138 import androidx.wear.protolayout.proto.LayoutElementProto.FontSetting;
139 import androidx.wear.protolayout.proto.LayoutElementProto.FontStyle;
140 import androidx.wear.protolayout.proto.LayoutElementProto.FontVariationSetting;
141 import androidx.wear.protolayout.proto.LayoutElementProto.Image;
142 import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
143 import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
144 import androidx.wear.protolayout.proto.LayoutElementProto.MarqueeParameters;
145 import androidx.wear.protolayout.proto.LayoutElementProto.Row;
146 import androidx.wear.protolayout.proto.LayoutElementProto.Spacer;
147 import androidx.wear.protolayout.proto.LayoutElementProto.Span;
148 import androidx.wear.protolayout.proto.LayoutElementProto.SpanImage;
149 import androidx.wear.protolayout.proto.LayoutElementProto.SpanText;
150 import androidx.wear.protolayout.proto.LayoutElementProto.SpanVerticalAlignmentProp;
151 import androidx.wear.protolayout.proto.LayoutElementProto.Spannable;
152 import androidx.wear.protolayout.proto.LayoutElementProto.StrokeCapProp;
153 import androidx.wear.protolayout.proto.LayoutElementProto.Text;
154 import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflow;
155 import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflowProp;
156 import androidx.wear.protolayout.proto.ModifiersProto.ArcModifiers;
157 import androidx.wear.protolayout.proto.ModifiersProto.Background;
158 import androidx.wear.protolayout.proto.ModifiersProto.Border;
159 import androidx.wear.protolayout.proto.ModifiersProto.Clickable;
160 import androidx.wear.protolayout.proto.ModifiersProto.Corner;
161 import androidx.wear.protolayout.proto.ModifiersProto.CornerRadius;
162 import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition;
163 import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition;
164 import androidx.wear.protolayout.proto.ModifiersProto.FadeInTransition;
165 import androidx.wear.protolayout.proto.ModifiersProto.FadeOutTransition;
166 import androidx.wear.protolayout.proto.ModifiersProto.Modifiers;
167 import androidx.wear.protolayout.proto.ModifiersProto.Padding;
168 import androidx.wear.protolayout.proto.ModifiersProto.Semantics;
169 import androidx.wear.protolayout.proto.ModifiersProto.SemanticsRole;
170 import androidx.wear.protolayout.proto.ModifiersProto.Shadow;
171 import androidx.wear.protolayout.proto.ModifiersProto.SlideDirection;
172 import androidx.wear.protolayout.proto.ModifiersProto.SlideInTransition;
173 import androidx.wear.protolayout.proto.ModifiersProto.SlideOutTransition;
174 import androidx.wear.protolayout.proto.ModifiersProto.SlideParentSnapOption;
175 import androidx.wear.protolayout.proto.ModifiersProto.SpanModifiers;
176 import androidx.wear.protolayout.proto.ModifiersProto.Transformation;
177 import androidx.wear.protolayout.proto.StateProto.State;
178 import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger;
179 import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger;
180 import androidx.wear.protolayout.proto.TriggerProto.Trigger;
181 import androidx.wear.protolayout.proto.TypesProto.BoolProp;
182 import androidx.wear.protolayout.proto.TypesProto.FloatProp;
183 import androidx.wear.protolayout.proto.TypesProto.StringProp;
184 import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider;
185 import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
186 import androidx.wear.protolayout.renderer.ProtoLayoutTheme.FontSet;
187 import androidx.wear.protolayout.renderer.R;
188 import androidx.wear.protolayout.renderer.common.LoggingUtils;
189 import androidx.wear.protolayout.renderer.common.NoOpProviderStatsLogger;
190 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer;
191 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.LayoutDiff;
192 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.TreeNodeWithChange;
193 import androidx.wear.protolayout.renderer.common.ProviderStatsLogger.InflaterStatsLogger;
194 import androidx.wear.protolayout.renderer.common.RenderingArtifact;
195 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline;
196 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline.PipelineMaker;
197 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LayoutInfo;
198 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LinearLayoutProperties;
199 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingFrameLayoutParams;
200 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingLayoutParams;
201 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.ViewProperties;
202 import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.ResourceAccessException;
203 import androidx.wear.widget.ArcLayout;
204 import androidx.wear.widget.CurvedTextView;
205 
206 import com.google.common.collect.ImmutableSet;
207 import com.google.common.util.concurrent.ListenableFuture;
208 import com.google.common.util.concurrent.SettableFuture;
209 
210 import org.jspecify.annotations.NonNull;
211 import org.jspecify.annotations.Nullable;
212 
213 import java.nio.ByteBuffer;
214 import java.util.ArrayList;
215 import java.util.Arrays;
216 import java.util.List;
217 import java.util.Locale;
218 import java.util.Map;
219 import java.util.Optional;
220 import java.util.StringJoiner;
221 import java.util.concurrent.CancellationException;
222 import java.util.concurrent.ExecutionException;
223 import java.util.concurrent.Executor;
224 import java.util.concurrent.Future;
225 import java.util.function.Consumer;
226 import java.util.function.Function;
227 
228 /**
229  * Renderer for ProtoLayout.
230  *
231  * <p>This variant uses Android views to represent the contents of the ProtoLayout.
232  */
233 public final class ProtoLayoutInflater {
234 
235     private static final String TAG = "ProtoLayoutInflater";
236     private static final char ZERO_WIDTH_JOINER = '\u200D';
237 
238     // This will help debug potential layout issues that might be leading to full layout updates or
239     // poor performance in general.
240     private static final boolean DEBUG_DIFF_UPDATE_ENABLED = false;
241 
242     /** Target alpha for fade in animation. */
243     private static final float FADE_IN_TARGET_ALPHA = 1;
244 
245     /** Initial alpha for fade out animation. */
246     private static final float FADE_OUT_INITIAL_ALPHA = 1;
247 
248     /** The default trigger for animations set to onLoad. */
249     private static final Trigger DEFAULT_ANIMATION_TRIGGER =
250             Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build();
251 
252     /** The default minimal click target size, for meeting the accessibility requirement */
253     @VisibleForTesting static final float DEFAULT_MIN_CLICKABLE_SIZE_DP = 48f;
254 
255     /**
256      * Default maximum raw byte size for a bitmap drawable.
257      *
258      * @see <a
259      *     href="https://cs.android.com/android/_/android/platform/frameworks/base/+/d01036ee5893357db577c961119fb85825247f03:graphics/java/android/graphics/RecordingCanvas.java;l=44;bpv=1;bpt=0;drc=00af5271dabd578397176eda0cd7a66c55fac59a">
260      *     The framework enforced max size</a>
261      */
262     private static final int DEFAULT_MAX_BITMAP_RAW_SIZE = 20 * 1024 * 1024;
263 
264     private static final int HORIZONTAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_HORIZONTAL;
265     private static final int VERTICAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_VERTICAL;
266     private static final int TEXT_ALIGN_DEFAULT = Gravity.CENTER_HORIZONTAL;
267     private static final ScaleType IMAGE_DEFAULT_SCALE_TYPE = ScaleType.FIT_CENTER;
268 
269     @ArcLayout.LayoutParams.VerticalAlignment
270     private static final int ARC_VERTICAL_ALIGN_DEFAULT =
271             ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER;
272 
273     @SizedArcContainer.LayoutParams.AngularAlignment
274     private static final int ANGULAR_ALIGNMENT_DEFAULT =
275             SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER;
276 
277     private static final int SPAN_VERTICAL_ALIGN_DEFAULT = ImageSpan.ALIGN_BOTTOM;
278 
279     // This is pretty badly named; TruncateAt specifies where to place the ellipsis (or whether to
280     // marquee). Disabling truncation with null actually disables the _ellipsis_, but text will
281     // still be truncated.
282     private static final @Nullable TruncateAt TEXT_OVERFLOW_DEFAULT = null;
283 
284     private static final int TEXT_COLOR_DEFAULT = 0xFFFFFFFF;
285     private static final int TEXT_MAX_LINES_DEFAULT = 1;
286     @VisibleForTesting static final int TEXT_AUTOSIZES_LIMIT = 10;
287     private static final int TEXT_MIN_LINES = 1;
288 
289     private static final ContainerDimension CONTAINER_DIMENSION_DEFAULT =
290             ContainerDimension.newBuilder()
291                     .setWrappedDimension(WrappedDimensionProp.getDefaultInstance())
292                     .build();
293 
294     @ArcLayout.AnchorType private static final int ARC_ANCHOR_DEFAULT = ArcLayout.ANCHOR_CENTER;
295 
296     // White
297     private static final int LINE_COLOR_DEFAULT = 0xFFFFFFFF;
298 
299     static final PendingLayoutParams NO_OP_PENDING_LAYOUT_PARAMS = layoutParams -> layoutParams;
300     private static final byte UNSET_MASK = 0;
301 
302     final Context mUiContext;
303 
304     // Context wrapped with the provided ProtoLayoutTheme. This context should be used for creating
305     // any Text Views, as it will apply text appearance attributes.
306     private final Context mProtoLayoutThemeContext;
307 
308     private final ProtoLayoutTheme mProtoLayoutTheme;
309     private final Layout mLayoutProto;
310     private final ResourceResolvers mLayoutResourceResolvers;
311 
312     private final Optional<ProtoLayoutDynamicDataPipeline> mDataPipeline;
313 
314     private final @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider;
315 
316     private final boolean mAllowLayoutChangingBindsWithoutDefault;
317     final String mClickableIdExtra;
318 
319     private final @Nullable LoggingUtils mLoggingUtils;
320     private final @NonNull InflaterStatsLogger mInflaterStatsLogger;
321     final @Nullable Executor mLoadActionExecutor;
322     final LoadActionListener mLoadActionListener;
323     final boolean mAnimationEnabled;
324 
325     private boolean mApplyFontVariantBodyAsDefault = false;
326 
327     @VisibleForTesting static final String WEIGHT_AXIS_TAG = "wght";
328     @VisibleForTesting static final String WIDTH_AXIS_TAG = "wdth";
329     @VisibleForTesting static final String ROUNDNESS_AXIS_TAG = "ROND";
330     @VisibleForTesting static final String TABULAR_OPTION_TAG = "tnum";
331 
332     private static final ImmutableSet<String> SUPPORTED_FONT_SETTING_TAGS =
333             ImmutableSet.of(
334                     WEIGHT_AXIS_TAG, WIDTH_AXIS_TAG, ROUNDNESS_AXIS_TAG, TABULAR_OPTION_TAG);
335 
336     /**
337      * Listener for clicks on Clickable objects that have an Action to (re)load the contents of a
338      * layout.
339      */
340     public interface LoadActionListener {
341 
342         /**
343          * Called when a Clickable that has a LoadAction is clicked.
344          *
345          * @param nextState The state that the next layout should be in.
346          */
onClick(@onNull State nextState)347         void onClick(@NonNull State nextState);
348     }
349 
350     /**
351      * A one-off class to be returned from {@link ProtoLayoutInflater#inflate} containing top level
352      * parent, list of content transition animations to be run and a PipelineMaker with pending
353      * changes to the dynamic data pipeline.
354      */
355     public static final class InflateResult {
356         public final ViewGroup inflateParent;
357         public final View firstChild;
358         private final Optional<PipelineMaker> mPipelineMaker;
359 
InflateResult( ViewGroup inflateParent, View firstChild, Optional<PipelineMaker> pipelineMaker)360         InflateResult(
361                 ViewGroup inflateParent, View firstChild, Optional<PipelineMaker> pipelineMaker) {
362             this.inflateParent = inflateParent;
363             this.firstChild = firstChild;
364             this.mPipelineMaker = pipelineMaker;
365         }
366 
367         /**
368          * Update the DynamicDataPipeline with new nodes that were stored during the layout update.
369          *
370          * @param isReattaching if True, this layout is being reattached and will skip content
371          *     transition animations.
372          */
373         @UiThread
updateDynamicDataPipeline(boolean isReattaching)374         public void updateDynamicDataPipeline(boolean isReattaching) {
375             mPipelineMaker.ifPresent(
376                     pipe -> pipe.clearDataPipelineAndCommit(inflateParent, isReattaching));
377         }
378     }
379 
380     /** A mutation that can be applied to a {@link ViewGroup}, using {@link #applyMutation}. */
381     public static final class ViewGroupMutation {
382         final List<InflatedView> mInflatedViews;
383         final RenderedMetadata mRenderedMetadataAfterMutation;
384         final NodeFingerprint mPreMutationRootNodeFingerprint;
385         final Optional<PipelineMaker> mPipelineMaker;
386 
ViewGroupMutation( List<InflatedView> inflatedViews, RenderedMetadata renderedMetadataAfterMutation, NodeFingerprint preMutationRootNodeFingerprint, Optional<PipelineMaker> pipelineMaker)387         ViewGroupMutation(
388                 List<InflatedView> inflatedViews,
389                 RenderedMetadata renderedMetadataAfterMutation,
390                 NodeFingerprint preMutationRootNodeFingerprint,
391                 Optional<PipelineMaker> pipelineMaker) {
392             this.mInflatedViews = inflatedViews;
393             this.mRenderedMetadataAfterMutation = renderedMetadataAfterMutation;
394             this.mPreMutationRootNodeFingerprint = preMutationRootNodeFingerprint;
395             this.mPipelineMaker = pipelineMaker;
396         }
397 
398         /** Returns true if this mutation has no effect. */
isNoOp()399         public boolean isNoOp() {
400             return this.mInflatedViews.isEmpty();
401         }
402     }
403 
404     private static final class InflatedView {
405         final View mView;
406         final LayoutParams mLayoutParams;
407         final PendingLayoutParams mChildLayoutParams;
408         private int mNumMissingChildren;
409 
410         /**
411          * @param view The {@link View} that has been inflated.
412          * @param layoutParams The {@link LayoutParams} that must be used when attaching the
413          *     inflated view to a parent.
414          * @param childLayoutParams The {@link LayoutParams} that must be applied to children
415          *     carried over from a previous layout.
416          * @param numMissingChildren Non-zero if {@code view} is a {@link ViewGroup} whose children
417          *     have not been added. This means that before using this view in a layout, its children
418          *     must be copied from the {@link ViewGroup} that represents the previous version of
419          *     this layout element.
420          */
InflatedView( View view, LayoutParams layoutParams, PendingLayoutParams childLayoutParams, int numMissingChildren)421         InflatedView(
422                 View view,
423                 LayoutParams layoutParams,
424                 PendingLayoutParams childLayoutParams,
425                 int numMissingChildren) {
426             this.mView = view;
427             this.mLayoutParams = layoutParams;
428             this.mChildLayoutParams = childLayoutParams;
429             this.mNumMissingChildren = numMissingChildren;
430         }
431 
InflatedView(View view, LayoutParams layoutParams)432         InflatedView(View view, LayoutParams layoutParams) {
433             this(view, layoutParams, NO_OP_PENDING_LAYOUT_PARAMS, /* numMissingChildren= */ 0);
434         }
435 
addMissingChildrenFrom(View source)436         boolean addMissingChildrenFrom(View source) {
437             if (mNumMissingChildren == 0) {
438                 // Nothing to do.
439                 return true;
440             }
441             Object tagObj = source.getTag();
442             String tag = (tagObj == null ? "unknown" : (String) tagObj);
443             if (!(mView instanceof ViewGroup)) {
444                 Log.w(TAG, "Destination is not a group: " + tag);
445                 return false;
446             }
447             ViewGroup destinationGroup = (ViewGroup) mView;
448             if (destinationGroup.getChildCount() > 0) {
449                 Log.w(TAG, "Destination already has children: " + tag);
450                 return false;
451             }
452             if (!(source instanceof ViewGroup)) {
453                 Log.w(TAG, "Source is not a group: " + tag);
454                 return false;
455             }
456             ViewGroup sourceGroup = (ViewGroup) source;
457             if (getEffectiveChildCount(sourceGroup) != mNumMissingChildren) {
458                 Log.w(
459                         TAG,
460                         String.format(
461                                 "Expected %d children in %s found %d",
462                                 mNumMissingChildren, tag, sourceGroup.getChildCount()));
463                 return false;
464             }
465             List<View> children = new ArrayList<>(sourceGroup.getChildCount());
466             for (int i = 0; i < mNumMissingChildren; i++) {
467                 children.add(sourceGroup.getChildAt(i));
468             }
469             sourceGroup.removeAllViews();
470 
471             for (View child : children) {
472                 destinationGroup.addView(child);
473                 child.setLayoutParams(
474                         mChildLayoutParams.apply(checkNotNull(child.getLayoutParams())));
475             }
476             mNumMissingChildren = 0;
477 
478             if (source.getTouchDelegate() != null) {
479                 addTouchDelegate(
480                         destinationGroup, (TouchDelegateComposite) source.getTouchDelegate());
481             }
482             return true;
483         }
484 
485         /**
486          * Returns the number of children that the given {@link ViewGroup} has, ignoring the
487          * children that are {@link IgnorableSpace} type.
488          */
getEffectiveChildCount(ViewGroup viewGroup)489         private static int getEffectiveChildCount(ViewGroup viewGroup) {
490             int nonIgnoredChildrenCnt = 0;
491             for (int i = 0; i < viewGroup.getChildCount(); i++) {
492                 if (!(viewGroup.getChildAt(i) instanceof IgnorableSpace)) {
493                     nonIgnoredChildrenCnt++;
494                 }
495             }
496             return nonIgnoredChildrenCnt;
497         }
498 
getTag()499         @Nullable String getTag() {
500             return (String) mView.getTag();
501         }
502     }
503 
504     /**
505      * A one-of class to pass either a real {@link ViewGroup} or only its needed properties through
506      * the renderer.
507      */
508     private static final class ParentViewWrapper {
509         private final @Nullable ViewGroup mParent;
510         private final ViewProperties mParentProps;
511 
ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams)512         ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams) {
513             this.mParent = parent;
514             this.mParentProps =
515                     ViewProperties.fromViewGroup(
516                             parent, parentLayoutParams, NO_OP_PENDING_LAYOUT_PARAMS);
517         }
518 
ParentViewWrapper( ViewGroup parent, LayoutParams parentLayoutParams, PendingLayoutParams childLayoutParams)519         ParentViewWrapper(
520                 ViewGroup parent,
521                 LayoutParams parentLayoutParams,
522                 PendingLayoutParams childLayoutParams) {
523             this.mParent = parent;
524             this.mParentProps =
525                     ViewProperties.fromViewGroup(parent, parentLayoutParams, childLayoutParams);
526         }
527 
ParentViewWrapper(ViewProperties parentProps)528         ParentViewWrapper(ViewProperties parentProps) {
529             this.mParent = null;
530             this.mParentProps = parentProps;
531         }
532 
getParentProperties()533         ViewProperties getParentProperties() {
534             return mParentProps;
535         }
536 
537         /** If this class holds a {@link ViewGroup}, add {@code child} to it. */
maybeAddView(View child, LayoutParams layoutParams)538         void maybeAddView(View child, LayoutParams layoutParams) {
539             if (mParent != null) {
540                 mParent.addView(child, layoutParams);
541             }
542         }
543     }
544 
545     /** Exception that will be thrown when applying a mutation to a {@link View} fails. */
546     public static class ViewMutationException extends RuntimeException {
ViewMutationException(@onNull String message)547         public ViewMutationException(@NonNull String message) {
548             super(message);
549         }
550     }
551 
552     /** Config class for ProtoLayoutInflater */
553     public static final class Config {
554         public static final String DEFAULT_CLICKABLE_ID_EXTRA =
555                 "androidx.wear.protolayout.extra.CLICKABLE_ID";
556         private final @NonNull Context mUiContext;
557         private final @NonNull Layout mLayout;
558         private final @NonNull ResourceResolvers mLayoutResourceResolvers;
559         private final @Nullable Executor mLoadActionExecutor;
560         private final @NonNull LoadActionListener mLoadActionListener;
561         private final @NonNull Resources mRendererResources;
562         private final @NonNull ProtoLayoutTheme mProtoLayoutTheme;
563         private final @Nullable ProtoLayoutDynamicDataPipeline mDataPipeline;
564         private final @NonNull String mClickableIdExtra;
565 
566         private final @Nullable LoggingUtils mLoggingUtils;
567         private final @NonNull InflaterStatsLogger mInflaterStatsLogger;
568         private final @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider;
569         private final boolean mAnimationEnabled;
570 
571         private final boolean mAllowLayoutChangingBindsWithoutDefault;
572 
573         private final boolean mApplyFontVariantBodyAsDefault;
574 
Config( @onNull Context uiContext, @NonNull Layout layout, @NonNull ResourceResolvers layoutResourceResolvers, @Nullable Executor loadActionExecutor, @NonNull LoadActionListener loadActionListener, @NonNull Resources rendererResources, @NonNull ProtoLayoutTheme protoLayoutTheme, @Nullable ProtoLayoutDynamicDataPipeline dataPipeline, @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider, @NonNull String clickableIdExtra, @Nullable LoggingUtils loggingUtils, @NonNull InflaterStatsLogger inflaterStatsLogger, boolean animationEnabled, boolean allowLayoutChangingBindsWithoutDefault, boolean applyFontVariantBodyAsDefault)575         Config(
576                 @NonNull Context uiContext,
577                 @NonNull Layout layout,
578                 @NonNull ResourceResolvers layoutResourceResolvers,
579                 @Nullable Executor loadActionExecutor,
580                 @NonNull LoadActionListener loadActionListener,
581                 @NonNull Resources rendererResources,
582                 @NonNull ProtoLayoutTheme protoLayoutTheme,
583                 @Nullable ProtoLayoutDynamicDataPipeline dataPipeline,
584                 @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider,
585                 @NonNull String clickableIdExtra,
586                 @Nullable LoggingUtils loggingUtils,
587                 @NonNull InflaterStatsLogger inflaterStatsLogger,
588                 boolean animationEnabled,
589                 boolean allowLayoutChangingBindsWithoutDefault,
590                 boolean applyFontVariantBodyAsDefault) {
591             this.mUiContext = uiContext;
592             this.mLayout = layout;
593             this.mLayoutResourceResolvers = layoutResourceResolvers;
594             this.mLoadActionExecutor = loadActionExecutor;
595             this.mLoadActionListener = loadActionListener;
596             this.mRendererResources = rendererResources;
597             this.mProtoLayoutTheme = protoLayoutTheme;
598             this.mDataPipeline = dataPipeline;
599             this.mAnimationEnabled = animationEnabled;
600             this.mAllowLayoutChangingBindsWithoutDefault = allowLayoutChangingBindsWithoutDefault;
601             this.mClickableIdExtra = clickableIdExtra;
602             this.mLoggingUtils = loggingUtils;
603             this.mInflaterStatsLogger = inflaterStatsLogger;
604             this.mExtensionViewProvider = extensionViewProvider;
605             this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault;
606         }
607 
608         /** A {@link Context} suitable for interacting with UI. */
getUiContext()609         public @NonNull Context getUiContext() {
610             return mUiContext;
611         }
612 
613         /** The layout to be rendered. */
getLayout()614         public @NonNull Layout getLayout() {
615             return mLayout;
616         }
617 
618         /** Resolvers for the resources used for rendering this layout. */
getLayoutResourceResolvers()619         public @NonNull ResourceResolvers getLayoutResourceResolvers() {
620             return mLayoutResourceResolvers;
621         }
622 
623         /** Executor to dispatch loadActionListener on. */
getLoadActionExecutor()624         public @Nullable Executor getLoadActionExecutor() {
625             return mLoadActionExecutor;
626         }
627 
628         /** Listener for clicks that will cause contents to be reloaded. */
getLoadActionListener()629         public @NonNull LoadActionListener getLoadActionListener() {
630             return mLoadActionListener;
631         }
632 
633         /**
634          * Renderer internal resources. This Resources object can be used to resolve Renderer's
635          * resources.
636          */
getRendererResources()637         public @NonNull Resources getRendererResources() {
638             return mRendererResources;
639         }
640 
641         /**
642          * Theme to use for this ProtoLayoutInflater instance. This can be used to customise things
643          * like the default font family.
644          */
getProtoLayoutTheme()645         public @NonNull ProtoLayoutTheme getProtoLayoutTheme() {
646             return mProtoLayoutTheme;
647         }
648 
649         /**
650          * Pipeline for dynamic data. If null, the dynamic properties would not be registered for
651          * update.
652          */
getDynamicDataPipeline()653         public @Nullable ProtoLayoutDynamicDataPipeline getDynamicDataPipeline() {
654             return mDataPipeline;
655         }
656 
657         /**
658          * ID for the Intent extra containing the ID of a Clickable. Defaults to {@link
659          * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified.
660          */
getClickableIdExtra()661         public @NonNull String getClickableIdExtra() {
662             return mClickableIdExtra;
663         }
664 
665         /** Debug logger used to log debug messages. */
getLoggingUtils()666         public @Nullable LoggingUtils getLoggingUtils() {
667             return mLoggingUtils;
668         }
669 
670         /** Stats logger used for telemetry. */
getInflaterStatsLogger()671         public @NonNull InflaterStatsLogger getInflaterStatsLogger() {
672             return mInflaterStatsLogger;
673         }
674 
675         /** View provider for the renderer extension. */
getExtensionViewProvider()676         public @Nullable ProtoLayoutExtensionViewProvider getExtensionViewProvider() {
677             return mExtensionViewProvider;
678         }
679 
680         /** Whether animation is enabled, which decides whether to load contentUpdateAnimations. */
getAnimationEnabled()681         public boolean getAnimationEnabled() {
682             return mAnimationEnabled;
683         }
684 
685         /**
686          * Whether a "layout changing" data bind can be applied without the "value_for_layout" field
687          * being filled in. This is to support legacy apps which use layout-changing data binds
688          * before the full support was built.
689          */
getAllowLayoutChangingBindsWithoutDefault()690         public boolean getAllowLayoutChangingBindsWithoutDefault() {
691             return mAllowLayoutChangingBindsWithoutDefault;
692         }
693 
694         /** Whether to apply FONT_VARIANT_BODY as default variant. */
getApplyFontVariantBodyAsDefault()695         public boolean getApplyFontVariantBodyAsDefault() {
696             return mApplyFontVariantBodyAsDefault;
697         }
698 
699         /** Builder for the Config class. */
700         public static final class Builder {
701             private final @NonNull Context mUiContext;
702             private final @NonNull Layout mLayout;
703             private final @NonNull ResourceResolvers mLayoutResourceResolvers;
704             private @Nullable Executor mLoadActionExecutor;
705             private @Nullable LoadActionListener mLoadActionListener;
706             private @NonNull Resources mRendererResources;
707             private @Nullable ProtoLayoutTheme mProtoLayoutTheme;
708             private @Nullable ProtoLayoutDynamicDataPipeline mDataPipeline = null;
709             private boolean mAnimationEnabled = true;
710             private boolean mAllowLayoutChangingBindsWithoutDefault = false;
711             private @Nullable String mClickableIdExtra;
712 
713             private @Nullable LoggingUtils mLoggingUtils;
714             private @Nullable InflaterStatsLogger mInflaterStatsLogger;
715 
716             private @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider = null;
717 
718             private boolean mApplyFontVariantBodyAsDefault = false;
719 
720             /**
721              * @param uiContext A {@link Context} suitable for interacting with UI with.
722              * @param layout The layout to be rendered.
723              * @param layoutResourceResolvers Resolvers for the resources used for rendering this
724              *     layout.
725              */
Builder( @onNull Context uiContext, @NonNull Layout layout, @NonNull ResourceResolvers layoutResourceResolvers)726             public Builder(
727                     @NonNull Context uiContext,
728                     @NonNull Layout layout,
729                     @NonNull ResourceResolvers layoutResourceResolvers) {
730                 this.mUiContext = uiContext;
731                 this.mRendererResources = uiContext.getResources();
732                 this.mLayout = layout;
733                 this.mLayoutResourceResolvers = layoutResourceResolvers;
734             }
735 
736             /**
737              * Sets the Executor to dispatch loadActionListener on. This is required when setting
738              * {@link Builder#setLoadActionListener}.
739              */
setLoadActionExecutor(@onNull Executor loadActionExecutor)740             public @NonNull Builder setLoadActionExecutor(@NonNull Executor loadActionExecutor) {
741                 this.mLoadActionExecutor = loadActionExecutor;
742                 return this;
743             }
744 
745             /**
746              * Sets the listener for clicks that will cause contents to be reloaded. Defaults to
747              * no-op. This is required if the given layout contains a load action. When this is set,
748              * it's also required to set an executor with {@link Builder#setLoadActionExecutor}.
749              */
setLoadActionListener( @onNull LoadActionListener loadActionListener)750             public @NonNull Builder setLoadActionListener(
751                     @NonNull LoadActionListener loadActionListener) {
752                 this.mLoadActionListener = loadActionListener;
753                 return this;
754             }
755 
756             /**
757              * Sets the Renderer internal Resources object. This should be specified when loading
758              * the renderer from a separate APK. This can usually be retrieved with {@link
759              * android.content.pm.PackageManager#getResourcesForApplication(String)}. If not
760              * specified, this is retrieved from the Ui Context.
761              */
setRendererResources(@onNull Resources rendererResources)762             public @NonNull Builder setRendererResources(@NonNull Resources rendererResources) {
763                 this.mRendererResources = rendererResources;
764                 return this;
765             }
766 
767             /**
768              * Sets the theme to use for this ProtoLayoutInflater instance. This can be used to
769              * customise things like the default font family. If not set, the default theme is used.
770              */
setProtoLayoutTheme( @onNull ProtoLayoutTheme protoLayoutTheme)771             public @NonNull Builder setProtoLayoutTheme(
772                     @NonNull ProtoLayoutTheme protoLayoutTheme) {
773                 this.mProtoLayoutTheme = protoLayoutTheme;
774                 return this;
775             }
776 
777             /**
778              * Sets the pipeline for dynamic data. If null, the dynamic properties would not be
779              * registered for update.
780              */
setDynamicDataPipeline( @onNull ProtoLayoutDynamicDataPipeline dataPipeline)781             public @NonNull Builder setDynamicDataPipeline(
782                     @NonNull ProtoLayoutDynamicDataPipeline dataPipeline) {
783                 this.mDataPipeline = dataPipeline;
784                 return this;
785             }
786 
787             /** Sets the view provider for the renderer extension. */
setExtensionViewProvider( @onNull ProtoLayoutExtensionViewProvider extensionViewProvider)788             public @NonNull Builder setExtensionViewProvider(
789                     @NonNull ProtoLayoutExtensionViewProvider extensionViewProvider) {
790                 this.mExtensionViewProvider = extensionViewProvider;
791                 return this;
792             }
793 
794             /**
795              * Sets whether animation is enabled, which decides whether to load
796              * contentUpdateAnimations. Defaults to true.
797              */
setAnimationEnabled(boolean animationEnabled)798             public @NonNull Builder setAnimationEnabled(boolean animationEnabled) {
799                 this.mAnimationEnabled = animationEnabled;
800                 return this;
801             }
802 
803             /**
804              * Sets the ID for the Intent extra containing the ID of a Clickable. Defaults to {@link
805              * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified.
806              */
setClickableIdExtra(@onNull String clickableIdExtra)807             public @NonNull Builder setClickableIdExtra(@NonNull String clickableIdExtra) {
808                 this.mClickableIdExtra = clickableIdExtra;
809                 return this;
810             }
811 
812             /** Sets the debug logger used for extensive logging. */
setLoggingUtils(@onNull LoggingUtils loggingUtils)813             public @NonNull Builder setLoggingUtils(@NonNull LoggingUtils loggingUtils) {
814                 this.mLoggingUtils = loggingUtils;
815                 return this;
816             }
817 
818             /** Sets the stats logger used for telemetry. */
setInflaterStatsLogger( @onNull InflaterStatsLogger inflaterStatsLogger)819             public @NonNull Builder setInflaterStatsLogger(
820                     @NonNull InflaterStatsLogger inflaterStatsLogger) {
821                 this.mInflaterStatsLogger = inflaterStatsLogger;
822                 return this;
823             }
824 
825             /**
826              * Sets whether a "layout changing" data bind can be applied without the
827              * "value_for_layout" field being filled in, or being set to zero / empty. Defaults to
828              * false.
829              *
830              * <p>This is to support legacy apps which use layout-changing data bind before the full
831              * support was built.
832              */
setAllowLayoutChangingBindsWithoutDefault( boolean allowLayoutChangingBindsWithoutDefault)833             public @NonNull Builder setAllowLayoutChangingBindsWithoutDefault(
834                     boolean allowLayoutChangingBindsWithoutDefault) {
835                 this.mAllowLayoutChangingBindsWithoutDefault =
836                         allowLayoutChangingBindsWithoutDefault;
837                 return this;
838             }
839 
840             /** Apply FONT_VARIANT_BODY as default variant. */
setApplyFontVariantBodyAsDefault( boolean applyFontVariantBodyAsDefault)841             public @NonNull Builder setApplyFontVariantBodyAsDefault(
842                     boolean applyFontVariantBodyAsDefault) {
843                 this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault;
844                 return this;
845             }
846 
847             /** Builds a Config instance. */
build()848             public @NonNull Config build() {
849                 if (mLoadActionListener != null && mLoadActionExecutor == null) {
850                     throw new IllegalArgumentException(
851                             "A loadActionExecutor should always be set if setting a"
852                                     + " loadActionListener.");
853                 } else if (mLoadActionListener == null && mLoadActionExecutor != null) {
854                     throw new IllegalArgumentException(
855                             "A loadActionExecutor has been provided but no loadActionListener was"
856                                     + " set.");
857                 }
858 
859                 if (mLoadActionListener == null) {
860                     mLoadActionListener = p -> {};
861                 }
862 
863                 if (mProtoLayoutTheme == null) {
864                     this.mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext);
865                 }
866 
867                 if (mClickableIdExtra == null) {
868                     mClickableIdExtra = DEFAULT_CLICKABLE_ID_EXTRA;
869                 }
870 
871                 if (mInflaterStatsLogger == null) {
872                     mInflaterStatsLogger =
873                             new NoOpProviderStatsLogger("No implementation was provided")
874                                     .createInflaterStatsLogger();
875                 }
876 
877                 return new Config(
878                         mUiContext,
879                         mLayout,
880                         mLayoutResourceResolvers,
881                         mLoadActionExecutor,
882                         checkNotNull(mLoadActionListener),
883                         mRendererResources,
884                         checkNotNull(mProtoLayoutTheme),
885                         mDataPipeline,
886                         mExtensionViewProvider,
887                         checkNotNull(mClickableIdExtra),
888                         mLoggingUtils,
889                         mInflaterStatsLogger,
890                         mAnimationEnabled,
891                         mAllowLayoutChangingBindsWithoutDefault,
892                         mApplyFontVariantBodyAsDefault);
893             }
894         }
895     }
896 
ProtoLayoutInflater(@onNull Config config)897     public ProtoLayoutInflater(@NonNull Config config) {
898         // Wrap the Ui Context with a Theme from rendererResources, so that any implicit resource
899         // reads using the R class from this package are successful.
900         Theme rendererTheme = config.getRendererResources().newTheme();
901         rendererTheme.setTo(config.getUiContext().getTheme());
902         this.mUiContext = new ContextThemeWrapper(config.getUiContext(), rendererTheme);
903         this.mProtoLayoutTheme = config.getProtoLayoutTheme();
904         this.mProtoLayoutThemeContext =
905                 new ContextThemeWrapper(mUiContext, mProtoLayoutTheme.getTheme());
906         this.mLayoutProto = config.getLayout();
907         this.mLayoutResourceResolvers = config.getLayoutResourceResolvers();
908         this.mLoadActionExecutor = config.getLoadActionExecutor();
909         this.mLoadActionListener = config.getLoadActionListener();
910         this.mDataPipeline = Optional.ofNullable(config.getDynamicDataPipeline());
911         this.mAnimationEnabled = config.getAnimationEnabled();
912         this.mAllowLayoutChangingBindsWithoutDefault =
913                 config.getAllowLayoutChangingBindsWithoutDefault();
914         this.mClickableIdExtra = config.getClickableIdExtra();
915         this.mLoggingUtils = config.getLoggingUtils();
916         this.mInflaterStatsLogger = config.getInflaterStatsLogger();
917         this.mExtensionViewProvider = config.getExtensionViewProvider();
918         this.mApplyFontVariantBodyAsDefault = config.getApplyFontVariantBodyAsDefault();
919     }
920 
dpToPx(float dp)921     private int dpToPx(float dp) {
922         return round(dp * mUiContext.getResources().getDisplayMetrics().density);
923     }
924 
safeDpToPx(float dp)925     private int safeDpToPx(float dp) {
926         return max(0, dpToPx(dp));
927     }
928 
safeDpToPx(DpProp dpProp)929     private int safeDpToPx(DpProp dpProp) {
930         return safeDpToPx(dpProp.getValue());
931     }
932 
safeAspectRatioOrNull( ProportionalDimensionProp proportionalDimensionProp)933     private static @Nullable Float safeAspectRatioOrNull(
934             ProportionalDimensionProp proportionalDimensionProp) {
935         final int dividend = proportionalDimensionProp.getAspectRatioWidth();
936         final int divisor = proportionalDimensionProp.getAspectRatioHeight();
937 
938         if (dividend <= 0 || divisor <= 0) {
939             return null;
940         }
941         return (float) dividend / divisor;
942     }
943 
getSourceBounds(View v)944     private static Rect getSourceBounds(View v) {
945         final int[] pos = new int[2];
946         v.getLocationOnScreen(pos);
947 
948         return new Rect(
949                 /* left= */ pos[0],
950                 /* top= */ pos[1],
951                 /* right= */ pos[0] + v.getWidth(),
952                 /* bottom= */ pos[1] + v.getHeight());
953     }
954 
955     /**
956      * Generates a generic LayoutParameters for use by all components. This just defaults to setting
957      * the width/height to WRAP_CONTENT.
958      *
959      * @return The default layout parameters.
960      */
generateDefaultLayoutParams()961     private static LayoutParams generateDefaultLayoutParams() {
962         return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
963     }
964 
965     // dereference of possibly-null reference parent.getLayoutParams()
966     @SuppressWarnings("nullness:dereference.of.nullable")
updateLayoutParamsInLinearLayout( LinearLayoutProperties linearLayoutProperties, LayoutParams layoutParams, ContainerDimension width, ContainerDimension height)967     private LayoutParams updateLayoutParamsInLinearLayout(
968             LinearLayoutProperties linearLayoutProperties,
969             LayoutParams layoutParams,
970             ContainerDimension width,
971             ContainerDimension height) {
972         // This is a little bit fun. ProtoLayout's semantics is that dimension = expand should eat
973         // all remaining space in that dimension, but not grow the parent. This is easy for standard
974         // containers, but a little trickier in rows and columns on Android.
975         //
976         // A Row (LinearLayout) supports this with width=0 and weight>0. After doing a layout pass,
977         // it will assign all remaining space to elements with width=0 and weight>0, biased by the
978         // weight. This causes problems if there are two (or more) "expand" elements in a row, which
979         // is itself set to WRAP_CONTENTS, and one of those elements has a measured width (e.g.
980         // Text). In that case, the LinearLayout will measure the text, then ensure that all
981         // elements with a weight set have their widths set according to the weight. For us, that
982         // means that _all_ elements with expand=true will size themselves to the same width as the
983         // Text, pushing out the bounds of the parent row. This happens on columns too, but of
984         // course regarding height.
985         //
986         // To get around this, if an element with expand=true is added to a row that is WRAP_CONTENT
987         // (e.g. a row with no explicit width, that is not expanded), we ignore the expand=true, and
988         // set the inner element's width to WRAP_CONTENT too.
989 
990         LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(layoutParams);
991         LayoutParams parentLayoutParams = linearLayoutProperties.getRawLayoutParams();
992 
993         // Handle the width
994         if (linearLayoutProperties.getOrientation() == LinearLayout.HORIZONTAL
995                 && width.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
996             // If the parent container would not normally have "remaining space", ignore the
997             // expand=true.
998             if (parentLayoutParams.width == LayoutParams.WRAP_CONTENT) {
999                 linearLayoutParams.width = LayoutParams.WRAP_CONTENT;
1000             } else {
1001                 linearLayoutParams.width = 0;
1002                 float weight = width.getExpandedDimension().getLayoutWeight().getValue();
1003                 linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f;
1004             }
1005         } else {
1006             linearLayoutParams.width = dimensionToPx(width);
1007         }
1008 
1009         // And the height
1010         if (linearLayoutProperties.getOrientation() == LinearLayout.VERTICAL
1011                 && height.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
1012             // If the parent container would not normally have "remaining space", ignore the
1013             // expand=true.
1014             if (parentLayoutParams.height == LayoutParams.WRAP_CONTENT) {
1015                 linearLayoutParams.height = LayoutParams.WRAP_CONTENT;
1016             } else {
1017                 linearLayoutParams.height = 0;
1018                 float weight = height.getExpandedDimension().getLayoutWeight().getValue();
1019                 linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f;
1020             }
1021         } else {
1022             linearLayoutParams.height = dimensionToPx(height);
1023         }
1024 
1025         return linearLayoutParams;
1026     }
1027 
1028     /**
1029      * Creates {@link ContainerDimension} from the given {@link SpacerDimension}. If none of the
1030      * linear or expanded dimension are present, it defaults to linear dimension 0.
1031      */
spacerDimensionToContainerDimension( SpacerDimension spacerDimension)1032     private static ContainerDimension spacerDimensionToContainerDimension(
1033             SpacerDimension spacerDimension) {
1034         ContainerDimension.Builder containerDimension =
1035                 ContainerDimension.newBuilder().setLinearDimension(DpProp.newBuilder().setValue(0));
1036         if (spacerDimension.hasLinearDimension()) {
1037             containerDimension.setLinearDimension(spacerDimension.getLinearDimension());
1038         } else if (spacerDimension.hasExpandedDimension()) {
1039             containerDimension.setExpandedDimension(spacerDimension.getExpandedDimension());
1040         }
1041         return containerDimension.build();
1042     }
1043 
updateLayoutParams( ViewProperties viewProperties, LayoutParams layoutParams, ContainerDimension width, ContainerDimension height)1044     private LayoutParams updateLayoutParams(
1045             ViewProperties viewProperties,
1046             LayoutParams layoutParams,
1047             ContainerDimension width,
1048             ContainerDimension height) {
1049         if (viewProperties instanceof LinearLayoutProperties) {
1050             // LinearLayouts have a bunch of messy caveats in ProtoLayout when their children can be
1051             // expanded; factor that case out to keep this clean.
1052             return updateLayoutParamsInLinearLayout(
1053                     (LinearLayoutProperties) viewProperties, layoutParams, width, height);
1054         } else {
1055             layoutParams.width = dimensionToPx(width);
1056             layoutParams.height = dimensionToPx(height);
1057         }
1058 
1059         return layoutParams;
1060     }
1061 
resolveMinimumDimensions( View view, ContainerDimension width, ContainerDimension height)1062     private void resolveMinimumDimensions(
1063             View view, ContainerDimension width, ContainerDimension height) {
1064         if (width.getWrappedDimension().hasMinimumSize()) {
1065             view.setMinimumWidth(safeDpToPx(width.getWrappedDimension().getMinimumSize()));
1066         }
1067 
1068         if (height.getWrappedDimension().hasMinimumSize()) {
1069             view.setMinimumHeight(safeDpToPx(height.getWrappedDimension().getMinimumSize()));
1070         }
1071     }
1072 
1073     @VisibleForTesting()
getFrameLayoutGravity( HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment)1074     static int getFrameLayoutGravity(
1075             HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) {
1076         return horizontalAlignmentToGravity(horizontalAlignment)
1077                 | verticalAlignmentToGravity(verticalAlignment);
1078     }
1079 
1080     @SuppressLint("RtlHardcoded")
horizontalAlignmentToGravity(HorizontalAlignment alignment)1081     private static int horizontalAlignmentToGravity(HorizontalAlignment alignment) {
1082         switch (alignment) {
1083             case HORIZONTAL_ALIGN_START:
1084                 return Gravity.START;
1085             case HORIZONTAL_ALIGN_CENTER:
1086                 return Gravity.CENTER_HORIZONTAL;
1087             case HORIZONTAL_ALIGN_END:
1088                 return Gravity.END;
1089             case HORIZONTAL_ALIGN_LEFT:
1090                 return Gravity.LEFT;
1091             case HORIZONTAL_ALIGN_RIGHT:
1092                 return Gravity.RIGHT;
1093             case UNRECOGNIZED:
1094             case HORIZONTAL_ALIGN_UNDEFINED:
1095                 return HORIZONTAL_ALIGN_DEFAULT_GRAVITY;
1096         }
1097 
1098         return HORIZONTAL_ALIGN_DEFAULT_GRAVITY;
1099     }
1100 
verticalAlignmentToGravity(VerticalAlignment alignment)1101     private static int verticalAlignmentToGravity(VerticalAlignment alignment) {
1102         switch (alignment) {
1103             case VERTICAL_ALIGN_TOP:
1104                 return Gravity.TOP;
1105             case VERTICAL_ALIGN_CENTER:
1106                 return Gravity.CENTER_VERTICAL;
1107             case VERTICAL_ALIGN_BOTTOM:
1108                 return Gravity.BOTTOM;
1109             case UNRECOGNIZED:
1110             case VERTICAL_ALIGN_UNDEFINED:
1111                 return VERTICAL_ALIGN_DEFAULT_GRAVITY;
1112         }
1113 
1114         return VERTICAL_ALIGN_DEFAULT_GRAVITY;
1115     }
1116 
1117     @ArcLayout.LayoutParams.VerticalAlignment
verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment)1118     private static int verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment) {
1119         switch (alignment.getValue()) {
1120             case VERTICAL_ALIGN_TOP:
1121                 return ArcLayout.LayoutParams.VERTICAL_ALIGN_OUTER;
1122             case VERTICAL_ALIGN_CENTER:
1123                 return ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER;
1124             case VERTICAL_ALIGN_BOTTOM:
1125                 return ArcLayout.LayoutParams.VERTICAL_ALIGN_INNER;
1126             case UNRECOGNIZED:
1127             case VERTICAL_ALIGN_UNDEFINED:
1128                 return ARC_VERTICAL_ALIGN_DEFAULT;
1129         }
1130 
1131         return ARC_VERTICAL_ALIGN_DEFAULT;
1132     }
1133 
contentScaleModeToScaleType(ContentScaleMode contentScaleMode)1134     private static ScaleType contentScaleModeToScaleType(ContentScaleMode contentScaleMode) {
1135         switch (contentScaleMode) {
1136             case CONTENT_SCALE_MODE_FIT:
1137                 return ScaleType.FIT_CENTER;
1138             case CONTENT_SCALE_MODE_CROP:
1139                 return ScaleType.CENTER_CROP;
1140             case CONTENT_SCALE_MODE_FILL_BOUNDS:
1141                 return ScaleType.FIT_XY;
1142             case CONTENT_SCALE_MODE_UNDEFINED:
1143             case UNRECOGNIZED:
1144                 return IMAGE_DEFAULT_SCALE_TYPE;
1145         }
1146 
1147         return IMAGE_DEFAULT_SCALE_TYPE;
1148     }
1149 
spanVerticalAlignmentToImgSpanAlignment( SpanVerticalAlignmentProp alignment)1150     private static int spanVerticalAlignmentToImgSpanAlignment(
1151             SpanVerticalAlignmentProp alignment) {
1152         switch (alignment.getValue()) {
1153             case SPAN_VERTICAL_ALIGN_TEXT_BASELINE:
1154                 return ImageSpan.ALIGN_BASELINE;
1155             case SPAN_VERTICAL_ALIGN_BOTTOM:
1156                 return ImageSpan.ALIGN_BOTTOM;
1157             case SPAN_VERTICAL_ALIGN_UNDEFINED:
1158             case UNRECOGNIZED:
1159                 return SPAN_VERTICAL_ALIGN_DEFAULT;
1160         }
1161 
1162         return SPAN_VERTICAL_ALIGN_DEFAULT;
1163     }
1164 
1165     /**
1166      * Whether a font style is bold or not (has weight > 700). Note that this check is required,
1167      * even if you are using an explicitly bold font (e.g. Roboto-Bold), as Typeface still needs to
1168      * bold bit set to render properly.
1169      */
isBold(FontStyle fontStyle)1170     private static boolean isBold(FontStyle fontStyle) {
1171         // Even though we have weight axis too, this concept of bold and bold flag in Typeface is
1172         // different, so we only look at the FontWeight enum API here. Although this method could be
1173         // a simple equality check against FONT_WEIGHT_BOLD, we list all current cases here so that
1174         // this will become a compile time error as soon as a new FontWeight value is added to the
1175         // schema. If this fails to build, then this means that an int typeface style is no longer
1176         // enough to represent all FontWeight values and a customizable, per-weight text style must
1177         // be introduced to ProtoLayoutInflater to handle this. See b/176980535
1178         switch (fontStyle.getWeight().getValue()) {
1179             case FONT_WEIGHT_BOLD:
1180                 return true;
1181             case FONT_WEIGHT_NORMAL:
1182             case FONT_WEIGHT_MEDIUM:
1183             case FONT_WEIGHT_UNDEFINED:
1184             case UNRECOGNIZED:
1185                 return false;
1186         }
1187 
1188         return false;
1189     }
1190 
fontStyleToTypeface(FontStyle fontStyle)1191     private Typeface fontStyleToTypeface(FontStyle fontStyle) {
1192         String[] preferredFontFamilies = new String[fontStyle.getPreferredFontFamiliesCount() + 1];
1193         if (fontStyle.getPreferredFontFamiliesCount() > 0) {
1194             fontStyle.getPreferredFontFamiliesList().toArray(preferredFontFamilies);
1195         }
1196 
1197         // Add value from the FontVariant as fallback to work with providers using legacy
1198         // FONT_VARIANT API or with older system API.
1199         String fontVariantName;
1200         switch (fontStyle.getVariant().getValue()) {
1201             case FONT_VARIANT_TITLE:
1202                 fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_TITLE;
1203                 break;
1204             case UNRECOGNIZED:
1205             case FONT_VARIANT_BODY:
1206             default:
1207                 // fall through as this is default
1208                 fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_BODY;
1209                 break;
1210         }
1211         preferredFontFamilies[preferredFontFamilies.length - 1] = fontVariantName;
1212 
1213         FontSet fonts = mProtoLayoutTheme.getFontSet(preferredFontFamilies);
1214 
1215         // Only use FontWeight enum API. Weight axis is already covered by FontVariationSetting.
1216         switch (fontStyle.getWeight().getValue()) {
1217             case FONT_WEIGHT_BOLD:
1218                 return fonts.getBoldFont();
1219             case FONT_WEIGHT_MEDIUM:
1220                 return fonts.getMediumFont();
1221             case FONT_WEIGHT_NORMAL:
1222             case FONT_WEIGHT_UNDEFINED:
1223             case UNRECOGNIZED:
1224                 return fonts.getNormalFont();
1225         }
1226 
1227         return fonts.getNormalFont();
1228     }
1229 
fontStyleToTypefaceStyle(FontStyle fontStyle)1230     private static int fontStyleToTypefaceStyle(FontStyle fontStyle) {
1231         final boolean isBold = isBold(fontStyle);
1232         final boolean isItalic = fontStyle.getItalic().getValue();
1233 
1234         if (isBold && isItalic) {
1235             return Typeface.BOLD_ITALIC;
1236         } else if (isBold) {
1237             return Typeface.BOLD;
1238         } else if (isItalic) {
1239             return Typeface.ITALIC;
1240         } else {
1241             return Typeface.NORMAL;
1242         }
1243     }
1244 
1245     @SuppressWarnings("nullness")
createTypeface(FontStyle fontStyle)1246     private Typeface createTypeface(FontStyle fontStyle) {
1247         return Typeface.create(fontStyleToTypeface(fontStyle), fontStyleToTypefaceStyle(fontStyle));
1248     }
1249 
1250     /**
1251      * Returns whether or not the default style bits in Typeface can be used, or if we need to add
1252      * bold/italic flags there.
1253      */
hasDefaultTypefaceStyle(FontStyle fontStyle)1254     private static boolean hasDefaultTypefaceStyle(FontStyle fontStyle) {
1255         return !fontStyle.getItalic().getValue() && !isBold(fontStyle);
1256     }
1257 
toPx(SpProp spField)1258     private float toPx(SpProp spField) {
1259         return TypedValue.applyDimension(
1260                 COMPLEX_UNIT_SP, spField.getValue(), mUiContext.getResources().getDisplayMetrics());
1261     }
1262 
applyFontStyle( FontStyle style, TextView textView, String posId, Optional<PipelineMaker> pipelineMaker, boolean isAutoSizeAllowed)1263     private void applyFontStyle(
1264             FontStyle style,
1265             TextView textView,
1266             String posId,
1267             Optional<PipelineMaker> pipelineMaker,
1268             boolean isAutoSizeAllowed) {
1269         // Note: Underline must be applied as a Span to work correctly (as opposed to using
1270         // TextPaint#setTextUnderline). This is applied in the caller instead.
1271 
1272         // Need to supply typefaceStyle when creating the typeface (will select specialist
1273         // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic
1274         // flags in Paint if they're not supported by the given typeface). This is fine to do even
1275         // for variable fonts with weight axis, as if their weight axis is larger than 700 and BOLD
1276         // flag is on, it should be "bolded" two times - one for Typeface selection, one for axis
1277         // value.
1278         textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style));
1279 
1280         if (fontStyleHasSize(style)) {
1281             List<SpProp> sizes = style.getSizeList();
1282             int sizesCnt = sizes.size();
1283 
1284             if (sizesCnt == 1) {
1285                 // No autosizing needed.
1286                 textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(0).getValue());
1287             } else if (isAutoSizeAllowed && sizesCnt <= TEXT_AUTOSIZES_LIMIT) {
1288                 // We need to check values so that we are certain that there's at least 1 non zero
1289                 // value.
1290                 boolean atLeastOneCorrectSize =
1291                         sizes.stream()
1292                                         .mapToInt(sp -> (int) sp.getValue())
1293                                         .filter(sp -> sp > 0)
1294                                         .distinct()
1295                                         .count()
1296                                 > 0;
1297 
1298                 if (atLeastOneCorrectSize) {
1299                     // Max size is needed so that TextView leaves enough space for it. Otherwise,
1300                     // the text won't be able to grow.
1301                     int maxSize =
1302                             sizes.stream().mapToInt(sp -> (int) sp.getValue()).max().getAsInt();
1303                     textView.setTextSize(COMPLEX_UNIT_SP, maxSize);
1304 
1305                     // No need for sorting, TextView does that.
1306                     textView.setAutoSizeTextTypeUniformWithPresetSizes(
1307                             sizes.stream().mapToInt(spProp -> (int) spProp.getValue()).toArray(),
1308                             COMPLEX_UNIT_SP);
1309                 } else {
1310                     Log.w(
1311                             TAG,
1312                             "Trying to autosize text but no valid font sizes has been specified.");
1313                 }
1314             } else {
1315                 // Fallback where multiple values can't be used and the last value would be used.
1316                 // This can happen in two cases.
1317                 if (!isAutoSizeAllowed) {
1318                     // There is more than 1 size specified, but autosizing is not allowed.
1319                     Log.w(
1320                             TAG,
1321                             "Trying to autosize text with multiple font sizes where it's not "
1322                                     + "allowed. Ignoring all other sizes and using the last one.");
1323                 } else {
1324                     Log.w(
1325                             TAG,
1326                             "More than "
1327                                     + TEXT_AUTOSIZES_LIMIT
1328                                     + " sizes has been added for the text autosizing. Ignoring all"
1329                                     + " other sizes and using the last one.");
1330                 }
1331 
1332                 textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(sizesCnt - 1).getValue());
1333             }
1334         }
1335 
1336         if (style.hasLetterSpacing()) {
1337             textView.setLetterSpacing(style.getLetterSpacing().getValue());
1338         }
1339 
1340         if (style.hasColor()) {
1341             handleProp(style.getColor(), textView::setTextColor, posId, pipelineMaker);
1342         } else {
1343             textView.setTextColor(TEXT_COLOR_DEFAULT);
1344         }
1345 
1346         if (style.getSettingsCount() > 0) {
1347             applyFontSetting(style, textView);
1348         }
1349     }
1350 
applyFontSetting(@onNull FontStyle style, @NonNull TextView textView)1351     private void applyFontSetting(@NonNull FontStyle style, @NonNull TextView textView) {
1352         StringJoiner variationSettings = new StringJoiner(",");
1353         StringJoiner featureSettings = new StringJoiner(",");
1354 
1355         for (FontSetting setting : style.getSettingsList()) {
1356             String tag = "";
1357 
1358             switch (setting.getInnerCase()) {
1359                 case VARIATION:
1360                     FontVariationSetting variation = setting.getVariation();
1361                     tag = toTagString(variation.getAxisTag());
1362 
1363                     if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) {
1364                         variationSettings.add("'" + tag + "' " + variation.getValue());
1365                     } else {
1366                         // Skip not supported tags.
1367                         Log.d(TAG, "FontVariation axes tag " + tag + " is not supported.");
1368                     }
1369 
1370                     break;
1371                 case FEATURE:
1372                     FontFeatureSetting feature = setting.getFeature();
1373                     tag = toTagString(feature.getTag());
1374 
1375                     if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) {
1376                         featureSettings.add("'" + tag + "'");
1377                     } else {
1378                         // Skip not supported tags.
1379                         Log.d(TAG, "FontFeature tag " + tag + " is not supported.");
1380                     }
1381 
1382                     break;
1383                 case INNER_NOT_SET:
1384                     break;
1385             }
1386         }
1387 
1388         textView.setFontVariationSettings(variationSettings.toString());
1389         textView.setFontFeatureSettings(featureSettings.toString());
1390     }
1391 
1392     /** Given the integer representation of 4 characters ASCII code, returns the String of it. */
toTagString(int tagCode)1393     private static @NonNull String toTagString(int tagCode) {
1394         return new String(ByteBuffer.allocate(4).putInt(tagCode).array(), US_ASCII);
1395     }
1396 
applyFontStyle(FontStyle style, CurvedTextView textView)1397     private void applyFontStyle(FontStyle style, CurvedTextView textView) {
1398         // Need to supply typefaceStyle when creating the typeface (will select specialist
1399         // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic
1400         // flags in Paint if they're not supported by the given typeface).
1401         textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style));
1402 
1403         if (fontStyleHasSize(style)) {
1404             // We are using the last added size in the FontStyle because ArcText doesn't support
1405             // autosizing. This is the same behaviour as it was before size has made repeated.
1406             if (style.getSizeList().size() > 1) {
1407                 Log.w(
1408                         TAG,
1409                         "Font size with multiple values has been used on Arc Text. Ignoring "
1410                                 + "all size except the first one.");
1411             }
1412             textView.setTextSize(toPx(style.getSize(style.getSizeCount() - 1)));
1413         }
1414     }
1415 
dispatchLaunchActionIntent(Intent i)1416     void dispatchLaunchActionIntent(Intent i) {
1417         ActivityInfo ai = i.resolveActivityInfo(mUiContext.getPackageManager(), /* flags= */ 0);
1418 
1419         if (ai != null && ai.exported && (ai.permission == null || ai.permission.isEmpty())) {
1420             mUiContext.startActivity(i);
1421         }
1422     }
1423 
applyClickable( @onNull View view, @Nullable View wrapper, @NonNull Clickable clickable, boolean extendTouchTarget)1424     private void applyClickable(
1425             @NonNull View view,
1426             @Nullable View wrapper,
1427             @NonNull Clickable clickable,
1428             boolean extendTouchTarget) {
1429         view.setTag(R.id.clickable_id_tag, clickable.getId());
1430 
1431         boolean hasAction = false;
1432         switch (clickable.getOnClick().getValueCase()) {
1433             case LAUNCH_ACTION:
1434                 Intent i =
1435                         buildLaunchActionIntent(
1436                                 clickable.getOnClick().getLaunchAction(),
1437                                 clickable.getId(),
1438                                 mClickableIdExtra);
1439                 if (i != null) {
1440                     hasAction = true;
1441                     view.setOnClickListener(
1442                             v -> {
1443                                 i.setSourceBounds(getSourceBounds(view));
1444                                 dispatchLaunchActionIntent(i);
1445                             });
1446                 }
1447                 break;
1448             case LOAD_ACTION:
1449                 hasAction = true;
1450                 if (mLoadActionExecutor == null) {
1451                     Log.w(TAG, "Ignoring load action since an executor has not been provided.");
1452                     break;
1453                 }
1454                 view.setOnClickListener(
1455                         v ->
1456                                 checkNotNull(mLoadActionExecutor)
1457                                         .execute(
1458                                                 () -> {
1459                                                     Log.d(
1460                                                             TAG,
1461                                                             "Executing LoadAction listener"
1462                                                                     + " with clickable id: "
1463                                                                     + clickable.getId());
1464                                                     mLoadActionListener.onClick(
1465                                                             buildState(
1466                                                                     clickable
1467                                                                             .getOnClick()
1468                                                                             .getLoadAction(),
1469                                                                     clickable.getId()));
1470                                                 }));
1471                 break;
1472             case VALUE_NOT_SET:
1473                 break;
1474         }
1475 
1476         if (hasAction) {
1477             if (!clickable.hasVisualFeedbackEnabled() || clickable.getVisualFeedbackEnabled()) {
1478                 // Apply ripple effect
1479                 applyRippleEffect(view);
1480             }
1481 
1482             if (!extendTouchTarget) {
1483                 // For temporarily disable the touch size check for element on arc
1484                 return;
1485             }
1486 
1487             view.post(
1488                     () -> {
1489                         // Use the logical parent (the parent in the proto) for the touch delegation
1490                         // 1. When the direct parent is the wrapper view for layout sizing, it could
1491                         // no provide extra space around the view.
1492                         // 2. the ancestor view further up in the hierarchy are also NOT used, even
1493                         // when the logical parent might not have enough space around the view.
1494                         // Below are the considerations:
1495                         //  a. The clickables of the same logical parent should have same priority
1496                         //   of touch delegations. Only with this, we could avoid breaking the
1497                         //   rule of propagating touch event upwards in order. The higher the
1498                         //   ancester which forwards the touch event, the later the event is been
1499                         //   propagated to.
1500                         //  b. The minimal clickable size is not layout affecting, so unreasonable
1501                         //    big touch target size should not be guaranteed which leads to
1502                         //    unpredictable behavior. With it limited within the logical parent
1503                         //    bounds, the behaviour is predictable.
1504                         ViewGroup logicalParent;
1505                         if (wrapper != null) {
1506                             logicalParent = (ViewGroup) wrapper.getParent();
1507                         } else {
1508                             logicalParent = (ViewGroup) view.getParent();
1509                         }
1510                         if (logicalParent != null) {
1511                             float widthDp = DEFAULT_MIN_CLICKABLE_SIZE_DP;
1512                             float heightDp = DEFAULT_MIN_CLICKABLE_SIZE_DP;
1513                             if (clickable.hasMinimumClickableWidth()) {
1514                                 widthDp = clickable.getMinimumClickableWidth().getValue();
1515                             }
1516                             if (clickable.hasMinimumClickableHeight()) {
1517                                 heightDp = clickable.getMinimumClickableHeight().getValue();
1518                             }
1519                             extendClickableAreaIfNeeded(
1520                                     view, logicalParent, safeDpToPx(widthDp), safeDpToPx(heightDp));
1521                         }
1522                     });
1523         }
1524     }
1525 
applyRippleEffect(@onNull View view)1526     private void applyRippleEffect(@NonNull View view) {
1527         if (mProtoLayoutTheme.getRippleResId() != 0) {
1528             try {
1529                 view.setForeground(
1530                         mProtoLayoutTheme
1531                                 .getTheme()
1532                                 .getDrawable(mProtoLayoutTheme.getRippleResId()));
1533                 return;
1534             } catch (Resources.NotFoundException e) {
1535                 Log.e(
1536                         TAG,
1537                         "Could not resolve the provided ripple resource id from the theme, "
1538                                 + "fallback to use the system default ripple.");
1539             }
1540         }
1541 
1542         // Use the system default ripple effect by resolving selectableItemBackground against the
1543         // mUiContext theme, which provides the drawable.
1544         TypedValue outValue = new TypedValue();
1545         boolean isValid =
1546                 mUiContext
1547                         .getTheme()
1548                         .resolveAttribute(
1549                                 android.R.attr.selectableItemBackground,
1550                                 outValue,
1551                                 /* resolveRefs= */ true);
1552         if (isValid) {
1553             view.setForeground(mUiContext.getDrawable(outValue.resourceId));
1554         } else {
1555             Log.e(
1556                     TAG,
1557                     "Could not resolve android.R.attr.selectableItemBackground from Ui Context.");
1558         }
1559     }
1560 
1561     /*
1562      * Attempt to extend the clickable area if the view bound falls below the required minimum
1563      * clickable width/height. The clickable area is extended by delegating the touch event of its
1564      * surrounding space from its logical parent. Note that, the view's direct parent might not
1565      * be the logical parent in the proto. For example, ImageView is wrapped in a RatioViewWrapper
1566      */
extendClickableAreaIfNeeded( @onNull View view, @NonNull ViewGroup logicalParent, int minClickableWidthPx, int minClickableHeightPx)1567     private static void extendClickableAreaIfNeeded(
1568             @NonNull View view,
1569             @NonNull ViewGroup logicalParent,
1570             int minClickableWidthPx,
1571             int minClickableHeightPx) {
1572         Rect hitRect = new Rect();
1573         view.getHitRect(hitRect);
1574         if (minClickableWidthPx > hitRect.width() || minClickableHeightPx > hitRect.height()) {
1575 
1576             ViewGroup directParent = (ViewGroup) view.getParent();
1577             while (logicalParent != directParent) {
1578                 if (directParent == null) {
1579                     return;
1580                 }
1581                 Rect rect = new Rect();
1582                 directParent.getHitRect(rect);
1583                 hitRect.offset(rect.left, rect.top);
1584                 directParent = (ViewGroup) directParent.getParent();
1585             }
1586 
1587             Rect actualRect = new Rect(hitRect);
1588             // Negative inset makes the rect wider.
1589             hitRect.inset(
1590                     min(0, hitRect.width() - minClickableWidthPx) / 2,
1591                     min(0, hitRect.height() - minClickableHeightPx) / 2);
1592 
1593             addTouchDelegate(logicalParent, new TouchDelegateComposite(view, actualRect, hitRect));
1594         }
1595     }
1596 
addTouchDelegate( @onNull View parent, @NonNull TouchDelegateComposite touchDelegateComposite)1597     private static void addTouchDelegate(
1598             @NonNull View parent, @NonNull TouchDelegateComposite touchDelegateComposite) {
1599         TouchDelegateComposite touchDelegate;
1600         if (parent.getTouchDelegate() != null) {
1601             touchDelegate = (TouchDelegateComposite) parent.getTouchDelegate();
1602             touchDelegate.mergeFrom(touchDelegateComposite);
1603         } else {
1604             touchDelegate = touchDelegateComposite;
1605         }
1606         parent.setTouchDelegate(touchDelegate);
1607     }
1608 
applyPadding(View view, Padding padding)1609     private void applyPadding(View view, Padding padding) {
1610         if (padding.getRtlAware().getValue()) {
1611             view.setPaddingRelative(
1612                     safeDpToPx(padding.getStart()),
1613                     safeDpToPx(padding.getTop()),
1614                     safeDpToPx(padding.getEnd()),
1615                     safeDpToPx(padding.getBottom()));
1616         } else {
1617             view.setPadding(
1618                     safeDpToPx(padding.getStart()),
1619                     safeDpToPx(padding.getTop()),
1620                     safeDpToPx(padding.getEnd()),
1621                     safeDpToPx(padding.getBottom()));
1622         }
1623     }
1624 
applyBackground( View view, @Nullable View wrapper, Background background, @Nullable BackgroundDrawable drawable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)1625     private BackgroundDrawable applyBackground(
1626             View view,
1627             @Nullable View wrapper,
1628             Background background,
1629             @Nullable BackgroundDrawable drawable,
1630             String posId,
1631             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
1632         if (drawable == null) {
1633             drawable = new BackgroundDrawable();
1634         }
1635 
1636         if (background.hasBrush() && background.getBrush().hasLinearGradient()) {
1637             try {
1638                 LinearGradientHelper linearGradientHelper =
1639                         new LinearGradientHelper(
1640                                 background.getBrush().getLinearGradient(),
1641                                 wrapper != null ? wrapper : view,
1642                                 pipelineMaker,
1643                                 posId,
1644                                 view::invalidate);
1645                 drawable.setLinearGradientHelper(linearGradientHelper);
1646             } catch (IllegalArgumentException e) {
1647                 Log.e(TAG, "Failed to apply linear gradient to background", e);
1648             }
1649         } else if (background.hasColor()) {
1650             handleProp(background.getColor(), drawable::setColor, posId, pipelineMaker);
1651         }
1652 
1653         applyCornerToBackground(view, background, drawable);
1654 
1655         return drawable;
1656     }
1657 
applyCornerToBackground( View view, @NonNull Background background, @NonNull BackgroundDrawable drawable)1658     private void applyCornerToBackground(
1659             View view, @NonNull Background background, @NonNull BackgroundDrawable drawable) {
1660         if (!background.hasCorner()) {
1661             return;
1662         }
1663 
1664         // apply corner
1665         final Corner corner = background.getCorner();
1666         final int radiusPx = corner.hasRadius() ? safeDpToPx(corner.getRadius()) : 0;
1667 
1668         // Sort out specific corner radii.
1669         float[] radii = new float[8];
1670         Arrays.fill(radii, radiusPx);
1671         if (corner.hasTopLeftRadius()) {
1672             setCornerRadiusToArray(corner.getTopLeftRadius(), radii, /* index= */ 0);
1673         }
1674         if (corner.hasTopRightRadius()) {
1675             setCornerRadiusToArray(corner.getTopRightRadius(), radii, /* index= */ 2);
1676         }
1677         if (corner.hasBottomRightRadius()) {
1678             setCornerRadiusToArray(corner.getBottomRightRadius(), radii, /* index= */ 4);
1679         }
1680         if (corner.hasBottomLeftRadius()) {
1681             setCornerRadiusToArray(corner.getBottomLeftRadius(), radii, /* index= */ 6);
1682         }
1683 
1684         boolean isCornerWithEqualRadii = areAllEqual(radii, /* count= */ 8);
1685         if (isCornerWithEqualRadii) {
1686             if (radii[0] == 0) {
1687                 return;
1688             }
1689             // We will clip the drawable view with all equal corner radii by using outline, but we
1690             // still
1691             // need to set corners for cases when we use border.
1692             drawable.setCornerRadius(radii[0]);
1693 
1694             // Set outline provider to correctly clip to the given corner, when it's some form of
1695             // rounded
1696             // rectangular (i.e. all equal corner radii). This can't be done automatically by the
1697             // drawable
1698             // due to the aliasing issues. See b/357061501 for more details.
1699             view.setOutlineProvider(
1700                     new ViewOutlineProvider() {
1701                         @Override
1702                         public void getOutline(View view, Outline outline) {
1703                             outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radii[0]);
1704                             outline.setAlpha(0.0f);
1705                         }
1706                     });
1707             view.setClipToOutline(true);
1708         } else {
1709             drawable.setCornerRadii(radii);
1710         }
1711     }
1712 
setCornerRadiusToArray( @onNull CornerRadius cornerRadius, float[] radii, int index)1713     private void setCornerRadiusToArray(
1714             @NonNull CornerRadius cornerRadius, float[] radii, int index) {
1715         if (cornerRadius.hasX()) {
1716             radii[index] = safeDpToPx(cornerRadius.getX());
1717         }
1718         if (cornerRadius.hasY()) {
1719             radii[index + 1] = safeDpToPx(cornerRadius.getY());
1720         }
1721     }
1722 
areAllEqual(float[] array, int count)1723     private static boolean areAllEqual(float[] array, int count) {
1724         for (int i = 1; i < count; i++) {
1725             if (array[0] != array[i]) {
1726                 return false;
1727             }
1728         }
1729         return true;
1730     }
1731 
applyBorder( Border border, @Nullable BackgroundDrawable drawable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)1732     private BackgroundDrawable applyBorder(
1733             Border border,
1734             @Nullable BackgroundDrawable drawable,
1735             String posId,
1736             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
1737         if (drawable == null) {
1738             drawable = new BackgroundDrawable();
1739         }
1740 
1741         BackgroundDrawable finalDrawable = drawable;
1742         int width = safeDpToPx(border.getWidth());
1743         handleProp(
1744                 border.getColor(),
1745                 borderColor -> finalDrawable.setStroke(width, borderColor),
1746                 posId,
1747                 pipelineMaker);
1748 
1749         return drawable;
1750     }
1751 
applyTransformation( @onNull View view, @NonNull Transformation transformation, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)1752     private void applyTransformation(
1753             @NonNull View view,
1754             @NonNull Transformation transformation,
1755             @NonNull String posId,
1756             @NonNull Optional<PipelineMaker> pipelineMaker) {
1757         // In a composite transformation, the order of applying the individual transformations
1758         // does not affect the result, as Android view does the transformation in fixed order by
1759         // first scale, then rotate then translate.
1760 
1761         if (transformation.hasTranslationX()) {
1762             handleProp(
1763                     transformation.getTranslationX(),
1764                     translationX -> view.setTranslationX(dpToPx(translationX)),
1765                     posId,
1766                     pipelineMaker);
1767         }
1768 
1769         if (transformation.hasTranslationY()) {
1770             handleProp(
1771                     transformation.getTranslationY(),
1772                     translationY -> view.setTranslationY(dpToPx(translationY)),
1773                     posId,
1774                     pipelineMaker);
1775         }
1776 
1777         if (transformation.hasScaleX()) {
1778             handleProp(transformation.getScaleX(), view::setScaleX, posId, pipelineMaker);
1779         }
1780 
1781         if (transformation.hasScaleY()) {
1782             handleProp(transformation.getScaleY(), view::setScaleY, posId, pipelineMaker);
1783         }
1784 
1785         if (transformation.hasRotation()) {
1786             handleProp(transformation.getRotation(), view::setRotation, posId, pipelineMaker);
1787         }
1788 
1789         if (transformation.hasPivotX()) {
1790             applyTransformationPivot(
1791                     view,
1792                     transformation.getPivotX(),
1793                     offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.X),
1794                     locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.X),
1795                     posId,
1796                     pipelineMaker);
1797         }
1798 
1799         if (transformation.hasPivotY()) {
1800             applyTransformationPivot(
1801                     view,
1802                     transformation.getPivotY(),
1803                     offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.Y),
1804                     locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.Y),
1805                     posId,
1806                     pipelineMaker);
1807         }
1808     }
1809 
1810     private enum PivotType {
1811         X,
1812         Y
1813     }
1814 
setPivotInOffsetDp(View view, float pivot, PivotType type)1815     private void setPivotInOffsetDp(View view, float pivot, PivotType type) {
1816         if (type == PivotType.X) {
1817             view.setPivotX(dpToPx(pivot) + view.getWidth() * 0.5f);
1818         } else {
1819             view.setPivotY(dpToPx(pivot) + view.getHeight() * 0.5f);
1820         }
1821     }
1822 
setPivotInLocationRatio(View view, float pivot, PivotType type)1823     private void setPivotInLocationRatio(View view, float pivot, PivotType type) {
1824         if (type == PivotType.X) {
1825             view.setPivotX(pivot * view.getWidth());
1826         } else {
1827             view.setPivotY(pivot * view.getHeight());
1828         }
1829     }
1830 
applyTransformationPivot( View view, PivotDimension pivotDimension, Consumer<Float> consumerOffsetDp, Consumer<Float> consumerLocationRatio, @NonNull String posId, Optional<PipelineMaker> pipelineMaker)1831     private void applyTransformationPivot(
1832             View view,
1833             PivotDimension pivotDimension,
1834             Consumer<Float> consumerOffsetDp,
1835             Consumer<Float> consumerLocationRatio,
1836             @NonNull String posId,
1837             Optional<PipelineMaker> pipelineMaker) {
1838         switch (pivotDimension.getInnerCase()) {
1839             case OFFSET_DP:
1840                 DpProp offset = pivotDimension.getOffsetDp();
1841                 handleProp(
1842                         offset,
1843                         value -> view.post(() -> consumerOffsetDp.accept(value)),
1844                         consumerOffsetDp,
1845                         posId,
1846                         pipelineMaker);
1847                 break;
1848             case LOCATION_RATIO:
1849                 FloatProp ratio = pivotDimension.getLocationRatio().getRatio();
1850                 handleProp(
1851                         ratio,
1852                         value -> view.post(() -> consumerLocationRatio.accept(value)),
1853                         consumerLocationRatio,
1854                         posId,
1855                         pipelineMaker);
1856                 break;
1857             case INNER_NOT_SET:
1858                 Log.w(
1859                         TAG,
1860                         "PivotDimension has an unknown dimension type: "
1861                                 + pivotDimension.getInnerCase().name());
1862         }
1863     }
1864 
applyModifiers( @onNull View view, @Nullable View wrapper, @NonNull Modifiers modifiers, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)1865     private View applyModifiers(
1866             @NonNull View view,
1867             @Nullable View wrapper, // The wrapper view for layout sizing, if any
1868             @NonNull Modifiers modifiers,
1869             @NonNull String posId,
1870             @NonNull Optional<PipelineMaker> pipelineMaker) {
1871         if (modifiers.hasVisible()) {
1872             applyVisible(
1873                     view,
1874                     modifiers.getVisible(),
1875                     posId,
1876                     pipelineMaker,
1877                     visible -> visible ? VISIBLE : INVISIBLE);
1878         } else if (modifiers.hasHidden()) {
1879             // This is a deprecated field
1880             applyVisible(
1881                     view,
1882                     modifiers.getHidden(),
1883                     posId,
1884                     pipelineMaker,
1885                     hidden -> hidden ? INVISIBLE : VISIBLE);
1886         }
1887 
1888         if (modifiers.hasClickable()) {
1889             applyClickable(view, wrapper, modifiers.getClickable(), /* extendTouchTarget= */ true);
1890         }
1891 
1892         if (modifiers.hasSemantics()) {
1893             applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker);
1894         }
1895 
1896         if (modifiers.hasPadding()) {
1897             applyPadding(view, modifiers.getPadding());
1898         }
1899 
1900         BackgroundDrawable backgroundDrawable = null;
1901 
1902         if (modifiers.hasBackground()) {
1903             backgroundDrawable =
1904                     applyBackground(
1905                             view,
1906                             wrapper,
1907                             modifiers.getBackground(),
1908                             backgroundDrawable,
1909                             posId,
1910                             pipelineMaker);
1911         }
1912 
1913         if (modifiers.hasBorder()) {
1914             backgroundDrawable =
1915                     applyBorder(modifiers.getBorder(), backgroundDrawable, posId, pipelineMaker);
1916         }
1917 
1918         if (backgroundDrawable != null) {
1919             view.setBackground(backgroundDrawable);
1920         }
1921 
1922         if (mAnimationEnabled && modifiers.hasContentUpdateAnimation()) {
1923             pipelineMaker.ifPresent(
1924                     p ->
1925                             p.storeAnimatedVisibilityFor(
1926                                     posId, modifiers.getContentUpdateAnimation()));
1927         }
1928 
1929         if (modifiers.hasTransformation()) {
1930             applyTransformation(
1931                     wrapper == null ? view : wrapper,
1932                     modifiers.getTransformation(),
1933                     posId,
1934                     pipelineMaker);
1935         }
1936 
1937         if (modifiers.hasOpacity()) {
1938             handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker);
1939         }
1940 
1941         return view;
1942     }
1943 
applyVisible( View view, BoolProp visible, String posId, Optional<PipelineMaker> pipelineMaker, Function<Boolean, Integer> toViewVisibility)1944     private void applyVisible(
1945             View view,
1946             BoolProp visible,
1947             String posId,
1948             Optional<PipelineMaker> pipelineMaker,
1949             Function<Boolean, Integer> toViewVisibility) {
1950         handleProp(
1951                 visible,
1952                 visibility -> view.setVisibility(toViewVisibility.apply(visibility)),
1953                 posId,
1954                 pipelineMaker);
1955     }
1956 
1957     @SuppressWarnings("RestrictTo")
getEnterAnimations( @onNull EnterTransition enterTransition, @NonNull View view)1958     static AnimationSet getEnterAnimations(
1959             @NonNull EnterTransition enterTransition, @NonNull View view) {
1960         AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false);
1961         if (enterTransition.hasFadeIn()) {
1962             FadeInTransition fadeIn = enterTransition.getFadeIn();
1963             AlphaAnimation alphaAnimation =
1964                     new AlphaAnimation(fadeIn.getInitialAlpha(), FADE_IN_TARGET_ALPHA);
1965 
1966             // If it doesn't exist, this will be default object.
1967             AnimationSpec spec = fadeIn.getAnimationSpec();
1968 
1969             AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec);
1970             animations.addAnimation(alphaAnimation);
1971         }
1972 
1973         if (enterTransition.hasSlideIn()) {
1974             SlideInTransition slideIn = enterTransition.getSlideIn();
1975 
1976             // If it doesn't exist, this will be default object.
1977             AnimationSpec spec = slideIn.getAnimationSpec();
1978 
1979             float fromXDelta = 0;
1980             float toXDelta = 0;
1981             float fromYDelta = 0;
1982             float toYDelta = 0;
1983 
1984             switch (slideIn.getDirectionValue()) {
1985                 case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE:
1986                     // Do the same as for horizontal as that is default.
1987                 case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE:
1988                 case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE:
1989                     fromXDelta = getInitialOffsetOrDefaultX(slideIn, view);
1990                     break;
1991                 case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE:
1992                 case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE:
1993                     fromYDelta = getInitialOffsetOrDefaultY(slideIn, view);
1994                     break;
1995             }
1996 
1997             TranslateAnimation translateAnimation =
1998                     new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta);
1999             AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec);
2000             animations.addAnimation(translateAnimation);
2001         }
2002         return animations;
2003     }
2004 
2005     @SuppressWarnings("RestrictTo")
getExitAnimations( @onNull ExitTransition exitTransition, @NonNull View view)2006     static AnimationSet getExitAnimations(
2007             @NonNull ExitTransition exitTransition, @NonNull View view) {
2008         AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false);
2009         if (exitTransition.hasFadeOut()) {
2010             FadeOutTransition fadeOut = exitTransition.getFadeOut();
2011             AlphaAnimation alphaAnimation =
2012                     new AlphaAnimation(FADE_OUT_INITIAL_ALPHA, fadeOut.getTargetAlpha());
2013 
2014             // If it doesn't exist, this will be default object.
2015             AnimationSpec spec = fadeOut.getAnimationSpec();
2016 
2017             // Indefinite Exit animations aren't allowed.
2018             if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) {
2019                 AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec);
2020                 animations.addAnimation(alphaAnimation);
2021             }
2022         }
2023 
2024         if (exitTransition.hasSlideOut()) {
2025             SlideOutTransition slideOut = exitTransition.getSlideOut();
2026 
2027             // If it doesn't exist, this will be default object.
2028             AnimationSpec spec = slideOut.getAnimationSpec();
2029             // Indefinite Exit animations aren't allowed.
2030             if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) {
2031                 float fromXDelta = 0;
2032                 float toXDelta = 0;
2033                 float fromYDelta = 0;
2034                 float toYDelta = 0;
2035 
2036                 switch (slideOut.getDirectionValue()) {
2037                     case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE:
2038                         // Do the same as for horizontal as that is default.
2039                     case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE:
2040                     case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE:
2041                         toXDelta = getTargetOffsetOrDefaultX(slideOut, view);
2042                         break;
2043                     case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE:
2044                     case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE:
2045                         toYDelta = getTargetOffsetOrDefaultY(slideOut, view);
2046                         break;
2047                 }
2048 
2049                 TranslateAnimation translateAnimation =
2050                         new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta);
2051                 AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec);
2052                 animations.addAnimation(translateAnimation);
2053             }
2054         }
2055         return animations;
2056     }
2057 
2058     /**
2059      * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which
2060      * * is sliding to the left or right parent edge, depending on the direction.
2061      */
getInitialOffsetOrDefaultX( @onNull SlideInTransition slideIn, @NonNull View view)2062     private static float getInitialOffsetOrDefaultX(
2063             @NonNull SlideInTransition slideIn, @NonNull View view) {
2064         int sign =
2065                 slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE
2066                         ? -1
2067                         : 1;
2068         if (slideIn.hasInitialSlideBound()) {
2069 
2070             switch (slideIn.getInitialSlideBound().getInnerCase()) {
2071                 case LINEAR_BOUND:
2072                     return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign;
2073                 case PARENT_BOUND:
2074                     if (slideIn.getInitialSlideBound().getParentBound().getSnapTo()
2075                             == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
2076                         return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight())
2077                                 * sign;
2078                     }
2079                     // fall through
2080                 case INNER_NOT_SET:
2081                     break;
2082             }
2083         }
2084         return (sign == -1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign;
2085     }
2086 
2087     /**
2088      * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which
2089      * * is sliding to the left or right parent edge, depending on the direction.
2090      */
getInitialOffsetOrDefaultY( @onNull SlideInTransition slideIn, @NonNull View view)2091     private static float getInitialOffsetOrDefaultY(
2092             @NonNull SlideInTransition slideIn, @NonNull View view) {
2093         int sign =
2094                 slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE
2095                         ? -1
2096                         : 1;
2097         if (slideIn.hasInitialSlideBound()) {
2098 
2099             switch (slideIn.getInitialSlideBound().getInnerCase()) {
2100                 case LINEAR_BOUND:
2101                     return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign;
2102                 case PARENT_BOUND:
2103                     if (slideIn.getInitialSlideBound().getParentBound().getSnapTo()
2104                             == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
2105                         return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom())
2106                                 * sign;
2107                     }
2108                     // fall through
2109                 case INNER_NOT_SET:
2110                     break;
2111             }
2112         }
2113         return (sign == -1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign;
2114     }
2115 
2116     /**
2117      * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value
2118      * which is sliding to the left or right parent edge, depending on the direction.
2119      */
getTargetOffsetOrDefaultX( @onNull SlideOutTransition slideOut, @NonNull View view)2120     private static float getTargetOffsetOrDefaultX(
2121             @NonNull SlideOutTransition slideOut, @NonNull View view) {
2122         int sign =
2123                 slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE
2124                         ? 1
2125                         : -1;
2126         if (slideOut.hasTargetSlideBound()) {
2127 
2128             switch (slideOut.getTargetSlideBound().getInnerCase()) {
2129                 case LINEAR_BOUND:
2130                     return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign;
2131                 case PARENT_BOUND:
2132                     if (slideOut.getTargetSlideBound().getParentBound().getSnapTo()
2133                             == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
2134                         return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight())
2135                                 * sign;
2136                     }
2137                     // fall through
2138                 case INNER_NOT_SET:
2139                     break;
2140             }
2141         }
2142         return (sign == 1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign;
2143     }
2144 
2145     /**
2146      * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value
2147      * which is sliding to the top or bottom parent edge, depending on the direction.
2148      */
getTargetOffsetOrDefaultY( @onNull SlideOutTransition slideOut, @NonNull View view)2149     private static float getTargetOffsetOrDefaultY(
2150             @NonNull SlideOutTransition slideOut, @NonNull View view) {
2151         int sign =
2152                 slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE
2153                         ? 1
2154                         : -1;
2155         if (slideOut.hasTargetSlideBound()) {
2156 
2157             switch (slideOut.getTargetSlideBound().getInnerCase()) {
2158                 case LINEAR_BOUND:
2159                     return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign;
2160                 case PARENT_BOUND:
2161                     if (slideOut.getTargetSlideBound().getParentBound().getSnapTo()
2162                             == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
2163                         return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom())
2164                                 * sign;
2165                     }
2166                     // fall through
2167                 case INNER_NOT_SET:
2168                     break;
2169             }
2170         }
2171         return (sign == 1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign;
2172     }
2173 
2174     // This is a little nasty; ArcLayout.Widget is just an interface, so we have no guarantee that
2175     // the instance also extends View (as it should). Instead, just take a View in and rename this,
2176     // and check that it's an ArcLayout.Widget internally.
applyModifiersToArcLayoutView( View view, ArcModifiers modifiers, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2177     private View applyModifiersToArcLayoutView(
2178             View view,
2179             ArcModifiers modifiers,
2180             String posId,
2181             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
2182         if (!(view instanceof ArcLayout.Widget)) {
2183             Log.e(
2184                     TAG,
2185                     "applyModifiersToArcLayoutView should only be called with an ArcLayout.Widget");
2186             return view;
2187         }
2188 
2189         if (modifiers.hasClickable()) {
2190 
2191             // set the extendTouchTarget as false to disable the clickable area extension for
2192             // meeting the required minimum clickable area
2193             applyClickable(
2194                     view,
2195                     /* wrapper= */ null,
2196                     modifiers.getClickable(),
2197                     /* extendTouchTarget= */ false);
2198         }
2199 
2200         if (modifiers.hasSemantics()) {
2201             applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker);
2202         }
2203 
2204         if (modifiers.hasOpacity()) {
2205             handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker);
2206         }
2207 
2208         return view;
2209     }
2210 
textAlignToAndroidGravity(TextAlignment alignment)2211     private static int textAlignToAndroidGravity(TextAlignment alignment) {
2212         // Vertical center alignment is usually a default and text will be centered vertically.
2213         // However, we need it explicitly for cases when max lines are adjusted and shrank, so there
2214         // could be a bit of extra space and text would be anchored to the top.
2215         return textAlignToAndroidGravityHorizontal(alignment) | Gravity.CENTER_VERTICAL;
2216     }
2217 
textAlignToAndroidGravityHorizontal(TextAlignment alignment)2218     private static int textAlignToAndroidGravityHorizontal(TextAlignment alignment) {
2219         switch (alignment) {
2220             case TEXT_ALIGN_START:
2221                 return Gravity.START;
2222             case TEXT_ALIGN_CENTER:
2223                 return Gravity.CENTER_HORIZONTAL;
2224             case TEXT_ALIGN_END:
2225                 return Gravity.END;
2226             case TEXT_ALIGN_UNDEFINED:
2227             case UNRECOGNIZED:
2228                 return TEXT_ALIGN_DEFAULT;
2229         }
2230 
2231         return TEXT_ALIGN_DEFAULT;
2232     }
2233 
textTruncationToEllipsize(TextOverflow overflowValue)2234     private static @Nullable TruncateAt textTruncationToEllipsize(TextOverflow overflowValue) {
2235         switch (overflowValue) {
2236             case TEXT_OVERFLOW_TRUNCATE:
2237                 // A null TruncateAt disables adding an ellipsis.
2238                 return null;
2239             case TEXT_OVERFLOW_ELLIPSIZE_END:
2240             case TEXT_OVERFLOW_ELLIPSIZE:
2241                 return TruncateAt.END;
2242             case TEXT_OVERFLOW_MARQUEE:
2243                 return TruncateAt.MARQUEE;
2244             case TEXT_OVERFLOW_UNDEFINED:
2245             case UNRECOGNIZED:
2246                 return TEXT_OVERFLOW_DEFAULT;
2247         }
2248 
2249         return TEXT_OVERFLOW_DEFAULT;
2250     }
2251 
2252     @ArcLayout.AnchorType
anchorTypeToAnchorPos(ArcAnchorType type)2253     private static int anchorTypeToAnchorPos(ArcAnchorType type) {
2254         switch (type) {
2255             case ARC_ANCHOR_START:
2256                 return ArcLayout.ANCHOR_START;
2257             case ARC_ANCHOR_CENTER:
2258                 return ArcLayout.ANCHOR_CENTER;
2259             case ARC_ANCHOR_END:
2260                 return ArcLayout.ANCHOR_END;
2261             case ARC_ANCHOR_UNDEFINED:
2262             case UNRECOGNIZED:
2263                 return ARC_ANCHOR_DEFAULT;
2264         }
2265 
2266         return ARC_ANCHOR_DEFAULT;
2267     }
2268 
2269     @SizedArcContainer.LayoutParams.AngularAlignment
angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment)2270     private static int angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment) {
2271         switch (angularAlignment) {
2272             case ANGULAR_ALIGNMENT_START:
2273                 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_START;
2274             case ANGULAR_ALIGNMENT_CENTER:
2275                 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER;
2276             case ANGULAR_ALIGNMENT_END:
2277                 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_END;
2278             case ANGULAR_ALIGNMENT_UNDEFINED:
2279             case UNRECOGNIZED:
2280                 return ANGULAR_ALIGNMENT_DEFAULT;
2281         }
2282 
2283         return ANGULAR_ALIGNMENT_DEFAULT;
2284     }
2285 
dimensionToPx(ContainerDimension containerDimension)2286     private int dimensionToPx(ContainerDimension containerDimension) {
2287         switch (containerDimension.getInnerCase()) {
2288             case LINEAR_DIMENSION:
2289                 return safeDpToPx(containerDimension.getLinearDimension());
2290             case EXPANDED_DIMENSION:
2291                 return LayoutParams.MATCH_PARENT;
2292             case WRAPPED_DIMENSION:
2293                 return LayoutParams.WRAP_CONTENT;
2294             case INNER_NOT_SET:
2295                 return dimensionToPx(CONTAINER_DIMENSION_DEFAULT);
2296         }
2297 
2298         return dimensionToPx(CONTAINER_DIMENSION_DEFAULT);
2299     }
2300 
extractTextColorArgb(FontStyle fontStyle)2301     private static int extractTextColorArgb(FontStyle fontStyle) {
2302         if (fontStyle.hasColor()) {
2303             return fontStyle.getColor().getArgb();
2304         } else {
2305             return TEXT_COLOR_DEFAULT;
2306         }
2307     }
2308 
2309     /**
2310      * Returns an Android {@link Intent} that can perform the action defined in the given layout
2311      * {@link LaunchAction}.
2312      */
buildLaunchActionIntent( @onNull LaunchAction launchAction, @NonNull String clickableId, @NonNull String clickableIdExtra)2313     public static @Nullable Intent buildLaunchActionIntent(
2314             @NonNull LaunchAction launchAction,
2315             @NonNull String clickableId,
2316             @NonNull String clickableIdExtra) {
2317         if (launchAction.hasAndroidActivity()) {
2318             AndroidActivity activity = launchAction.getAndroidActivity();
2319             Intent i =
2320                     new Intent().setClassName(activity.getPackageName(), activity.getClassName());
2321             i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
2322 
2323             if (!clickableId.isEmpty() && !clickableIdExtra.isEmpty()) {
2324                 i.putExtra(clickableIdExtra, clickableId);
2325             }
2326 
2327             for (Map.Entry<String, AndroidExtra> entry : activity.getKeyToExtraMap().entrySet()) {
2328                 if (entry.getValue().hasStringVal()) {
2329                     i.putExtra(entry.getKey(), entry.getValue().getStringVal().getValue());
2330                 } else if (entry.getValue().hasIntVal()) {
2331                     i.putExtra(entry.getKey(), entry.getValue().getIntVal().getValue());
2332                 } else if (entry.getValue().hasLongVal()) {
2333                     i.putExtra(entry.getKey(), entry.getValue().getLongVal().getValue());
2334                 } else if (entry.getValue().hasDoubleVal()) {
2335                     i.putExtra(entry.getKey(), entry.getValue().getDoubleVal().getValue());
2336                 } else if (entry.getValue().hasBooleanVal()) {
2337                     i.putExtra(entry.getKey(), entry.getValue().getBooleanVal().getValue());
2338                 }
2339             }
2340 
2341             return i;
2342         }
2343 
2344         return null;
2345     }
2346 
buildState(LoadAction loadAction, String clickableId)2347     static State buildState(LoadAction loadAction, String clickableId) {
2348         // Get the state specified by the provider and add the last clicked clickable's ID to it.
2349         return loadAction.getRequestState().toBuilder().setLastClickableId(clickableId).build();
2350     }
2351 
inflateColumn( ParentViewWrapper parentViewWrapper, Column column, String columnPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2352     private @Nullable InflatedView inflateColumn(
2353             ParentViewWrapper parentViewWrapper,
2354             Column column,
2355             String columnPosId,
2356             boolean includeChildren,
2357             LayoutInfo.Builder layoutInfoBuilder,
2358             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
2359         ContainerDimension width =
2360                 column.hasWidth() ? column.getWidth() : CONTAINER_DIMENSION_DEFAULT;
2361         ContainerDimension height =
2362                 column.hasHeight() ? column.getHeight() : CONTAINER_DIMENSION_DEFAULT;
2363 
2364         if (!canMeasureContainer(width, height, column.getContentsList())) {
2365             Log.w(TAG, "Column set to wrap but contents are unmeasurable. Ignoring.");
2366             return null;
2367         }
2368 
2369         LinearLayout linearLayout = new LinearLayout(mUiContext);
2370         linearLayout.setOrientation(LinearLayout.VERTICAL);
2371 
2372         LayoutParams layoutParams = generateDefaultLayoutParams();
2373 
2374         linearLayout.setGravity(
2375                 horizontalAlignmentToGravity(column.getHorizontalAlignment().getValue()));
2376 
2377         layoutParams =
2378                 updateLayoutParams(
2379                         parentViewWrapper.getParentProperties(), layoutParams, width, height);
2380         resolveMinimumDimensions(linearLayout, width, height);
2381 
2382         View wrappedView =
2383                 applyModifiers(
2384                         linearLayout,
2385                         /* wrapper= */ null,
2386                         column.getModifiers(),
2387                         columnPosId,
2388                         pipelineMaker);
2389 
2390         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
2391 
2392         if (includeChildren) {
2393             inflateChildElements(
2394                     linearLayout,
2395                     layoutParams,
2396                     NO_OP_PENDING_LAYOUT_PARAMS,
2397                     column.getContentsList(),
2398                     columnPosId,
2399                     layoutInfoBuilder,
2400                     pipelineMaker);
2401             layoutInfoBuilder.removeSubtree(columnPosId);
2402         }
2403 
2404         int numMissingChildren = includeChildren ? 0 : column.getContentsCount();
2405         return new InflatedView(
2406                 wrappedView,
2407                 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
2408                 NO_OP_PENDING_LAYOUT_PARAMS,
2409                 numMissingChildren);
2410     }
2411 
inflateRow( ParentViewWrapper parentViewWrapper, Row row, String rowPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)2412     private @Nullable InflatedView inflateRow(
2413             ParentViewWrapper parentViewWrapper,
2414             Row row,
2415             String rowPosId,
2416             boolean includeChildren,
2417             LayoutInfo.Builder layoutInfoBuilder,
2418             Optional<PipelineMaker> pipelineMaker) {
2419         ContainerDimension width = row.hasWidth() ? row.getWidth() : CONTAINER_DIMENSION_DEFAULT;
2420         ContainerDimension height = row.hasHeight() ? row.getHeight() : CONTAINER_DIMENSION_DEFAULT;
2421 
2422         if (!canMeasureContainer(width, height, row.getContentsList())) {
2423             Log.w(TAG, "Row set to wrap but contents are unmeasurable. Ignoring.");
2424             return null;
2425         }
2426 
2427         LinearLayout linearLayout = new LinearLayout(mUiContext);
2428         linearLayout.setOrientation(LinearLayout.HORIZONTAL);
2429 
2430         LayoutParams layoutParams = generateDefaultLayoutParams();
2431 
2432         linearLayout.setGravity(verticalAlignmentToGravity(row.getVerticalAlignment().getValue()));
2433 
2434         layoutParams =
2435                 updateLayoutParams(
2436                         parentViewWrapper.getParentProperties(), layoutParams, width, height);
2437         resolveMinimumDimensions(linearLayout, width, height);
2438 
2439         View wrappedView =
2440                 applyModifiers(
2441                         linearLayout,
2442                         /* wrapper= */ null,
2443                         row.getModifiers(),
2444                         rowPosId,
2445                         pipelineMaker);
2446 
2447         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
2448 
2449         if (includeChildren) {
2450             inflateChildElements(
2451                     linearLayout,
2452                     layoutParams,
2453                     NO_OP_PENDING_LAYOUT_PARAMS,
2454                     row.getContentsList(),
2455                     rowPosId,
2456                     layoutInfoBuilder,
2457                     pipelineMaker);
2458             layoutInfoBuilder.removeSubtree(rowPosId);
2459         }
2460 
2461         int numMissingChildren = includeChildren ? 0 : row.getContentsCount();
2462         return new InflatedView(
2463                 wrappedView,
2464                 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
2465                 NO_OP_PENDING_LAYOUT_PARAMS,
2466                 numMissingChildren);
2467     }
2468 
2469     // dereference of possibly-null reference lp
2470     @SuppressWarnings("nullness:dereference.of.nullable")
inflateBox( ParentViewWrapper parentViewWrapper, Box box, String boxPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)2471     private @Nullable InflatedView inflateBox(
2472             ParentViewWrapper parentViewWrapper,
2473             Box box,
2474             String boxPosId,
2475             boolean includeChildren,
2476             LayoutInfo.Builder layoutInfoBuilder,
2477             Optional<PipelineMaker> pipelineMaker) {
2478         ContainerDimension width = box.hasWidth() ? box.getWidth() : CONTAINER_DIMENSION_DEFAULT;
2479         ContainerDimension height = box.hasHeight() ? box.getHeight() : CONTAINER_DIMENSION_DEFAULT;
2480 
2481         if (!canMeasureContainer(width, height, box.getContentsList())) {
2482             Log.w(TAG, "Box set to wrap but contents are unmeasurable. Ignoring.");
2483             return null;
2484         }
2485 
2486         FrameLayout frame = new FrameLayout(mUiContext);
2487 
2488         LayoutParams layoutParams = generateDefaultLayoutParams();
2489 
2490         layoutParams =
2491                 updateLayoutParams(
2492                         parentViewWrapper.getParentProperties(), layoutParams, width, height);
2493         resolveMinimumDimensions(frame, width, height);
2494 
2495         int gravity =
2496                 getFrameLayoutGravity(
2497                         box.getHorizontalAlignment().getValue(),
2498                         box.getVerticalAlignment().getValue());
2499         PendingFrameLayoutParams childLayoutParams = new PendingFrameLayoutParams(gravity);
2500 
2501         View wrappedView =
2502                 applyModifiers(
2503                         frame, /* wrapper= */ null, box.getModifiers(), boxPosId, pipelineMaker);
2504 
2505         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
2506 
2507         if (includeChildren) {
2508             inflateChildElements(
2509                     frame,
2510                     layoutParams,
2511                     childLayoutParams,
2512                     box.getContentsList(),
2513                     boxPosId,
2514                     layoutInfoBuilder,
2515                     pipelineMaker);
2516             layoutInfoBuilder.removeSubtree(boxPosId);
2517         }
2518 
2519         // We can't set layout gravity to a FrameLayout ahead of time (and foregroundGravity only
2520         // sets the gravity of the foreground Drawable). Go and apply gravity to the child.
2521         try {
2522             applyGravityToFrameLayoutChildren(frame, gravity);
2523         } catch (IllegalStateException ex) {
2524             Log.e(TAG, "Error applying Gravity to FrameLayout children.", ex);
2525         }
2526 
2527         // HACK: FrameLayout has a bug in it. If we add one WRAP_CONTENT child, and one MATCH_PARENT
2528         // child, the expected behaviour is that the FrameLayout sizes itself to fit the
2529         // WRAP_CONTENT child (e.g. a TextView), then the MATCH_PARENT child is forced to the same
2530         // size as the outer FrameLayout (and hence, the size of the TextView, after accounting for
2531         // padding etc). Because of a bug though, this doesn't happen; instead, the MATCH_PARENT
2532         // child will just keep its intrinsic size. This is because FrameLayout only forces
2533         // MATCH_PARENT children to a given size if there are _more than one_ of them (see the
2534         // bottom of FrameLayout#onMeasure).
2535         //
2536         // To work around this (without copying the whole of FrameLayout just to change a "1" to
2537         // "0"), we add a specific IgnorableSpace element in if there is one MATCH_PARENT child.
2538         // This has a tiny cost to the measure pass, and negligible cost to layout/draw (since it
2539         // doesn't take part in those passes).
2540         // This element needs to be specific class that won't be counted towards the effective
2541         // children of the parent when doing layout mutation. See b/345186544
2542         int numMatchParentChildren = 0;
2543         for (int i = 0; i < frame.getChildCount(); i++) {
2544             LayoutParams lp = frame.getChildAt(i).getLayoutParams();
2545             if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) {
2546                 numMatchParentChildren++;
2547             }
2548         }
2549 
2550         if (numMatchParentChildren == 1) {
2551             IgnorableSpace hackSpace = new IgnorableSpace(mUiContext);
2552             LayoutParams hackSpaceLp =
2553                     new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
2554             frame.addView(hackSpace, hackSpaceLp);
2555         }
2556 
2557         int numMissingChildren = includeChildren ? 0 : box.getContentsCount();
2558         return new InflatedView(
2559                 wrappedView,
2560                 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
2561                 childLayoutParams,
2562                 numMissingChildren);
2563     }
2564 
inflateSpacer( ParentViewWrapper parentViewWrapper, Spacer spacer, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2565     private @Nullable InflatedView inflateSpacer(
2566             ParentViewWrapper parentViewWrapper,
2567             Spacer spacer,
2568             String posId,
2569             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
2570         LayoutParams layoutParams = generateDefaultLayoutParams();
2571         layoutParams =
2572                 updateLayoutParams(
2573                         parentViewWrapper.getParentProperties(),
2574                         layoutParams,
2575                         // This doesn't copy layout constraint for dynamic fields. That is fine,
2576                         // because that will be later applied to the wrapper, and this layoutParams
2577                         // would have dimension reset and put into the pipeline.
2578                         spacerDimensionToContainerDimension(spacer.getWidth()),
2579                         spacerDimensionToContainerDimension(spacer.getHeight()));
2580 
2581         // Initialize the size wrapper here, if needed. This simplifies the logic below when
2582         // creating the actual Spacer and adding it to its parent...
2583         FrameLayout sizeWrapper = null;
2584         Float widthForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getWidth());
2585         Float heightForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getHeight());
2586 
2587         // Handling dynamic width/height for the spacer.
2588         if (widthForLayoutDp != null || heightForLayoutDp != null) {
2589             sizeWrapper = new FrameLayout(mUiContext);
2590             LayoutParams spaceWrapperLayoutParams = generateDefaultLayoutParams();
2591             spaceWrapperLayoutParams =
2592                     updateLayoutParams(
2593                             parentViewWrapper.getParentProperties(),
2594                             spaceWrapperLayoutParams,
2595                             spacerDimensionToContainerDimension(spacer.getWidth()),
2596                             spacerDimensionToContainerDimension(spacer.getHeight()));
2597 
2598             if (widthForLayoutDp != null) {
2599                 if (widthForLayoutDp <= 0f) {
2600                     Log.w(
2601                             TAG,
2602                             "Spacer width's value_for_layout is not a positive value. Element won't"
2603                                     + " be visible.");
2604                 }
2605                 spaceWrapperLayoutParams.width = safeDpToPx(widthForLayoutDp);
2606             }
2607 
2608             if (heightForLayoutDp != null) {
2609                 if (heightForLayoutDp <= 0f) {
2610                     Log.w(
2611                             TAG,
2612                             "Spacer height's value_for_layout is not a positive value. Element"
2613                                     + " won't be visible.");
2614                 }
2615                 spaceWrapperLayoutParams.height = safeDpToPx(heightForLayoutDp);
2616             }
2617 
2618             int gravity =
2619                     (spacer.getWidth().hasLinearDimension()
2620                                     ? horizontalAlignmentToGravity(
2621                                             spacer.getWidth()
2622                                                     .getLinearDimension()
2623                                                     .getHorizontalAlignmentForLayout())
2624                                     : UNSET_MASK)
2625                             | (spacer.getHeight().hasLinearDimension()
2626                                     ? verticalAlignmentToGravity(
2627                                             spacer.getHeight()
2628                                                     .getLinearDimension()
2629                                                     .getVerticalAlignmentForLayout())
2630                                     : UNSET_MASK);
2631 
2632             // This layoutParams will override what we initially had and will be used for Spacer
2633             // itself. This means that the wrapper's layout params should follow the rules for
2634             // expand, and this one should just match the parents size when dimension is set to
2635             // expand. When dimension is dynamic, the value will be assigned during the
2636             // evaluation, so currently we will just copy over the value from constraints.
2637             FrameLayout.LayoutParams frameLayoutLayoutParams =
2638                     new FrameLayout.LayoutParams(layoutParams);
2639             frameLayoutLayoutParams.gravity = gravity;
2640             if (spacer.getWidth().hasExpandedDimension()) {
2641                 frameLayoutLayoutParams.width = LayoutParams.MATCH_PARENT;
2642             }
2643             if (spacer.getHeight().hasExpandedDimension()) {
2644                 frameLayoutLayoutParams.height = LayoutParams.MATCH_PARENT;
2645             }
2646             layoutParams = frameLayoutLayoutParams;
2647 
2648             parentViewWrapper.maybeAddView(sizeWrapper, spaceWrapperLayoutParams);
2649 
2650             parentViewWrapper = new ParentViewWrapper(sizeWrapper, spaceWrapperLayoutParams);
2651         }
2652 
2653         // Modifiers cannot be applied to android's Space, so use a plain View if this Spacer has
2654         // modifiers.
2655         View view;
2656 
2657         // Init the layout params to 0 in case of linear dimension (so we don't get strange
2658         // behaviour before the first data pipeline update).
2659         if (spacer.getWidth().hasLinearDimension()) {
2660             layoutParams.width = 0;
2661         }
2662         if (spacer.getHeight().hasLinearDimension()) {
2663             layoutParams.height = 0;
2664         }
2665 
2666         if (spacer.hasModifiers()) {
2667             view =
2668                     applyModifiers(
2669                             new View(mUiContext),
2670                             sizeWrapper,
2671                             spacer.getModifiers(),
2672                             posId,
2673                             pipelineMaker);
2674 
2675             // LayoutParams have been updated above to accommodate from expand option.
2676             // The View needs to be added before any of the *Prop messages are wired up.
2677             // View#getLayoutParams will return null if the View has not been added to a container
2678             // yet
2679             // (since the LayoutParams are technically managed by the parent).
2680             parentViewWrapper.maybeAddView(view, layoutParams);
2681 
2682             if (spacer.getWidth().hasLinearDimension()) {
2683                 handleProp(
2684                         spacer.getWidth().getLinearDimension(),
2685                         widthDp -> updateLayoutWidthParam(view, widthDp),
2686                         posId,
2687                         pipelineMaker);
2688             }
2689 
2690             if (spacer.getHeight().hasLinearDimension()) {
2691                 handleProp(
2692                         spacer.getHeight().getLinearDimension(),
2693                         heightDp -> updateLayoutHeightParam(view, heightDp),
2694                         posId,
2695                         pipelineMaker);
2696             }
2697         } else {
2698             view = new Space(mUiContext);
2699             parentViewWrapper.maybeAddView(view, layoutParams);
2700             if (spacer.getWidth().hasLinearDimension()) {
2701                 handleProp(
2702                         spacer.getWidth().getLinearDimension(),
2703                         widthDp -> updateLayoutWidthParam(view, widthDp),
2704                         posId,
2705                         pipelineMaker);
2706             }
2707             if (spacer.getHeight().hasLinearDimension()) {
2708                 handleProp(
2709                         spacer.getHeight().getLinearDimension(),
2710                         heightDp -> updateLayoutHeightParam(view, heightDp),
2711                         posId,
2712                         pipelineMaker);
2713             }
2714         }
2715 
2716         if (sizeWrapper != null) {
2717             return new InflatedView(
2718                     sizeWrapper,
2719                     parentViewWrapper
2720                             .getParentProperties()
2721                             .applyPendingChildLayoutParams(layoutParams));
2722         } else {
2723             return new InflatedView(
2724                     view,
2725                     parentViewWrapper
2726                             .getParentProperties()
2727                             .applyPendingChildLayoutParams(layoutParams));
2728         }
2729     }
2730 
updateLayoutWidthParam(@onNull View view, float widthDp)2731     private void updateLayoutWidthParam(@NonNull View view, float widthDp) {
2732         scheduleLayoutParamsUpdate(
2733                 view,
2734                 () -> {
2735                     checkNotNull(view.getLayoutParams()).width = safeDpToPx(widthDp);
2736                     view.requestLayout();
2737                 });
2738     }
2739 
updateLayoutHeightParam(@onNull View view, float heightDp)2740     private void updateLayoutHeightParam(@NonNull View view, float heightDp) {
2741         scheduleLayoutParamsUpdate(
2742                 view,
2743                 () -> {
2744                     checkNotNull(view.getLayoutParams()).height = safeDpToPx(heightDp);
2745                     view.requestLayout();
2746                 });
2747     }
2748 
scheduleLayoutParamsUpdate(@onNull View view, Runnable layoutParamsUpdater)2749     private void scheduleLayoutParamsUpdate(@NonNull View view, Runnable layoutParamsUpdater) {
2750         if (view.getLayoutParams() != null) {
2751             layoutParamsUpdater.run();
2752             return;
2753         }
2754 
2755         // View#getLayoutParams() returns null if this view is not attached to a parent ViewGroup.
2756         // And once the view is attached to a parent ViewGroup, it guarantees a non-null return
2757         // value. Thus, we use the listener to do the update of the layout param the moment that the
2758         // view is attached to window.
2759         view.addOnAttachStateChangeListener(
2760                 new View.OnAttachStateChangeListener() {
2761 
2762                     @Override
2763                     public void onViewAttachedToWindow(@NonNull View v) {
2764                         layoutParamsUpdater.run();
2765                         v.removeOnAttachStateChangeListener(this);
2766                     }
2767 
2768                     @Override
2769                     public void onViewDetachedFromWindow(@NonNull View v) {}
2770                 });
2771     }
2772 
inflateArcSpacer( ParentViewWrapper parentViewWrapper, ArcSpacer spacer, String posId, Optional<PipelineMaker> pipelineMaker)2773     private @Nullable InflatedView inflateArcSpacer(
2774             ParentViewWrapper parentViewWrapper,
2775             ArcSpacer spacer,
2776             String posId,
2777             Optional<PipelineMaker> pipelineMaker) {
2778         float length = 0;
2779         int thicknessPx = safeDpToPx(spacer.getThickness());
2780         WearCurvedSpacer space = new WearCurvedSpacer(mUiContext);
2781         ArcLayout.LayoutParams layoutParams =
2782                 new ArcLayout.LayoutParams(generateDefaultLayoutParams());
2783 
2784         if (spacer.hasAngularLength()) {
2785             final AngularDimension angularLength = spacer.getAngularLength();
2786             switch (angularLength.getInnerCase()) {
2787                 case DEGREES:
2788                     length = max(0, angularLength.getDegrees().getValue());
2789                     space.setSweepAngleDegrees(length);
2790                     break;
2791 
2792                 case EXPANDED_ANGULAR_DIMENSION:
2793                     {
2794                         float weight =
2795                                 angularLength
2796                                         .getExpandedAngularDimension()
2797                                         .getLayoutWeight()
2798                                         .getValue();
2799                         if (weight == 0 && thicknessPx == 0) {
2800                             return null;
2801                         }
2802                         layoutParams.setWeight(weight);
2803 
2804                         space.setThickness(thicknessPx);
2805 
2806                         View wrappedView =
2807                                 applyModifiersToArcLayoutView(
2808                                         space, spacer.getModifiers(), posId, pipelineMaker);
2809                         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
2810 
2811                         return new InflatedView(
2812                                 wrappedView,
2813                                 parentViewWrapper
2814                                         .getParentProperties()
2815                                         .applyPendingChildLayoutParams(layoutParams));
2816                     }
2817 
2818                 case DP:
2819                     length = max(0, safeDpToPx(angularLength.getDp().getValue()));
2820                     space.setLengthPx(length);
2821                     break;
2822 
2823                 case INNER_NOT_SET:
2824                     break;
2825             }
2826         } else {
2827             length = max(0, spacer.getLength().getValue());
2828             space.setSweepAngleDegrees(length);
2829         }
2830 
2831         if (length == 0 && thicknessPx == 0) {
2832             return null;
2833         }
2834         space.setThickness(thicknessPx);
2835 
2836         View wrappedView =
2837                 applyModifiersToArcLayoutView(space, spacer.getModifiers(), posId, pipelineMaker);
2838         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
2839 
2840         return new InflatedView(
2841                 wrappedView,
2842                 parentViewWrapper
2843                         .getParentProperties()
2844                         .applyPendingChildLayoutParams(layoutParams));
2845     }
2846 
applyTextOverflow( TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters)2847     private void applyTextOverflow(
2848             TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters) {
2849         TextOverflow overflowValue = overflow.getValue();
2850         if (!mAnimationEnabled && overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE) {
2851             overflowValue = TextOverflow.TEXT_OVERFLOW_UNDEFINED;
2852         }
2853 
2854         textView.setEllipsize(textTruncationToEllipsize(overflowValue));
2855         if (overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE && textView.getMaxLines() == 1) {
2856             int marqueeIterations =
2857                     marqueeParameters.hasIterations()
2858                             ? marqueeParameters.getIterations()
2859                             : -1; // Defaults to repeat indefinitely (-1).
2860             textView.setMarqueeRepeatLimit(marqueeIterations);
2861             textView.setSelected(true);
2862             textView.setSingleLine();
2863             textView.setHorizontalFadingEdgeEnabled(true);
2864         }
2865     }
2866 
inflateText( ParentViewWrapper parentViewWrapper, Text text, String posId, Optional<PipelineMaker> pipelineMaker)2867     private InflatedView inflateText(
2868             ParentViewWrapper parentViewWrapper,
2869             Text text,
2870             String posId,
2871             Optional<PipelineMaker> pipelineMaker) {
2872         TextView textView = newThemedTextView();
2873 
2874         LayoutParams layoutParams = generateDefaultLayoutParams();
2875 
2876         handleProp(
2877                 text.getText(),
2878                 mUiContext.getResources().getConfiguration().getLocales().get(0),
2879                 t -> {
2880                     // Underlines are applied using a Spannable here, rather than setting paint bits
2881                     // (or
2882                     // using Paint#setTextUnderline). When multiple fonts are mixed on the same line
2883                     // (especially when mixing anything with NotoSans-CJK), multiple underlines can
2884                     // appear. Using UnderlineSpan instead though causes the correct behaviour to
2885                     // happen
2886                     // (only a
2887                     // single underline).
2888                     SpannableStringBuilder ssb = new SpannableStringBuilder();
2889                     ssb.append(t);
2890 
2891                     if (text.getFontStyle().getUnderline().getValue()) {
2892                         ssb.setSpan(new UnderlineSpan(), 0, ssb.length(), Spanned.SPAN_MARK_MARK);
2893                     }
2894 
2895                     textView.setText(ssb);
2896                 },
2897                 posId,
2898                 pipelineMaker);
2899 
2900         textView.setGravity(textAlignToAndroidGravity(text.getMultilineAlignment().getValue()));
2901 
2902         String valueForLayout = resolveValueForLayoutIfNeeded(text.getText());
2903 
2904         // Use valueForLayout as a proxy for "has a dynamic size". If there's a dynamic binding for
2905         // the text element, then it can only have a single line of text.
2906         if (text.hasMaxLines() && valueForLayout == null) {
2907             textView.setMaxLines(max(TEXT_MIN_LINES, text.getMaxLines().getValue()));
2908         } else {
2909             textView.setMaxLines(TEXT_MAX_LINES_DEFAULT);
2910         }
2911 
2912         TextOverflowProp overflow = text.getOverflow();
2913         applyTextOverflow(textView, overflow, text.getMarqueeParameters());
2914 
2915         if (overflow.getValue() == TextOverflow.TEXT_OVERFLOW_ELLIPSIZE
2916                 && !text.getText().hasDynamicValue()
2917                 // There's no need for any optimizations if max lines is already 1.
2918                 && textView.getMaxLines() != 1) {
2919             OneOffLayoutChangeListener.add(textView, () -> adjustMaxLinesForEllipsize(textView));
2920         }
2921 
2922         // Text auto size is not supported for dynamic text.
2923         boolean isAutoSizeAllowed = !(text.hasText() && text.getText().hasDynamicValue());
2924         // Setting colours **must** go after setting the Text Appearance, otherwise it will get
2925         // immediately overridden.
2926         if (text.hasFontStyle()) {
2927             applyFontStyle(text.getFontStyle(), textView, posId, pipelineMaker, isAutoSizeAllowed);
2928         } else {
2929             applyFontStyle(
2930                     FontStyle.getDefaultInstance(),
2931                     textView,
2932                     posId,
2933                     pipelineMaker,
2934                     isAutoSizeAllowed);
2935         }
2936 
2937         // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also
2938         // have excluded font padding. Here, it's being ignored, and default value (excluded font
2939         // padding) is used.
2940         applyExcludeFontPadding(textView);
2941 
2942         if (text.hasLineHeight()) {
2943             float lineHeightPx = toPx(text.getLineHeight());
2944             final float fontHeightPx = textView.getPaint().getFontSpacing();
2945             if (lineHeightPx != fontHeightPx) {
2946                 textView.setLineSpacing(lineHeightPx - fontHeightPx, 1f);
2947             }
2948         }
2949 
2950         // We don't want the text to be screen-reader focusable, unless wrapped in a Spannable
2951         // modifier. This prevents automatically reading out partial text (e.g. text in a row) etc.
2952         //
2953         // This **must** be done before applying modifiers; applying a Semantics modifier will set
2954         // importantForAccessibility, so we don't want to override it after applying modifiers.
2955         textView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
2956 
2957         if (valueForLayout != null) {
2958             if (valueForLayout.isEmpty()) {
2959                 Log.w(TAG, "Text's value_for_layout is empty. Element won't be visible.");
2960             }
2961 
2962             // Now create a "container" element, with that size, to hold the text.
2963             FrameLayout sizeChangingTextWrapper = new FrameLayout(mUiContext);
2964             LayoutParams sizeChangingTextWrapperLayoutParams = generateDefaultLayoutParams();
2965             // Use the actual TextView to measure the text width.
2966             sizeChangingTextWrapperLayoutParams.width =
2967                     (int) textView.getPaint().measureText(valueForLayout);
2968             sizeChangingTextWrapperLayoutParams.height = LayoutParams.WRAP_CONTENT;
2969             View wrappedView =
2970                     applyModifiers(
2971                             textView,
2972                             sizeChangingTextWrapper,
2973                             text.getModifiers(),
2974                             posId,
2975                             pipelineMaker);
2976 
2977             // Set horizontal gravity on the wrapper to reflect alignment.
2978             int gravity = textAlignToAndroidGravity(text.getText().getTextAlignmentForLayout());
2979             FrameLayout.LayoutParams frameLayoutLayoutParams =
2980                     new FrameLayout.LayoutParams(layoutParams);
2981             frameLayoutLayoutParams.gravity = gravity;
2982             layoutParams = frameLayoutLayoutParams;
2983 
2984             sizeChangingTextWrapper.addView(wrappedView, layoutParams);
2985             parentViewWrapper.maybeAddView(
2986                     sizeChangingTextWrapper, sizeChangingTextWrapperLayoutParams);
2987             return new InflatedView(
2988                     sizeChangingTextWrapper,
2989                     parentViewWrapper
2990                             .getParentProperties()
2991                             .applyPendingChildLayoutParams(sizeChangingTextWrapperLayoutParams));
2992         } else {
2993             View wrappedView =
2994                     applyModifiers(
2995                             textView,
2996                             /* wrapper= */ null,
2997                             text.getModifiers(),
2998                             posId,
2999                             pipelineMaker);
3000             parentViewWrapper.maybeAddView(wrappedView, layoutParams);
3001             return new InflatedView(
3002                     wrappedView,
3003                     parentViewWrapper
3004                             .getParentProperties()
3005                             .applyPendingChildLayoutParams(layoutParams));
3006         }
3007     }
3008 
3009     /**
3010      * Sorts out what maxLines should be if the text could possibly be truncated before maxLines is
3011      * reached.
3012      *
3013      * <p>Should be only called for the {@link TextOverflow#TEXT_OVERFLOW_ELLIPSIZE} option which
3014      * ellipsizes the text even before the last line, if there's no space for all lines. This is
3015      * different than what TEXT_OVERFLOW_ELLIPSIZE_END does, as that option just ellipsizes the last
3016      * line of text.
3017      */
adjustMaxLinesForEllipsize(@onNull TextView textView)3018     private static void adjustMaxLinesForEllipsize(@NonNull TextView textView) {
3019         ViewParent maybeParent = textView.getParent();
3020         if (!(maybeParent instanceof View)) {
3021             Log.d(
3022                     TAG,
3023                     "Couldn't adjust max lines for ellipsizing as there's no View/ViewGroup"
3024                             + " parent.");
3025             return;
3026         }
3027 
3028         View parent = (View) maybeParent;
3029         int availableHeight = parent.getHeight();
3030         int oneLineHeight = textView.getLineHeight();
3031         // This is what was set in proto, we shouldn't exceed it.
3032         int maxMaxLines = textView.getMaxLines();
3033         // Avoid having maxLines as 0 in case the space is really tight.
3034         int availableLines = max(availableHeight / oneLineHeight, 1);
3035 
3036         // Update only if maxLines are changed.
3037         if (availableLines >= maxMaxLines) {
3038             return;
3039         }
3040 
3041         textView.setMaxLines(availableLines);
3042         // We need to trigger TextView to re-measure its content in order to place the ellipsis
3043         // correctly at the and of {@code availableLines}th line. Using only {@code requestLayout}
3044         // or {@code invalidate} isn't enough as TextView wouldn't remeasure itself.
3045         textView.setText(textView.getText());
3046     }
3047 
3048     /**
3049      * Sets font padding to be excluded and applies correct padding to the TextView to avoid
3050      * clipping taller languages.
3051      */
applyExcludeFontPadding(TextView textView)3052     private void applyExcludeFontPadding(TextView textView) {
3053         textView.setIncludeFontPadding(false);
3054 
3055         // We need to update padding in the TextView to avoid clipping of taller languages.
3056 
3057         float ascent = textView.getPaint().getFontMetrics().ascent;
3058         float descent = textView.getPaint().getFontMetrics().descent;
3059         String text = textView.getText().toString();
3060         Rect bounds = new Rect();
3061 
3062         textView.getPaint().getTextBounds(text, 0, max(0, text.length() - 1), bounds);
3063 
3064         int topPadding = textView.getPaddingTop();
3065         int bottomPadding = textView.getPaddingBottom();
3066 
3067         if (ascent > bounds.top) {
3068             topPadding = (int) (ascent - bounds.top);
3069         }
3070 
3071         if (descent < bounds.bottom) {
3072             bottomPadding = (int) (bounds.bottom - descent);
3073         }
3074 
3075         textView.setPadding(
3076                 textView.getPaddingLeft(), topPadding, textView.getPaddingRight(), bottomPadding);
3077     }
3078 
inflateArcText( ParentViewWrapper parentViewWrapper, ArcText text, String posId, Optional<PipelineMaker> pipelineMaker)3079     private InflatedView inflateArcText(
3080             ParentViewWrapper parentViewWrapper,
3081             ArcText text,
3082             String posId,
3083             Optional<PipelineMaker> pipelineMaker) {
3084         CurvedTextView textView = newThemedCurvedTextView();
3085 
3086         LayoutParams layoutParams = generateDefaultLayoutParams();
3087         layoutParams.width = LayoutParams.MATCH_PARENT;
3088         layoutParams.height = LayoutParams.MATCH_PARENT;
3089 
3090         textView.setText(text.getText().getValue());
3091 
3092         if (text.hasFontStyle()) {
3093             applyFontStyle(text.getFontStyle(), textView);
3094         } else if (mApplyFontVariantBodyAsDefault) {
3095             applyFontStyle(FontStyle.getDefaultInstance(), textView);
3096         }
3097 
3098         // Setting colours **must** go after setting the Text Appearance, otherwise it will get
3099         // immediately overridden.
3100         textView.setTextColor(extractTextColorArgb(text.getFontStyle()));
3101 
3102         if (text.hasArcDirection()) {
3103             switch (text.getArcDirection().getValue()) {
3104                 case ARC_DIRECTION_NORMAL:
3105                     textView.setClockwise(!isRtlLayoutDirectionFromLocale());
3106                     break;
3107                 case ARC_DIRECTION_COUNTER_CLOCKWISE:
3108                     textView.setClockwise(false);
3109                     break;
3110                 case ARC_DIRECTION_CLOCKWISE:
3111                 case UNRECOGNIZED:
3112                     textView.setClockwise(true);
3113                     break;
3114             }
3115         }
3116 
3117         View wrappedView =
3118                 applyModifiersToArcLayoutView(textView, text.getModifiers(), posId, pipelineMaker);
3119         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
3120 
3121         return new InflatedView(
3122                 wrappedView,
3123                 parentViewWrapper
3124                         .getParentProperties()
3125                         .applyPendingChildLayoutParams(layoutParams));
3126     }
3127 
isZeroLengthImageDimension(ImageDimension dimension)3128     private static boolean isZeroLengthImageDimension(ImageDimension dimension) {
3129         return dimension.getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION
3130                 && dimension.getLinearDimension().getValue() == 0;
3131     }
3132 
imageDimensionToContainerDimension(ImageDimension dimension)3133     private static ContainerDimension imageDimensionToContainerDimension(ImageDimension dimension) {
3134         switch (dimension.getInnerCase()) {
3135             case LINEAR_DIMENSION:
3136                 return ContainerDimension.newBuilder()
3137                         .setLinearDimension(dimension.getLinearDimension())
3138                         .build();
3139             case EXPANDED_DIMENSION:
3140                 return ContainerDimension.newBuilder()
3141                         .setExpandedDimension(ExpandedDimensionProp.getDefaultInstance())
3142                         .build();
3143             case PROPORTIONAL_DIMENSION:
3144                 // A ratio size should be translated to a WRAP_CONTENT; the RatioViewWrapper will
3145                 // deal with the sizing of that.
3146                 return ContainerDimension.newBuilder()
3147                         .setWrappedDimension(WrappedDimensionProp.getDefaultInstance())
3148                         .build();
3149             case INNER_NOT_SET:
3150                 break;
3151         }
3152         // Caller should have already checked for this.
3153         throw new IllegalArgumentException(
3154                 "ImageDimension has an unknown dimension type: " + dimension.getInnerCase().name());
3155     }
3156 
3157     @SuppressWarnings("ExecutorTaskName")
inflateImage( ParentViewWrapper parentViewWrapper, Image image, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)3158     private @Nullable InflatedView inflateImage(
3159             ParentViewWrapper parentViewWrapper,
3160             Image image,
3161             String posId,
3162             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
3163         String protoResId = image.getResourceId().getValue();
3164 
3165         // If either width or height isn't set, abort.
3166         if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET
3167                 || image.getHeight().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET) {
3168             Log.w(TAG, "One of width and height not set on image " + protoResId);
3169             return null;
3170         }
3171 
3172         // The image must occupy _some_ space.
3173         if (isZeroLengthImageDimension(image.getWidth())
3174                 || isZeroLengthImageDimension(image.getHeight())) {
3175             Log.w(TAG, "One of width and height was zero on image " + protoResId);
3176             return null;
3177         }
3178 
3179         // Both dimensions can't be ratios.
3180         if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION
3181                 && image.getHeight().getInnerCase()
3182                         == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
3183             Log.w(TAG, "Both width and height were proportional for image " + protoResId);
3184             return null;
3185         }
3186 
3187         // Pull the ratio for the RatioViewWrapper. Was either argument a proportional dimension?
3188         Float ratio = RatioViewWrapper.UNDEFINED_ASPECT_RATIO;
3189 
3190         if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
3191             ratio = safeAspectRatioOrNull(image.getWidth().getProportionalDimension());
3192         }
3193 
3194         if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
3195             ratio = safeAspectRatioOrNull(image.getHeight().getProportionalDimension());
3196         }
3197 
3198         if (ratio == null) {
3199             Log.w(TAG, "Invalid aspect ratio for image " + protoResId);
3200             return null;
3201         }
3202 
3203         ImageViewWithoutIntrinsicSizes imageView = new ImageViewWithoutIntrinsicSizes(mUiContext);
3204 
3205         if (image.hasContentScaleMode()) {
3206             imageView.setScaleType(
3207                     contentScaleModeToScaleType(image.getContentScaleMode().getValue()));
3208         }
3209 
3210         if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) {
3211             imageView.setMinimumWidth(safeDpToPx(image.getWidth().getLinearDimension()));
3212         }
3213 
3214         if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) {
3215             imageView.setMinimumHeight(safeDpToPx(image.getHeight().getLinearDimension()));
3216         }
3217 
3218         // We need to sort out the sizing of the widget now, so we can pass the correct params to
3219         // RatioViewWrapper. First, translate the ImageSize to a ContainerSize. A ratio size should
3220         // be translated to a WRAP_CONTENT; the RatioViewWrapper will deal with the sizing of that.
3221         LayoutParams ratioWrapperLayoutParams = generateDefaultLayoutParams();
3222         ratioWrapperLayoutParams =
3223                 updateLayoutParams(
3224                         parentViewWrapper.getParentProperties(),
3225                         ratioWrapperLayoutParams,
3226                         imageDimensionToContainerDimension(image.getWidth()),
3227                         imageDimensionToContainerDimension(image.getHeight()));
3228 
3229         // Apply the modifiers to the ImageView, **not** the RatioViewWrapper.
3230         //
3231         // RatioViewWrapper doesn't do any custom drawing, it only exists to force dimensions during
3232         // the measure/layout passes, so it doesn't matter which element any border/background
3233         // modifiers get applied to. Applying modifiers to the ImageView is important for Semantics
3234         // though; screen readers try and pick up on the type of element being read, so in the case
3235         // of an image would read "image, <description>" (where the location of "image" can move
3236         // depending on user settings). If we apply the modifiers to RatioViewWrapper though, screen
3237         // readers will not realise that this is an image, and will read the incorrect description.
3238         RatioViewWrapper ratioViewWrapper = new RatioViewWrapper(mUiContext);
3239         ratioViewWrapper.setAspectRatio(ratio);
3240         View wrappedImageView =
3241                 applyModifiers(
3242                         imageView, ratioViewWrapper, image.getModifiers(), posId, pipelineMaker);
3243         ratioViewWrapper.addView(wrappedImageView);
3244 
3245         parentViewWrapper.maybeAddView(ratioViewWrapper, ratioWrapperLayoutParams);
3246 
3247         ListenableFuture<Drawable> drawableFuture =
3248                 mLayoutResourceResolvers.getDrawable(protoResId);
3249         Drawable immediatelySetDrawable = null;
3250         if (drawableFuture.isDone() && !drawableFuture.isCancelled()) {
3251             // If the future is done, immediately draw.
3252             immediatelySetDrawable = setImageDrawable(imageView, drawableFuture, protoResId);
3253         }
3254 
3255         if (immediatelySetDrawable != null && pipelineMaker.isPresent()) {
3256             if (immediatelySetDrawable instanceof AnimatedVectorDrawable) {
3257                 AnimatedVectorDrawable avd = (AnimatedVectorDrawable) immediatelySetDrawable;
3258                 try {
3259                     Trigger trigger = mLayoutResourceResolvers.getAnimationTrigger(protoResId);
3260 
3261                     if (trigger != null
3262                             && trigger.getInnerCase()
3263                                     == Trigger.InnerCase.ON_CONDITION_MET_TRIGGER) {
3264                         OnConditionMetTrigger conditionTrigger = trigger.getOnConditionMetTrigger();
3265                         pipelineMaker
3266                                 .get()
3267                                 .addResolvedAnimatedImageWithBoolTrigger(
3268                                         avd, trigger, posId, conditionTrigger.getCondition());
3269                     } else {
3270                         // Use default trigger if it's not set.
3271                         if (trigger == null
3272                                 || trigger.getInnerCase() == Trigger.InnerCase.INNER_NOT_SET) {
3273                             trigger = DEFAULT_ANIMATION_TRIGGER;
3274                         }
3275                         pipelineMaker.get().addResolvedAnimatedImage(avd, trigger, posId);
3276                     }
3277                 } catch (RuntimeException ex) {
3278                     Log.e(TAG, "Error setting up animation trigger", ex);
3279                 }
3280             } else if (immediatelySetDrawable instanceof SeekableAnimatedVectorDrawable) {
3281                 SeekableAnimatedVectorDrawable seekableAvd =
3282                         (SeekableAnimatedVectorDrawable) immediatelySetDrawable;
3283                 try {
3284                     DynamicFloat progress = mLayoutResourceResolvers.getBoundProgress(protoResId);
3285                     if (progress != null) {
3286                         pipelineMaker
3287                                 .get()
3288                                 .addResolvedSeekableAnimatedImage(seekableAvd, progress, posId);
3289                     }
3290                 } catch (IllegalArgumentException ex) {
3291                     Log.e(TAG, "Error setting up seekable animated image", ex);
3292                 }
3293             }
3294         } else {
3295             // Is there a placeholder to use in the meantime?
3296             try {
3297                 if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) {
3298                     if (setImageDrawable(
3299                                     imageView,
3300                                     mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(
3301                                             protoResId),
3302                                     protoResId)
3303                             == null) {
3304                         Log.w(TAG, "Failed to set the placeholder for " + protoResId);
3305                     }
3306                 }
3307             } catch (ResourceAccessException | IllegalArgumentException ex) {
3308                 Log.e(TAG, "Exception loading placeholder for resource " + protoResId, ex);
3309             }
3310 
3311             // Otherwise, handle the result on the UI thread.
3312             drawableFuture.addListener(
3313                     () -> setImageDrawable(imageView, drawableFuture, protoResId),
3314                     ContextCompat.getMainExecutor(mUiContext));
3315         }
3316 
3317         boolean canImageBeTinted = false;
3318 
3319         try {
3320             canImageBeTinted = mLayoutResourceResolvers.canImageBeTinted(protoResId);
3321         } catch (IllegalArgumentException ex) {
3322             Log.e(TAG, "Exception tinting image " + protoResId, ex);
3323         }
3324 
3325         if (image.getColorFilter().hasTint() && canImageBeTinted) {
3326             // Only allow tinting for Android images.f
3327             handleProp(
3328                     image.getColorFilter().getTint(),
3329                     tintColor -> {
3330                         ColorStateList tint = ColorStateList.valueOf(tintColor);
3331                         imageView.setImageTintList(tint);
3332 
3333                         // SRC_IN throws away the colours in the drawable that we're tinting.
3334                         // Effectively, the drawable being tinted is only a mask to apply the colour
3335                         // to.
3336                         imageView.setImageTintMode(Mode.SRC_IN);
3337                     },
3338                     posId,
3339                     pipelineMaker);
3340         }
3341 
3342         return new InflatedView(
3343                 ratioViewWrapper,
3344                 parentViewWrapper
3345                         .getParentProperties()
3346                         .applyPendingChildLayoutParams(ratioWrapperLayoutParams));
3347     }
3348 
3349     /**
3350      * Set drawable to the image view.
3351      *
3352      * @return Returns the drawable if it is successfully retrieved from the drawable future and set
3353      *     to the image view; otherwise returns null to indicate the failure of setting drawable.
3354      */
setImageDrawable( ImageView imageView, Future<Drawable> drawableFuture, String protoResId)3355     private @Nullable Drawable setImageDrawable(
3356             ImageView imageView, Future<Drawable> drawableFuture, String protoResId) {
3357         try {
3358             return setImageDrawable(imageView, drawableFuture.get(), protoResId);
3359         } catch (ExecutionException | InterruptedException | CancellationException e) {
3360             Log.w(TAG, "Could not get drawable for image " + protoResId, e);
3361         }
3362         return null;
3363     }
3364 
3365     /**
3366      * Set drawable to the image view.
3367      *
3368      * @return Returns the drawable if it is successfully set to the image view; otherwise returns
3369      *     null to indicate the failure of setting drawable.
3370      */
setImageDrawable( ImageView imageView, Drawable drawable, String protoResId)3371     private @Nullable Drawable setImageDrawable(
3372             ImageView imageView, Drawable drawable, String protoResId) {
3373         if (drawable != null) {
3374             mInflaterStatsLogger.logDrawableUsage(drawable);
3375         }
3376         if (drawable instanceof BitmapDrawable
3377                 && ((BitmapDrawable) drawable).getBitmap().getByteCount()
3378                         > DEFAULT_MAX_BITMAP_RAW_SIZE) {
3379             Log.w(TAG, "Ignoring image " + protoResId + " as it's too large.");
3380             return null;
3381         }
3382         imageView.setImageDrawable(drawable);
3383         return drawable;
3384     }
3385 
inflateArcLine( ParentViewWrapper parentViewWrapper, ArcLine line, String posId, Optional<PipelineMaker> pipelineMaker)3386     private @Nullable InflatedView inflateArcLine(
3387             ParentViewWrapper parentViewWrapper,
3388             ArcLine line,
3389             String posId,
3390             Optional<PipelineMaker> pipelineMaker) {
3391         float lengthDegrees = 0;
3392         if (line.hasAngularLength()) {
3393             if (line.getAngularLength().getInnerCase() == ArcLineLength.InnerCase.DEGREES) {
3394                 lengthDegrees = max(0, line.getAngularLength().getDegrees().getValue());
3395             }
3396         } else {
3397             lengthDegrees = max(0, line.getLength().getValue());
3398         }
3399 
3400         int thicknessPx = safeDpToPx(line.getThickness());
3401 
3402         if (lengthDegrees == 0 && thicknessPx == 0) {
3403             return null;
3404         }
3405 
3406         WearCurvedLineView lineView = new WearCurvedLineView(mUiContext);
3407 
3408         try {
3409             lineView.setUpdatesEnabled(false);
3410 
3411             if (line.hasBrush() && line.getBrush().hasSweepGradient()) {
3412                 try {
3413                     SweepGradientHelper sweepGradientHelper =
3414                             SweepGradientHelper.create(
3415                                     line.getBrush().getSweepGradient(),
3416                                     posId,
3417                                     pipelineMaker,
3418                                     lineView::triggerRefresh);
3419                     lineView.setSweepGradient(sweepGradientHelper);
3420                 } catch (IllegalArgumentException e) {
3421                     Log.e(TAG, "Invalid SweepGradient definition: " + e.getMessage());
3422                 }
3423             } else if (line.hasColor()) {
3424                 handleProp(line.getColor(), lineView::setColor, posId, pipelineMaker);
3425             } else {
3426                 lineView.setColor(LINE_COLOR_DEFAULT);
3427             }
3428 
3429             if (line.hasStrokeCap()) {
3430                 StrokeCapProp strokeCapProp = line.getStrokeCap();
3431                 switch (strokeCapProp.getValue()) {
3432                     case STROKE_CAP_BUTT:
3433                         lineView.setStrokeCap(Cap.BUTT);
3434                         break;
3435                     case STROKE_CAP_ROUND:
3436                         lineView.setStrokeCap(Cap.ROUND);
3437                         break;
3438                     case STROKE_CAP_SQUARE:
3439                         lineView.setStrokeCap(Cap.SQUARE);
3440                         break;
3441                     case UNRECOGNIZED:
3442                     case STROKE_CAP_UNDEFINED:
3443                         Log.w(TAG, "Undefined StrokeCap value.");
3444                         break;
3445                 }
3446 
3447                 if (strokeCapProp.hasShadow()) {
3448                     Shadow shadow = strokeCapProp.getShadow();
3449                     int color =
3450                             shadow.getColor().hasArgb() ? shadow.getColor().getArgb() : Color.BLACK;
3451                     lineView.setStrokeCapShadow(
3452                             safeDpToPx(shadow.getBlurRadius().getValue()), color);
3453                 }
3454             }
3455 
3456             lineView.setThickness(thicknessPx);
3457 
3458             DegreesProp length = DegreesProp.getDefaultInstance();
3459 
3460             float arcLayoutWeight = 0;
3461             if (line.hasAngularLength()) {
3462                 final ArcLineLength angularLength = line.getAngularLength();
3463                 switch (angularLength.getInnerCase()) {
3464                     case DEGREES:
3465                         length = line.getAngularLength().getDegrees();
3466                         handleProp(
3467                                 length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker);
3468                         break;
3469 
3470                     case EXPANDED_ANGULAR_DIMENSION:
3471                         {
3472                             ExpandedAngularDimensionProp expandedAngularDimension =
3473                                     angularLength.getExpandedAngularDimension();
3474                             arcLayoutWeight =
3475                                     expandedAngularDimension.hasLayoutWeight()
3476                                             ? expandedAngularDimension.getLayoutWeight().getValue()
3477                                             : 1.0f;
3478                             length = DegreesProp.getDefaultInstance();
3479                             break;
3480                         }
3481                     case INNER_NOT_SET:
3482                         break;
3483                 }
3484             } else {
3485                 length = line.getLength();
3486                 handleProp(length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker);
3487             }
3488 
3489             ArcDirection arcLineDirection =
3490                     line.hasArcDirection()
3491                             ? line.getArcDirection().getValue()
3492                             : ArcDirection.ARC_DIRECTION_CLOCKWISE;
3493 
3494             lineView.setLineDirection(arcLineDirection);
3495 
3496             @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
3497             if (sizeForLayout != null) {
3498                 lineView.setMaxSweepAngleDegrees(sizeForLayout);
3499             }
3500             View wrappedView =
3501                     applyModifiersToArcLayoutView(
3502                             lineView, line.getModifiers(), posId, pipelineMaker);
3503             return addLineViewToParentArc(
3504                     parentViewWrapper,
3505                     wrappedView,
3506                     sizeForLayout,
3507                     arcLineDirection,
3508                     angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()),
3509                     arcLayoutWeight);
3510         } finally {
3511             lineView.setUpdatesEnabled(true);
3512         }
3513     }
3514 
inflateDashedArcLine( @onNull ParentViewWrapper parentViewWrapper, @NonNull DashedArcLine dashedLine, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)3515     private @Nullable InflatedView inflateDashedArcLine(
3516             @NonNull ParentViewWrapper parentViewWrapper,
3517             @NonNull DashedArcLine dashedLine,
3518             @NonNull String posId,
3519             @NonNull Optional<PipelineMaker> pipelineMaker) {
3520         float lengthDegrees = max(0, dashedLine.getLength().getValue());
3521         int thicknessPx = safeDpToPx(dashedLine.getThickness());
3522         if (lengthDegrees == 0 && thicknessPx == 0) {
3523             return null;
3524         }
3525 
3526         WearDashedArcLineView dashedLineView = new WearDashedArcLineView(mUiContext);
3527         dashedLineView.setThickness(thicknessPx);
3528 
3529         if (dashedLine.hasColor()) {
3530             handleProp(dashedLine.getColor(), dashedLineView::setColor, posId, pipelineMaker);
3531         } else {
3532             dashedLineView.setColor(LINE_COLOR_DEFAULT);
3533         }
3534 
3535         if (dashedLine.hasLinePattern()) {
3536             DashedLinePattern linePattern = dashedLine.getLinePattern();
3537             if (linePattern.getGapLocationsCount() > 0) {
3538                 List<Float> gapLocations = new ArrayList<>();
3539                 for (DegreesProp degree : linePattern.getGapLocationsList()) {
3540                     gapLocations.add(degree.getValue());
3541                 }
3542                 dashedLineView.setGapLocations(gapLocations);
3543             }
3544             dashedLineView.setGapSize(safeDpToPx(linePattern.getGapSize()));
3545         }
3546 
3547         ArcDirection arcLineDirection =
3548                 dashedLine.hasArcDirection()
3549                         ? dashedLine.getArcDirection().getValue()
3550                         : ArcDirection.UNRECOGNIZED;
3551         dashedLineView.setLineDirection(arcLineDirection);
3552 
3553         DegreesProp length = dashedLine.getLength();
3554         handleProp(length, dashedLineView::setLineSweepAngleDegrees, posId, pipelineMaker);
3555 
3556         @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
3557         if (sizeForLayout != null) {
3558             dashedLineView.setMaxSweepAngleDegrees(sizeForLayout);
3559         }
3560         View wrappedView =
3561                 applyModifiersToArcLayoutView(
3562                         dashedLineView, dashedLine.getModifiers(), posId, pipelineMaker);
3563         return addLineViewToParentArc(
3564                 parentViewWrapper,
3565                 wrappedView,
3566                 sizeForLayout,
3567                 arcLineDirection,
3568                 angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()),
3569                 /* arcLayoutWeight= */ 0
3570                 // Zero weight in ArcLayout means the view should not be stretched.
3571                 );
3572     }
3573 
addLineViewToParentArc( @onNull ParentViewWrapper parentArc, @NonNull View lineView, @Nullable Float sizeForLayout, @NonNull ArcDirection arcLineDirection, @SizedArcContainer.LayoutParams.AngularAlignment int angularAlignment, float arcLayoutWeight)3574     private InflatedView addLineViewToParentArc(
3575             @NonNull ParentViewWrapper parentArc,
3576             @NonNull View lineView,
3577             @Nullable Float sizeForLayout,
3578             @NonNull ArcDirection arcLineDirection,
3579             @SizedArcContainer.LayoutParams.AngularAlignment int angularAlignment,
3580             float arcLayoutWeight) {
3581         SizedArcContainer sizeWrapper = null;
3582         SizedArcContainer.LayoutParams sizedLayoutParams =
3583                 new SizedArcContainer.LayoutParams(
3584                         LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
3585         if (sizeForLayout != null) {
3586             sizeWrapper = new SizedArcContainer(mUiContext);
3587             sizeWrapper.setArcDirection(arcLineDirection);
3588             if (sizeForLayout <= 0f) {
3589                 Log.w(
3590                         TAG,
3591                         "Arc Line length's value_for_layout is not a positive value. Element won't"
3592                                 + " be visible.");
3593             }
3594             sizeWrapper.setSweepAngleDegrees(sizeForLayout);
3595             sizedLayoutParams.setAngularAlignment(angularAlignment);
3596         }
3597 
3598         // A WearDashedArcLineView or WearCurvedLineView must always be the same width/height as its
3599         // parent, so it can draw the line properly inside of those bounds.
3600         ArcLayout.LayoutParams layoutParams =
3601                 new ArcLayout.LayoutParams(generateDefaultLayoutParams());
3602         layoutParams.width = LayoutParams.MATCH_PARENT;
3603         layoutParams.height = LayoutParams.MATCH_PARENT;
3604         layoutParams.setWeight(arcLayoutWeight);
3605 
3606         if (sizeWrapper != null) {
3607             sizeWrapper.addView(lineView, sizedLayoutParams);
3608             parentArc.maybeAddView(sizeWrapper, layoutParams);
3609             return new InflatedView(
3610                     sizeWrapper,
3611                     parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams));
3612         } else {
3613             parentArc.maybeAddView(lineView, layoutParams);
3614             return new InflatedView(
3615                     lineView,
3616                     parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams));
3617         }
3618     }
3619 
3620     // dereference of possibly-null reference childLayoutParams
3621     @SuppressWarnings("nullness:dereference.of.nullable")
inflateArc( ParentViewWrapper parentViewWrapper, Arc arc, String arcPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)3622     private @Nullable InflatedView inflateArc(
3623             ParentViewWrapper parentViewWrapper,
3624             Arc arc,
3625             String arcPosId,
3626             boolean includeChildren,
3627             LayoutInfo.Builder layoutInfoBuilder,
3628             Optional<PipelineMaker> pipelineMaker) {
3629         ArcLayout arcLayout = new ArcLayout(mUiContext);
3630         int anchorAngleSign = 1;
3631 
3632         if (arc.hasArcDirection()) {
3633             switch (arc.getArcDirection().getValue()) {
3634                 case ARC_DIRECTION_CLOCKWISE:
3635                     arcLayout.setLayoutDirection(LAYOUT_DIRECTION_LTR);
3636                     break;
3637                 case ARC_DIRECTION_COUNTER_CLOCKWISE:
3638                     arcLayout.setLayoutDirection(LAYOUT_DIRECTION_RTL);
3639                     anchorAngleSign = -1;
3640                     break;
3641                 case ARC_DIRECTION_NORMAL:
3642                     boolean isRtl = isRtlLayoutDirectionFromLocale();
3643                     arcLayout.setLayoutDirection(
3644                             isRtl ? LAYOUT_DIRECTION_RTL : LAYOUT_DIRECTION_LTR);
3645                     if (isRtl) {
3646                         anchorAngleSign = -1;
3647                     }
3648                     break;
3649                 case UNRECOGNIZED:
3650                     break;
3651             }
3652         }
3653 
3654         LayoutParams layoutParams = generateDefaultLayoutParams();
3655         layoutParams.width = LayoutParams.MATCH_PARENT;
3656         layoutParams.height = LayoutParams.MATCH_PARENT;
3657 
3658         int finalAnchorAngleSign = anchorAngleSign;
3659         handleProp(
3660                 arc.getAnchorAngle(),
3661                 angle -> {
3662                     arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * angle);
3663                     // Invalidating arcLayout isn't enough. AnchorAngleDegrees change should trigger
3664                     // child requestLayout.
3665                     arcLayout.requestLayout();
3666                 },
3667                 arcPosId,
3668                 pipelineMaker);
3669         arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * arc.getAnchorAngle().getValue());
3670         arcLayout.setAnchorType(anchorTypeToAnchorPos(arc.getAnchorType().getValue()));
3671 
3672         if (arc.hasMaxAngle()) {
3673             arcLayout.setMaxAngleDegrees(arc.getMaxAngle().getValue());
3674         }
3675 
3676         // Add all children.
3677         if (includeChildren) {
3678             int index = FIRST_CHILD_INDEX;
3679             for (ArcLayoutElement child : arc.getContentsList()) {
3680                 String childPosId = ProtoLayoutDiffer.createNodePosId(arcPosId, index++);
3681                 InflatedView childView =
3682                         inflateArcLayoutElement(
3683                                 new ParentViewWrapper(arcLayout, layoutParams),
3684                                 child,
3685                                 childPosId,
3686                                 layoutInfoBuilder,
3687                                 pipelineMaker);
3688                 if (childView != null) {
3689                     ArcLayout.LayoutParams childLayoutParams =
3690                             (ArcLayout.LayoutParams) childView.mView.getLayoutParams();
3691                     boolean rotate = false;
3692                     if (child.hasAdapter()) {
3693                         rotate = child.getAdapter().getRotateContents().getValue();
3694                     }
3695 
3696                     // Apply rotation and gravity.
3697                     childLayoutParams.setRotated(rotate);
3698                     childLayoutParams.setVerticalAlignment(
3699                             verticalAlignmentToArcVAlign(arc.getVerticalAlign()));
3700                 }
3701             }
3702             layoutInfoBuilder.removeSubtree(arcPosId);
3703         }
3704 
3705         View wrappedView =
3706                 applyModifiers(
3707                         arcLayout,
3708                         /* wrapper= */ null,
3709                         arc.getModifiers(),
3710                         arcPosId,
3711                         pipelineMaker);
3712         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
3713 
3714         int numMissingChildren = includeChildren ? 0 : arc.getContentsCount();
3715         return new InflatedView(
3716                 wrappedView,
3717                 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
3718                 NO_OP_PENDING_LAYOUT_PARAMS,
3719                 numMissingChildren);
3720     }
3721 
isRtlLayoutDirectionFromLocale()3722     static boolean isRtlLayoutDirectionFromLocale() {
3723         return TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL;
3724     }
3725 
applyStylesToSpan( SpannableStringBuilder builder, int start, int end, FontStyle fontStyle)3726     private void applyStylesToSpan(
3727             SpannableStringBuilder builder, int start, int end, FontStyle fontStyle) {
3728         if (fontStyleHasSize(fontStyle)) {
3729             // We are using the last added size in the FontStyle because ArcText doesn't support
3730             // autosizing. This is the same behaviour as it was before size has made repeated.
3731             if (fontStyle.getSizeList().size() > 1) {
3732                 Log.w(
3733                         TAG,
3734                         "Font size with multiple values has been used on Span Text. Ignoring "
3735                                 + "all size except the first one.");
3736             }
3737             AbsoluteSizeSpan span =
3738                     new AbsoluteSizeSpan(
3739                             round(toPx(fontStyle.getSize(fontStyle.getSizeCount() - 1))));
3740             builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
3741         }
3742 
3743         if (fontStyle.hasWeight() || fontStyle.hasVariant()) {
3744             CustomTypefaceSpan span = new CustomTypefaceSpan(fontStyleToTypeface(fontStyle));
3745             builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
3746         }
3747 
3748         if (!hasDefaultTypefaceStyle(fontStyle)) {
3749             StyleSpan span = new StyleSpan(fontStyleToTypefaceStyle(fontStyle));
3750             builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
3751         }
3752 
3753         if (fontStyle.getUnderline().getValue()) {
3754             UnderlineSpan span = new UnderlineSpan();
3755             builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
3756         }
3757 
3758         if (fontStyle.hasLetterSpacing()) {
3759             LetterSpacingSpan span = new LetterSpacingSpan(fontStyle.getLetterSpacing().getValue());
3760             builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
3761         }
3762 
3763         ForegroundColorSpan colorSpan = new ForegroundColorSpan(extractTextColorArgb(fontStyle));
3764 
3765         builder.setSpan(colorSpan, start, end, Spanned.SPAN_MARK_MARK);
3766     }
3767 
fontStyleHasSize(FontStyle fontStyle)3768     private static boolean fontStyleHasSize(FontStyle fontStyle) {
3769         return !fontStyle.getSizeList().isEmpty();
3770     }
3771 
applyModifiersToSpan( SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers)3772     private void applyModifiersToSpan(
3773             SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers) {
3774         if (modifiers.hasClickable()) {
3775             ClickableSpan clickableSpan = new ProtoLayoutClickableSpan(modifiers.getClickable());
3776 
3777             builder.setSpan(clickableSpan, start, end, Spanned.SPAN_MARK_MARK);
3778         }
3779     }
3780 
inflateTextInSpannable( SpannableStringBuilder builder, SpanText text)3781     private SpannableStringBuilder inflateTextInSpannable(
3782             SpannableStringBuilder builder, SpanText text) {
3783         int currentPos = builder.length();
3784         int lastPos = currentPos + text.getText().getValue().length();
3785 
3786         builder.append(text.getText().getValue());
3787 
3788         applyStylesToSpan(builder, currentPos, lastPos, text.getFontStyle());
3789         applyModifiersToSpan(builder, currentPos, lastPos, text.getModifiers());
3790 
3791         return builder;
3792     }
3793 
3794     @SuppressWarnings("ExecutorTaskName")
inflateImageInSpannable( SpannableStringBuilder builder, SpanImage protoImage, TextView textView)3795     private SpannableStringBuilder inflateImageInSpannable(
3796             SpannableStringBuilder builder, SpanImage protoImage, TextView textView) {
3797         String protoResId = protoImage.getResourceId().getValue();
3798 
3799         if (protoImage.getWidth().getValue() == 0 || protoImage.getHeight().getValue() == 0) {
3800             Log.w(TAG, "One of width and height was zero on image " + protoResId);
3801             return builder;
3802         }
3803 
3804         ListenableFuture<Drawable> drawableFuture =
3805                 mLayoutResourceResolvers.getDrawable(protoResId);
3806         if (drawableFuture.isDone()) {
3807             // If the future is done, immediately add drawable to builder.
3808             try {
3809                 Drawable drawable = drawableFuture.get();
3810                 appendSpanDrawable(builder, drawable, protoImage);
3811             } catch (ExecutionException | InterruptedException e) {
3812                 Log.w(
3813                         TAG,
3814                         "Could not get drawable for image "
3815                                 + protoImage.getResourceId().getValue());
3816             }
3817         } else {
3818             // If the future is not done, add an empty drawable to builder as a placeholder.
3819             Drawable placeholderDrawable = null;
3820 
3821             try {
3822                 if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) {
3823                     placeholderDrawable =
3824                             mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(protoResId);
3825                 }
3826             } catch (ResourceAccessException | IllegalArgumentException ex) {
3827                 Log.e(TAG, "Could not get placeholder for image " + protoResId, ex);
3828             }
3829 
3830             if (placeholderDrawable == null) {
3831                 placeholderDrawable = new ColorDrawable(Color.TRANSPARENT);
3832             }
3833 
3834             int startInclusive = builder.length();
3835             FixedImageSpan placeholderDrawableSpan =
3836                     appendSpanDrawable(builder, placeholderDrawable, protoImage);
3837             int endExclusive = builder.length();
3838 
3839             // When the future is done, replace the empty drawable with the received one.
3840             drawableFuture.addListener(
3841                     () -> {
3842                         // Remove the placeholder. This should be safe, even with other modifiers
3843                         // applied. This just removes the single drawable span, and should leave
3844                         // other spans in place.
3845                         builder.removeSpan(placeholderDrawableSpan);
3846                         // Add the new drawable to the same range.
3847                         setSpanDrawable(
3848                                 builder, drawableFuture, startInclusive, endExclusive, protoImage);
3849                         // Update the TextView.
3850                         textView.setText(builder);
3851                     },
3852                     ContextCompat.getMainExecutor(mUiContext));
3853         }
3854 
3855         return builder;
3856     }
3857 
appendSpanDrawable( SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage)3858     private FixedImageSpan appendSpanDrawable(
3859             SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage) {
3860         drawable.setBounds(
3861                 0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight()));
3862         FixedImageSpan imgSpan =
3863                 new FixedImageSpan(
3864                         drawable,
3865                         spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment()));
3866 
3867         int startPos = builder.length();
3868 
3869         // Adding NBSP around the space to prevent it from being trimmed.
3870         builder.append(
3871                 ZERO_WIDTH_JOINER + " " + ZERO_WIDTH_JOINER, imgSpan, Spanned.SPAN_MARK_MARK);
3872         int endPos = builder.length();
3873 
3874         applyModifiersToSpan(builder, startPos, endPos, protoImage.getModifiers());
3875 
3876         return imgSpan;
3877     }
3878 
setSpanDrawable( SpannableStringBuilder builder, ListenableFuture<Drawable> drawableFuture, int startInclusive, int endExclusive, SpanImage protoImage)3879     private void setSpanDrawable(
3880             SpannableStringBuilder builder,
3881             ListenableFuture<Drawable> drawableFuture,
3882             int startInclusive,
3883             int endExclusive,
3884             SpanImage protoImage) {
3885         final String protoResourceId = protoImage.getResourceId().getValue();
3886 
3887         try {
3888             // Add the image span to the same range occupied by the placeholder.
3889             Drawable drawable = drawableFuture.get();
3890             drawable.setBounds(
3891                     0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight()));
3892             FixedImageSpan imgSpan =
3893                     new FixedImageSpan(
3894                             drawable,
3895                             spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment()));
3896             builder.setSpan(
3897                     imgSpan,
3898                     startInclusive,
3899                     endExclusive,
3900                     android.text.Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
3901         } catch (ExecutionException | InterruptedException | CancellationException e) {
3902             Log.w(TAG, "Could not get drawable for image " + protoResourceId);
3903         }
3904     }
3905 
inflateSpannable( ParentViewWrapper parentViewWrapper, Spannable spannable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)3906     private InflatedView inflateSpannable(
3907             ParentViewWrapper parentViewWrapper,
3908             Spannable spannable,
3909             String posId,
3910             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
3911         TextView tv = newThemedTextView();
3912 
3913         // Setting colours **must** go after setting the Text Appearance, otherwise it will get
3914         // immediately overridden.
3915         if (mApplyFontVariantBodyAsDefault) {
3916             applyFontStyle(
3917                     FontStyle.getDefaultInstance(),
3918                     tv,
3919                     posId,
3920                     pipelineMaker,
3921                     /* isAutoSizeAllowed= */ false);
3922         }
3923 
3924         LayoutParams layoutParams = generateDefaultLayoutParams();
3925 
3926         SpannableStringBuilder builder = new SpannableStringBuilder();
3927 
3928         boolean isAnySpanClickable = false;
3929 
3930         for (Span element : spannable.getSpansList()) {
3931             switch (element.getInnerCase()) {
3932                 case IMAGE:
3933                     SpanImage protoImage = element.getImage();
3934                     builder = inflateImageInSpannable(builder, protoImage, tv);
3935 
3936                     if (protoImage.getModifiers().hasClickable()) {
3937                         isAnySpanClickable = true;
3938                     }
3939 
3940                     break;
3941                 case TEXT:
3942                     SpanText protoText = element.getText();
3943                     builder = inflateTextInSpannable(builder, protoText);
3944 
3945                     if (protoText.getModifiers().hasClickable()) {
3946                         isAnySpanClickable = true;
3947                     }
3948 
3949                     break;
3950                 case INNER_NOT_SET:
3951                     Log.w(TAG, "Unknown Span child type.");
3952                     break;
3953             }
3954         }
3955 
3956         tv.setGravity(horizontalAlignmentToGravity(spannable.getMultilineAlignment().getValue()));
3957 
3958         if (spannable.hasMaxLines()) {
3959             tv.setMaxLines(max(TEXT_MIN_LINES, spannable.getMaxLines().getValue()));
3960         } else {
3961             tv.setMaxLines(TEXT_MAX_LINES_DEFAULT);
3962         }
3963         applyTextOverflow(tv, spannable.getOverflow(), spannable.getMarqueeParameters());
3964 
3965         if (spannable.hasLineHeight()) {
3966             // We use a Span here instead of just calling TextViewCompat#setLineHeight.
3967             // setLineHeight is implemented by taking the difference between the current font height
3968             // (via the font metrics, not just the size in SP), subtracting that from the desired
3969             // line height, and setting that as the inter-line spacing. This doesn't work for our
3970             // Spannables; we don't use a default height, yet TextView still has a default font (and
3971             // size) that it tries to base the requested line height on, despite that never actually
3972             // being used. The end result is that the line height never actually drops out as
3973             // expected.
3974             //
3975             // Instead, wrap the whole thing in a LineHeightSpan with the desired line height. This
3976             // gets calculated properly as the TextView is calculating its per-line font metrics,
3977             // and will actually work correctly.
3978             StandardLineHeightSpan span =
3979                     new StandardLineHeightSpan((int) toPx(spannable.getLineHeight()));
3980             builder.setSpan(
3981                     span,
3982                     /* start= */ 0,
3983                     /* end= */ builder.length(),
3984                     Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
3985         } else if (spannable.hasLineSpacing()) {
3986             tv.setLineSpacing(toPx(spannable.getLineSpacing()), 1f);
3987         }
3988 
3989         tv.setText(builder);
3990 
3991         // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also
3992         // have excluded font padding. Here, it's being ignored, and default value (excluded font
3993         // padding) is used.
3994         applyExcludeFontPadding(tv);
3995 
3996         if (isAnySpanClickable) {
3997             // For any ClickableSpans to work, the MovementMethod must be set to LinkMovementMethod.
3998             tv.setMovementMethod(LinkMovementMethod.getInstance());
3999 
4000             // Disable the highlight color; if we don't do this, the clicked span will get
4001             // highlighted, which will be cleared half a second later if using LoadAction as the
4002             // next layout will be delivered, which recreates the elements and clears the highlight.
4003             tv.setHighlightColor(Color.TRANSPARENT);
4004 
4005             // Use InhibitingScroller to prevent the text from scrolling when tapped. Setting a
4006             // MovementMethod on a TextView (e.g. for clickables in a Spannable) then cause the
4007             // TextView to be scrollable, and to jump to the end when tapped.
4008             tv.setScroller(new InhibitingScroller(mUiContext));
4009         }
4010 
4011         View wrappedView =
4012                 applyModifiers(
4013                         tv, /* wrapper= */ null, spannable.getModifiers(), posId, pipelineMaker);
4014         parentViewWrapper.maybeAddView(wrappedView, layoutParams);
4015 
4016         return new InflatedView(
4017                 wrappedView,
4018                 parentViewWrapper
4019                         .getParentProperties()
4020                         .applyPendingChildLayoutParams(layoutParams));
4021     }
4022 
inflateArcLayoutElement( ParentViewWrapper parentViewWrapper, ArcLayoutElement element, String nodePosId, LayoutInfo.Builder layoutInfoBuilder, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)4023     private @Nullable InflatedView inflateArcLayoutElement(
4024             ParentViewWrapper parentViewWrapper,
4025             ArcLayoutElement element,
4026             String nodePosId,
4027             LayoutInfo.Builder layoutInfoBuilder,
4028             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
4029         InflatedView inflatedView = null;
4030 
4031         switch (element.getInnerCase()) {
4032             case ADAPTER:
4033                 if (hasTransformation(element.getAdapter().getContent())) {
4034                     Log.e(
4035                             TAG,
4036                             "Error inflating "
4037                                     + element.getAdapter().getContent().getInnerCase().name()
4038                                     + " in the arc. Transformation modifier is not supported for "
4039                                     + "the layout element"
4040                                     + "  inside an ArcAdapter.");
4041                     inflatedView = null;
4042                 } else {
4043                     // Fall back to the normal inflater.
4044                     inflatedView =
4045                             inflateLayoutElement(
4046                                     parentViewWrapper,
4047                                     element.getAdapter().getContent(),
4048                                     nodePosId,
4049                                     /* includeChildren= */ true,
4050                                     layoutInfoBuilder,
4051                                     pipelineMaker);
4052                 }
4053                 break;
4054 
4055             case SPACER:
4056                 inflatedView =
4057                         inflateArcSpacer(
4058                                 parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker);
4059                 break;
4060 
4061             case LINE:
4062                 inflatedView =
4063                         inflateArcLine(
4064                                 parentViewWrapper, element.getLine(), nodePosId, pipelineMaker);
4065                 break;
4066 
4067             case TEXT:
4068                 inflatedView =
4069                         inflateArcText(
4070                                 parentViewWrapper, element.getText(), nodePosId, pipelineMaker);
4071                 break;
4072 
4073             case DASHED_LINE:
4074                 inflatedView =
4075                         inflateDashedArcLine(
4076                                 parentViewWrapper,
4077                                 element.getDashedLine(),
4078                                 nodePosId,
4079                                 pipelineMaker);
4080                 break;
4081 
4082             case INNER_NOT_SET:
4083                 break;
4084         }
4085 
4086         if (inflatedView == null) {
4087             // Covers null (returned when the childCase in the proto isn't known). Sadly, ProtoLite
4088             // doesn't give us a way to access childCase's underlying tag, so we can't give any
4089             // smarter error message here.
4090             Log.w(TAG, "Unknown child type");
4091         } else if (nodePosId.isEmpty()) {
4092             Log.w(TAG, "No node ID for " + element.getInnerCase().name());
4093         } else {
4094             // Set the view's tag to a known, position-based ID so that it can be looked up to apply
4095             // mutations.
4096             inflatedView.mView.setTag(nodePosId);
4097             if (inflatedView.mView instanceof ViewGroup) {
4098                 layoutInfoBuilder.add(
4099                         nodePosId,
4100                         ViewProperties.fromViewGroup(
4101                                 (ViewGroup) inflatedView.mView,
4102                                 inflatedView.mLayoutParams,
4103                                 inflatedView.mChildLayoutParams));
4104             }
4105             pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId));
4106         }
4107         return inflatedView;
4108     }
4109 
4110     /** Checks whether a layout element has a transformation modifier. */
hasTransformation(@onNull LayoutElement content)4111     private static boolean hasTransformation(@NonNull LayoutElement content) {
4112         switch (content.getInnerCase()) {
4113             case IMAGE:
4114                 return content.getImage().hasModifiers()
4115                         && content.getImage().getModifiers().hasTransformation();
4116             case TEXT:
4117                 return content.getText().hasModifiers()
4118                         && content.getText().getModifiers().hasTransformation();
4119             case SPACER:
4120                 return content.getSpacer().hasModifiers()
4121                         && content.getSpacer().getModifiers().hasTransformation();
4122             case BOX:
4123                 return content.getBox().hasModifiers()
4124                         && content.getBox().getModifiers().hasTransformation();
4125             case ROW:
4126                 return content.getRow().hasModifiers()
4127                         && content.getRow().getModifiers().hasTransformation();
4128             case COLUMN:
4129                 return content.getColumn().hasModifiers()
4130                         && content.getColumn().getModifiers().hasTransformation();
4131             case SPANNABLE:
4132                 return content.getSpannable().hasModifiers()
4133                         && content.getSpannable().getModifiers().hasTransformation();
4134             case ARC:
4135                 return content.getArc().hasModifiers()
4136                         && content.getArc().getModifiers().hasTransformation();
4137             case EXTENSION:
4138                 // fall through
4139             case INNER_NOT_SET:
4140                 return false;
4141         }
4142         return false;
4143     }
4144 
inflateLayoutElement( ParentViewWrapper parentViewWrapper, LayoutElement element, String nodePosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)4145     private @Nullable InflatedView inflateLayoutElement(
4146             ParentViewWrapper parentViewWrapper,
4147             LayoutElement element,
4148             String nodePosId,
4149             boolean includeChildren,
4150             LayoutInfo.Builder layoutInfoBuilder,
4151             Optional<PipelineMaker> pipelineMaker) {
4152         InflatedView inflatedView = null;
4153         // What is it?
4154         switch (element.getInnerCase()) {
4155             case COLUMN:
4156                 inflatedView =
4157                         inflateColumn(
4158                                 parentViewWrapper,
4159                                 element.getColumn(),
4160                                 nodePosId,
4161                                 includeChildren,
4162                                 layoutInfoBuilder,
4163                                 pipelineMaker);
4164                 break;
4165             case ROW:
4166                 inflatedView =
4167                         inflateRow(
4168                                 parentViewWrapper,
4169                                 element.getRow(),
4170                                 nodePosId,
4171                                 includeChildren,
4172                                 layoutInfoBuilder,
4173                                 pipelineMaker);
4174                 break;
4175             case BOX:
4176                 inflatedView =
4177                         inflateBox(
4178                                 parentViewWrapper,
4179                                 element.getBox(),
4180                                 nodePosId,
4181                                 includeChildren,
4182                                 layoutInfoBuilder,
4183                                 pipelineMaker);
4184                 break;
4185             case SPACER:
4186                 inflatedView =
4187                         inflateSpacer(
4188                                 parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker);
4189                 break;
4190             case TEXT:
4191                 inflatedView =
4192                         inflateText(parentViewWrapper, element.getText(), nodePosId, pipelineMaker);
4193                 break;
4194             case IMAGE:
4195                 inflatedView =
4196                         inflateImage(
4197                                 parentViewWrapper, element.getImage(), nodePosId, pipelineMaker);
4198                 break;
4199             case ARC:
4200                 inflatedView =
4201                         inflateArc(
4202                                 parentViewWrapper,
4203                                 element.getArc(),
4204                                 nodePosId,
4205                                 includeChildren,
4206                                 layoutInfoBuilder,
4207                                 pipelineMaker);
4208                 break;
4209             case SPANNABLE:
4210                 inflatedView =
4211                         inflateSpannable(
4212                                 parentViewWrapper,
4213                                 element.getSpannable(),
4214                                 nodePosId,
4215                                 pipelineMaker);
4216                 break;
4217             case EXTENSION:
4218                 try {
4219                     inflatedView = inflateExtension(parentViewWrapper, element.getExtension());
4220                 } catch (IllegalStateException ex) {
4221                     Log.w(TAG, "Error inflating Extension.", ex);
4222                 }
4223                 break;
4224             case INNER_NOT_SET:
4225                 Log.w(TAG, "Unknown child type: " + element.getInnerCase().name());
4226                 break;
4227         }
4228 
4229         if (inflatedView == null) {
4230             Log.w(TAG, "Error inflating " + element.getInnerCase().name());
4231         } else if (nodePosId.isEmpty()) {
4232             Log.w(TAG, "No node ID for " + element.getInnerCase().name());
4233         } else {
4234             // Set the view's tag to a known, position-based ID so that it can be looked up to apply
4235             // mutations.
4236             inflatedView.mView.setTag(nodePosId);
4237             if (inflatedView.mView instanceof ViewGroup) {
4238                 layoutInfoBuilder.add(
4239                         nodePosId,
4240                         ViewProperties.fromViewGroup(
4241                                 (ViewGroup) inflatedView.mView,
4242                                 inflatedView.mLayoutParams,
4243                                 inflatedView.mChildLayoutParams));
4244             }
4245             pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId));
4246         }
4247         return inflatedView;
4248     }
4249 
inflateExtension( ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element)4250     private @Nullable InflatedView inflateExtension(
4251             ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) {
4252         int widthPx = safeDpToPx(element.getWidth().getLinearDimension());
4253         int heightPx = safeDpToPx(element.getHeight().getLinearDimension());
4254 
4255         if (widthPx == 0 && heightPx == 0) {
4256             return null;
4257         }
4258 
4259         if (mExtensionViewProvider == null) {
4260             Log.e(TAG, "Layout has extension payload, but no extension provider is available.");
4261             return inflateFailedExtension(parentViewWrapper, element);
4262         }
4263 
4264         View view =
4265                 mExtensionViewProvider.provideView(
4266                         element.getPayload().toByteArray(), element.getExtensionId());
4267 
4268         if (view == null) {
4269             Log.w(TAG, "Extension view provider returned null.");
4270             // A failed extension should still occupy space.
4271             return inflateFailedExtension(parentViewWrapper, element);
4272         }
4273 
4274         if (view.getTag() != null) {
4275             throw new IllegalStateException("Extension must not set View's default tag");
4276         }
4277 
4278         LayoutParams lp = new LayoutParams(widthPx, heightPx);
4279         parentViewWrapper.maybeAddView(view, lp);
4280 
4281         return new InflatedView(
4282                 view, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp));
4283     }
4284 
inflateFailedExtension( ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element)4285     private InflatedView inflateFailedExtension(
4286             ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) {
4287         int widthPx = safeDpToPx(element.getWidth().getLinearDimension());
4288         int heightPx = safeDpToPx(element.getHeight().getLinearDimension());
4289 
4290         Space space = new Space(mUiContext);
4291 
4292         LayoutParams lp = new LayoutParams(widthPx, heightPx);
4293         parentViewWrapper.maybeAddView(space, lp);
4294 
4295         return new InflatedView(
4296                 space, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp));
4297     }
4298 
4299     /**
4300      * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
4301      * values. Returns null if no size wrapper is needed.
4302      */
resolveValueForLayoutIfNeeded(StringProp stringProp)4303     private @Nullable String resolveValueForLayoutIfNeeded(StringProp stringProp) {
4304         if (!stringProp.hasDynamicValue() || !mDataPipeline.isPresent()) {
4305             return null;
4306         }
4307 
4308         // If value_for_layout is set to non-zero, always use it.
4309         if (!stringProp.getValueForLayout().isEmpty()) {
4310             return stringProp.getValueForLayout();
4311         }
4312 
4313         return mAllowLayoutChangingBindsWithoutDefault ? null : "";
4314     }
4315 
4316     /**
4317      * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
4318      * values. Returns null if no size wrapper is needed.
4319      */
resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension)4320     private @Nullable Float resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension) {
4321         DpProp dimension = spacerDimension.getLinearDimension();
4322         if (!dimension.hasDynamicValue() || !mDataPipeline.isPresent()) {
4323             return null;
4324         }
4325 
4326         if (dimension.getValueForLayout() > 0f) {
4327             return dimension.getValueForLayout();
4328         }
4329 
4330         return mAllowLayoutChangingBindsWithoutDefault ? null : 0f;
4331     }
4332 
4333     /**
4334      * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
4335      * values. Returns null if no size wrapper is needed.
4336      */
resolveSizeForLayoutIfNeeded(DegreesProp degreesProp)4337     private @Nullable Float resolveSizeForLayoutIfNeeded(DegreesProp degreesProp) {
4338         if (!degreesProp.hasDynamicValue() || !mDataPipeline.isPresent()) {
4339             return null;
4340         }
4341 
4342         // If value_for_layout is set to non-zero, always use it
4343         if (degreesProp.getValueForLayout() > 0f) {
4344             return degreesProp.getValueForLayout();
4345         }
4346 
4347         return mAllowLayoutChangingBindsWithoutDefault ? null : 0f;
4348     }
4349 
canMeasureContainer( ContainerDimension containerWidth, ContainerDimension containerHeight, List<LayoutElement> elements)4350     private boolean canMeasureContainer(
4351             ContainerDimension containerWidth,
4352             ContainerDimension containerHeight,
4353             List<LayoutElement> elements) {
4354         // We can't measure a container if it's set to wrap-contents but all of its contents are set
4355         // to expand-to-parent. Such containers must not be displayed.
4356         if (containerWidth.hasWrappedDimension()
4357                 && !containsMeasurableWidth(containerHeight, elements)) {
4358             return false;
4359         }
4360         return !containerHeight.hasWrappedDimension()
4361                 || containsMeasurableHeight(containerWidth, elements);
4362     }
4363 
containsMeasurableWidth( ContainerDimension containerHeight, List<LayoutElement> elements)4364     private boolean containsMeasurableWidth(
4365             ContainerDimension containerHeight, List<LayoutElement> elements) {
4366         for (LayoutElement element : elements) {
4367             if (isWidthMeasurable(element, containerHeight)) {
4368                 // Enough to find a single element that is measurable.
4369                 return true;
4370             }
4371         }
4372         return false;
4373     }
4374 
containsMeasurableHeight( ContainerDimension containerWidth, List<LayoutElement> elements)4375     private boolean containsMeasurableHeight(
4376             ContainerDimension containerWidth, List<LayoutElement> elements) {
4377         for (LayoutElement element : elements) {
4378             if (isHeightMeasurable(element, containerWidth)) {
4379                 // Enough to find a single element that is measurable.
4380                 return true;
4381             }
4382         }
4383         return false;
4384     }
4385 
isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight)4386     private boolean isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight) {
4387         switch (element.getInnerCase()) {
4388             case COLUMN:
4389                 return isMeasurable(element.getColumn().getWidth());
4390             case ROW:
4391                 return isMeasurable(element.getRow().getWidth());
4392             case BOX:
4393                 return isMeasurable(element.getBox().getWidth());
4394             case SPACER:
4395                 return isMeasurable(element.getSpacer().getWidth());
4396             case IMAGE:
4397                 // Special-case. If the image width is proportional, then the height must be
4398                 // measurable. This means either a fixed size, or expanded where we know the parent
4399                 // dimension.
4400                 Image img = element.getImage();
4401                 if (img.getWidth().hasProportionalDimension()) {
4402                     boolean isContainerHeightKnown =
4403                             (containerHeight.hasExpandedDimension()
4404                                     || containerHeight.hasLinearDimension());
4405                     return img.getHeight().hasLinearDimension()
4406                             || (img.getHeight().hasExpandedDimension() && isContainerHeightKnown);
4407                 } else {
4408                     return isMeasurable(element.getImage().getWidth());
4409                 }
4410             case ARC:
4411             case TEXT:
4412             case SPANNABLE:
4413                 return true;
4414             case EXTENSION:
4415                 return isMeasurable(element.getExtension().getWidth());
4416             case INNER_NOT_SET:
4417                 return false;
4418         }
4419         return false;
4420     }
4421 
isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth)4422     private boolean isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth) {
4423         switch (element.getInnerCase()) {
4424             case COLUMN:
4425                 return isMeasurable(element.getColumn().getHeight());
4426             case ROW:
4427                 return isMeasurable(element.getRow().getHeight());
4428             case BOX:
4429                 return isMeasurable(element.getBox().getHeight());
4430             case SPACER:
4431                 return isMeasurable(element.getSpacer().getHeight());
4432             case IMAGE:
4433                 // Special-case. If the image height is proportional, then the width must be
4434                 // measurable. This means either a fixed size, or expanded where we know the parent
4435                 // dimension.
4436                 Image img = element.getImage();
4437                 if (img.getHeight().hasProportionalDimension()) {
4438                     boolean isContainerWidthKnown =
4439                             (containerWidth.hasExpandedDimension()
4440                                     || containerWidth.hasLinearDimension());
4441                     return img.getWidth().hasLinearDimension()
4442                             || (img.getWidth().hasExpandedDimension() && isContainerWidthKnown);
4443                 } else {
4444                     return isMeasurable(element.getImage().getHeight());
4445                 }
4446             case ARC:
4447             case TEXT:
4448             case SPANNABLE:
4449                 return true;
4450             case EXTENSION:
4451                 return isMeasurable(element.getExtension().getHeight());
4452             case INNER_NOT_SET:
4453                 return false;
4454         }
4455         return false;
4456     }
4457 
isMeasurable(ContainerDimension dimension)4458     private boolean isMeasurable(ContainerDimension dimension) {
4459         return dimensionToPx(dimension) != LayoutParams.MATCH_PARENT;
4460     }
4461 
isMeasurable(ImageDimension dimension)4462     private static boolean isMeasurable(ImageDimension dimension) {
4463         switch (dimension.getInnerCase()) {
4464             case LINEAR_DIMENSION:
4465             case PROPORTIONAL_DIMENSION:
4466                 return true;
4467             case EXPANDED_DIMENSION:
4468             case INNER_NOT_SET:
4469                 return false;
4470         }
4471         return false;
4472     }
4473 
isMeasurable(SpacerDimension dimension)4474     private static boolean isMeasurable(SpacerDimension dimension) {
4475         switch (dimension.getInnerCase()) {
4476             case LINEAR_DIMENSION:
4477                 return true;
4478             case EXPANDED_DIMENSION:
4479                 return false;
4480             case INNER_NOT_SET:
4481                 return false;
4482         }
4483         return false;
4484     }
4485 
isMeasurable(ExtensionDimension dimension)4486     private static boolean isMeasurable(ExtensionDimension dimension) {
4487         switch (dimension.getInnerCase()) {
4488             case LINEAR_DIMENSION:
4489                 return true;
4490             case INNER_NOT_SET:
4491                 return false;
4492         }
4493         return false;
4494     }
4495 
inflateChildElements( @onNull ViewGroup parent, @NonNull LayoutParams parentLayoutParams, PendingLayoutParams childLayoutParams, List<LayoutElement> childElements, String parentPosId, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)4496     private void inflateChildElements(
4497             @NonNull ViewGroup parent,
4498             @NonNull LayoutParams parentLayoutParams,
4499             PendingLayoutParams childLayoutParams,
4500             List<LayoutElement> childElements,
4501             String parentPosId,
4502             LayoutInfo.Builder layoutInfoBuilder,
4503             Optional<PipelineMaker> pipelineMaker) {
4504         int index = FIRST_CHILD_INDEX;
4505         for (LayoutElement childElement : childElements) {
4506             String childPosId = ProtoLayoutDiffer.createNodePosId(parentPosId, index++);
4507             inflateLayoutElement(
4508                     new ParentViewWrapper(parent, parentLayoutParams, childLayoutParams),
4509                     childElement,
4510                     childPosId,
4511                     /* includeChildren= */ true,
4512                     layoutInfoBuilder,
4513                     pipelineMaker);
4514         }
4515     }
4516 
4517     /**
4518      * Inflates a ProtoLayout into {@code inflateParent}.
4519      *
4520      * @param inflateParent The view to attach the layout into.
4521      * @return The {@link InflateResult} class containing the first child that was inflated,
4522      *     animations to be played, and new nodes for the dynamic data pipeline. Callers should use
4523      *     {@link InflateResult#updateDynamicDataPipeline} to apply those changes using a UI Thread.
4524      *     <p>This may be null if the proto is empty the top-level LayoutElement has no inner set,
4525      *     or the top-level LayoutElement contains an unsupported inner type.
4526      */
inflate(@onNull ViewGroup inflateParent)4527     public @Nullable InflateResult inflate(@NonNull ViewGroup inflateParent) {
4528 
4529         // This is a full re-inflation, so we don't need any previous rendering information.
4530         LayoutInfo.Builder layoutInfoBuilder =
4531                 new LayoutInfo.Builder(/* previousLayoutInfo= */ null);
4532 
4533         // Go!
4534         Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker =
4535                 mDataPipeline.map(
4536                         p ->
4537                                 p.newPipelineMaker(
4538                                         ProtoLayoutInflater::getEnterAnimations,
4539                                         ProtoLayoutInflater::getExitAnimations));
4540         InflatedView firstInflatedChild =
4541                 inflateLayoutElement(
4542                         new ParentViewWrapper(
4543                                 inflateParent,
4544                                 new LayoutParams(
4545                                         LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)),
4546                         mLayoutProto.getRoot(),
4547                         ROOT_NODE_ID,
4548                         /* includeChildren= */ true,
4549                         layoutInfoBuilder,
4550                         pipelineMaker);
4551         if (firstInflatedChild == null) {
4552             return null;
4553         }
4554         if (mLayoutProto.hasFingerprint()) {
4555             inflateParent.setTag(
4556                     R.id.rendered_metadata_tag,
4557                     new RenderedMetadata(mLayoutProto.getFingerprint(), layoutInfoBuilder.build()));
4558         }
4559         return new InflateResult(inflateParent, firstInflatedChild.mView, pipelineMaker);
4560     }
4561 
4562     /**
4563      * Compute the mutation that must be applied to the given {@link ViewGroup} in order to produce
4564      * the given target layout.
4565      *
4566      * <p>If the return value is {@code null}, {@code parent} must be updated in full using {@link
4567      * #inflate}. Otherwise, call {ViewGroupMutation#isNoOp} on the return value to check if there
4568      * are any mutations to apply and call {@link #applyMutation} to apply them.
4569      *
4570      * <p>Can be called from a background thread.
4571      *
4572      * @param prevRenderedMetadata The metadata for the previous rendering of this view, either
4573      *     using {@code inflate} or {@code applyMutation}. This can be retrieved by calling {@link
4574      *     #getRenderedMetadata} on the previous layout view parent.
4575      * @param targetLayout The target layout that the mutation should result in.
4576      * @return The mutation that will produce the target layout.
4577      */
computeMutation( @onNull RenderedMetadata prevRenderedMetadata, @NonNull Layout targetLayout, @NonNull ViewProperties parentViewProp)4578     public @Nullable ViewGroupMutation computeMutation(
4579             @NonNull RenderedMetadata prevRenderedMetadata,
4580             @NonNull Layout targetLayout,
4581             @NonNull ViewProperties parentViewProp) {
4582         if (prevRenderedMetadata.getTreeFingerprint() == null) {
4583             Log.w(TAG, "No previous fingerprint available.");
4584             return null;
4585         }
4586         LayoutDiff diff =
4587                 ProtoLayoutDiffer.getDiff(prevRenderedMetadata.getTreeFingerprint(), targetLayout);
4588         if (diff == null) {
4589             Log.w(TAG, "getDiff failed");
4590             return null;
4591         }
4592 
4593         logDebug(diff);
4594 
4595         List<InflatedView> inflatedViews = new ArrayList<>();
4596         LayoutInfo.Builder layoutInfoBuilder =
4597                 new LayoutInfo.Builder(prevRenderedMetadata.getLayoutInfo());
4598         LayoutInfo prevLayoutInfo = prevRenderedMetadata.getLayoutInfo();
4599         Optional<PipelineMaker> pipelineMaker =
4600                 mDataPipeline.map(
4601                         p ->
4602                                 p.newPipelineMaker(
4603                                         ProtoLayoutInflater::getEnterAnimations,
4604                                         ProtoLayoutInflater::getExitAnimations));
4605         for (TreeNodeWithChange changedNode : diff.getChangedNodes()) {
4606             String nodePosId = changedNode.getPosId();
4607             if (nodePosId.isEmpty()) {
4608                 // Failed to compute mutation. Need to update fully.
4609                 Log.w(TAG, "Empty nodePosId");
4610                 return null;
4611             }
4612             ViewProperties parentInfo;
4613             if (nodePosId.equals(ROOT_NODE_ID)) {
4614                 parentInfo = parentViewProp;
4615             } else {
4616                 String parentNodePosId = getParentNodePosId(nodePosId);
4617                 if (parentNodePosId == null || !prevLayoutInfo.contains(parentNodePosId)) {
4618                     // Failed to compute mutation. Need to update fully.
4619                     Log.w(TAG, "Can't find view " + nodePosId);
4620                     return null;
4621                 }
4622 
4623                 // The parent node might also have been updated.
4624                 ViewProperties possibleUpdatedParentInfo =
4625                         layoutInfoBuilder.getViewPropertiesFor(parentNodePosId);
4626                 parentInfo =
4627                         possibleUpdatedParentInfo != null
4628                                 ? possibleUpdatedParentInfo
4629                                 : checkNotNull(
4630                                         prevLayoutInfo.getViewPropertiesFor(parentNodePosId));
4631             }
4632             InflatedView inflatedView = null;
4633             LayoutElement updatedLayoutElement = changedNode.getLayoutElement();
4634             ArcLayoutElement updatedArcLayoutElement = changedNode.getArcLayoutElement();
4635             if (updatedLayoutElement != null) {
4636                 inflatedView =
4637                         inflateLayoutElement(
4638                                 new ParentViewWrapper(parentInfo),
4639                                 updatedLayoutElement,
4640                                 nodePosId,
4641                                 !changedNode.isSelfOnlyChange(),
4642                                 layoutInfoBuilder,
4643                                 pipelineMaker);
4644             } else if (updatedArcLayoutElement != null) {
4645                 inflatedView =
4646                         inflateArcLayoutElement(
4647                                 new ParentViewWrapper(parentInfo),
4648                                 updatedArcLayoutElement,
4649                                 nodePosId,
4650                                 layoutInfoBuilder,
4651                                 pipelineMaker);
4652             }
4653             if (inflatedView == null) {
4654                 // Failed to compute mutation. Need to update fully.
4655                 Log.w(TAG, "No inflatedView");
4656                 return null;
4657             }
4658             inflatedViews.add(inflatedView);
4659 
4660             if (!ProtoLayoutDiffer.UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE) {
4661 
4662                 throw new UnsupportedOperationException();
4663             }
4664             if (!changedNode.isSelfOnlyChange()) {
4665                 // A child addition/removal causes a full reinflation of the parent. So the only
4666                 // case that we might not replace a node in pipeline is when it's removed as part of
4667                 // a parent change.
4668                 pipelineMaker.ifPresent(p -> p.markForChildRemoval(nodePosId));
4669             }
4670             pipelineMaker.ifPresent(
4671                     p -> p.markNodeAsChanged(nodePosId, !changedNode.isSelfOnlyChange()));
4672         }
4673         return new ViewGroupMutation(
4674                 inflatedViews,
4675                 new RenderedMetadata(targetLayout.getFingerprint(), layoutInfoBuilder.build()),
4676                 prevRenderedMetadata.getTreeFingerprint().getRoot(),
4677                 pipelineMaker);
4678     }
4679 
4680     /** Apply the mutation that was previously computed with {@link #computeMutation}. */
4681     @UiThread
applyMutation( @onNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation)4682     public @NonNull ListenableFuture<RenderingArtifact> applyMutation(
4683             @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) {
4684         RenderedMetadata prevRenderedMetadata = getRenderedMetadata(prevInflatedParent);
4685         if (prevRenderedMetadata != null
4686                 && !ProtoLayoutDiffer.areNodesEquivalent(
4687                         prevRenderedMetadata.getTreeFingerprint().getRoot(),
4688                         groupMutation.mPreMutationRootNodeFingerprint)) {
4689 
4690             // be considered unequal. Log.e(TAG, "View has changed. Skipping mutation."); return
4691             // false;
4692         }
4693         if (groupMutation.isNoOp()) {
4694             // Nothing to do.
4695             return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger));
4696         }
4697 
4698         if (groupMutation.mPipelineMaker.isPresent()) {
4699             SettableFuture<RenderingArtifact> result = SettableFuture.create();
4700             groupMutation
4701                     .mPipelineMaker
4702                     .get()
4703                     .playExitAnimations(
4704                             prevInflatedParent,
4705                             /* isReattaching= */ false,
4706                             () -> {
4707                                 try {
4708                                     applyMutationInternal(prevInflatedParent, groupMutation);
4709                                     result.set(RenderingArtifact.create(mInflaterStatsLogger));
4710                                 } catch (ViewMutationException ex) {
4711                                     result.setException(ex);
4712                                 }
4713                             });
4714             return result;
4715         } else {
4716             try {
4717                 applyMutationInternal(prevInflatedParent, groupMutation);
4718                 return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger));
4719             } catch (ViewMutationException ex) {
4720                 return immediateFailedFuture(ex);
4721             }
4722         }
4723     }
4724 
applyMutationInternal( @onNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation)4725     private void applyMutationInternal(
4726             @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) {
4727         mInflaterStatsLogger.logMutationChangedNodes(groupMutation.mInflatedViews.size());
4728         for (InflatedView inflatedView : groupMutation.mInflatedViews) {
4729             String posId = inflatedView.getTag();
4730             if (posId == null) {
4731                 // Failed to apply the mutation. Need to update fully.
4732                 throw new ViewMutationException("View has no tag");
4733             }
4734             View viewToUpdate = prevInflatedParent.findViewWithTag(posId);
4735             if (viewToUpdate == null) {
4736                 // Failed to apply the mutation. Need to update fully.
4737                 throw new ViewMutationException("Can't find view " + posId);
4738             }
4739             ViewParent potentialImmediateParent = viewToUpdate.getParent();
4740             if (!(potentialImmediateParent instanceof ViewGroup)) {
4741                 // Failed to apply the mutation. Need to update fully.
4742                 throw new ViewMutationException("Parent not a ViewGroup");
4743             }
4744             ViewGroup immediateParent = (ViewGroup) potentialImmediateParent;
4745             int childIndex = immediateParent.indexOfChild(viewToUpdate);
4746             if (childIndex == -1) {
4747                 // Failed to apply the mutation. Need to update fully.
4748                 throw new ViewMutationException("Can't find child at " + childIndex);
4749             }
4750             if (!inflatedView.addMissingChildrenFrom(viewToUpdate)) {
4751                 throw new ViewMutationException("Failed to add missing children " + posId);
4752             }
4753             // Remove the touch delegate to the view to be updated
4754             if (immediateParent.getTouchDelegate() != null) {
4755                 TouchDelegateComposite delegateComposite =
4756                         (TouchDelegateComposite) immediateParent.getTouchDelegate();
4757                 delegateComposite.removeDelegate(viewToUpdate);
4758 
4759                 // Make sure to remove the touch delegate when the actual clickable view is wrapped,
4760                 // for example ImageView inside the RatioViewWrapper
4761                 if (viewToUpdate instanceof ViewGroup
4762                         && ((ViewGroup) viewToUpdate).getChildCount() > 0) {
4763                     delegateComposite.removeDelegate(((ViewGroup) viewToUpdate).getChildAt(0));
4764                 }
4765 
4766                 // If no more touch delegate left in the composite, remove it completely from the
4767                 // parent
4768                 if (delegateComposite.isEmpty()) {
4769                     immediateParent.setTouchDelegate(null);
4770                 }
4771             }
4772             immediateParent.removeViewAt(childIndex);
4773             immediateParent.addView(inflatedView.mView, childIndex, inflatedView.mLayoutParams);
4774 
4775             if (DEBUG_DIFF_UPDATE_ENABLED) {
4776                 // Visualize diff update (by flashing the inflated element).
4777                 inflatedView
4778                         .mView
4779                         .animate()
4780                         .alpha(0.7f)
4781                         .setDuration(50)
4782                         .withEndAction(() -> inflatedView.mView.animate().alpha(1).setDuration(50));
4783             }
4784         }
4785         groupMutation.mPipelineMaker.ifPresent(
4786                 pipe -> pipe.commit(prevInflatedParent, /* isReattaching= */ false));
4787         prevInflatedParent.setTag(
4788                 R.id.rendered_metadata_tag, groupMutation.mRenderedMetadataAfterMutation);
4789     }
4790 
4791     /** Returns the {@link RenderedMetadata} attached to {@code inflateParent}. */
4792     @UiThread
getRenderedMetadata(@onNull ViewGroup inflateParent)4793     public static @Nullable RenderedMetadata getRenderedMetadata(@NonNull ViewGroup inflateParent) {
4794         Object prevMetadataObject = inflateParent.getTag(R.id.rendered_metadata_tag);
4795         if (prevMetadataObject instanceof RenderedMetadata) {
4796             return (RenderedMetadata) prevMetadataObject;
4797         } else {
4798             if (prevMetadataObject != null) {
4799                 Log.w(TAG, "Incompatible prevMetadataObject");
4800             }
4801             return null;
4802         }
4803     }
4804 
4805     /** Clears the {@link RenderedMetadata} attached to {@code inflateParent}. */
4806     @UiThread
clearRenderedMetadata(@onNull ViewGroup inflateParent)4807     public static void clearRenderedMetadata(@NonNull ViewGroup inflateParent) {
4808         Log.d(TAG, "Clearing rendered metadata. Next inflation won't use diff update.");
4809         inflateParent.setTag(R.id.rendered_metadata_tag, /* tag= */ null);
4810     }
4811 
4812     // dereference of possibly-null reference ((FrameLayout.LayoutParams)child.getLayoutParams())
4813     @SuppressWarnings("nullness:dereference.of.nullable")
applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity)4814     private static void applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity) {
4815         for (int i = 0; i < parent.getChildCount(); i++) {
4816             View child = parent.getChildAt(i);
4817 
4818             // All children should have a LayoutParams already set...
4819             if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) {
4820                 // This...shouldn't happen.
4821                 throw new IllegalStateException(
4822                         "Layout params of child is not a descendant of FrameLayout.LayoutParams.");
4823             }
4824 
4825             // Children should grow out from the middle of the layout.
4826             ((FrameLayout.LayoutParams) child.getLayoutParams()).gravity = gravity;
4827         }
4828     }
4829 
roleToClassName(SemanticsRole role)4830     static String roleToClassName(SemanticsRole role) {
4831         switch (role) {
4832             case SEMANTICS_ROLE_IMAGE:
4833                 return "android.widget.ImageView";
4834             case SEMANTICS_ROLE_BUTTON:
4835                 return "android.widget.Button";
4836             case SEMANTICS_ROLE_CHECKBOX:
4837                 return "android.widget.CheckBox";
4838             case SEMANTICS_ROLE_SWITCH:
4839                 return "android.widget.Switch";
4840             case SEMANTICS_ROLE_RADIOBUTTON:
4841                 return "android.widget.RadioButton";
4842             case SEMANTICS_ROLE_NONE:
4843             case UNRECOGNIZED:
4844                 return "";
4845         }
4846         return "";
4847     }
4848 
applySemantics( View view, Semantics semantics, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)4849     private void applySemantics(
4850             View view,
4851             Semantics semantics,
4852             String posId,
4853             Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
4854         view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
4855         ViewCompat.setAccessibilityDelegate(
4856                 view,
4857                 new AccessibilityDelegateCompat() {
4858                     @Override
4859                     public void onInitializeAccessibilityNodeInfo(
4860                             @NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
4861                         super.onInitializeAccessibilityNodeInfo(host, info);
4862 
4863                         String className = roleToClassName(semantics.getRole());
4864                         if (!className.isEmpty()) {
4865                             info.setClassName(className);
4866                         }
4867                         info.setFocusable(true);
4868                         info.setImportantForAccessibility(true);
4869                     }
4870                 });
4871 
4872         if (semantics.hasContentDescription()) {
4873             handleProp(
4874                     semantics.getContentDescription(),
4875                     mUiContext.getResources().getConfiguration().getLocales().get(0),
4876                     view::setContentDescription,
4877                     posId,
4878                     pipelineMaker);
4879         } else {
4880             // This is for backward compatibility
4881             view.setContentDescription(semantics.getObsoleteContentDescription());
4882         }
4883 
4884         if (semantics.hasStateDescription()) {
4885             handleProp(
4886                     semantics.getStateDescription(),
4887                     mUiContext.getResources().getConfiguration().getLocales().get(0),
4888                     (state) -> ViewCompat.setStateDescription(view, state),
4889                     posId,
4890                     pipelineMaker);
4891         }
4892     }
4893 
4894     /** Creates a TextView with the fallbackTextAppearance from the current theme. */
newThemedTextView()4895     private TextView newThemedTextView() {
4896         return new TextView(
4897                 mProtoLayoutThemeContext,
4898                 /* attrs= */ null,
4899                 mProtoLayoutTheme.getFallbackTextAppearanceResId());
4900     }
4901 
4902     /** Creates a CurvedTextView with the fallbackTextAppearance from the current theme. */
newThemedCurvedTextView()4903     private CurvedTextView newThemedCurvedTextView() {
4904         return new CurvedTextView(
4905                 mProtoLayoutThemeContext,
4906                 /* attrs= */ null,
4907                 mProtoLayoutTheme.getFallbackTextAppearanceResId());
4908     }
4909 
logDebug(LayoutDiff diff)4910     private void logDebug(LayoutDiff diff) {
4911         if (mLoggingUtils != null && mLoggingUtils.canLogD(TAG)) {
4912             StringBuilder sb =
4913                     new StringBuilder("LayoutDiff result at LayoutInflater#computeMutation: \n");
4914             List<TreeNodeWithChange> diffNodes = diff.getChangedNodes();
4915             if (diffNodes.isEmpty()) {
4916                 mLoggingUtils.logD(TAG, "No diff.");
4917                 return;
4918             }
4919             for (TreeNodeWithChange changedNode : diffNodes) {
4920                 sb.append(formatNodeChangeForLogs(changedNode));
4921                 if (changedNode.getLayoutElement() != null) {
4922                     sb.append(changedNode.getLayoutElement());
4923                 } else if (changedNode.getArcLayoutElement() != null) {
4924                     sb.append(changedNode.getArcLayoutElement());
4925                 }
4926                 sb.append("\n");
4927             }
4928             mLoggingUtils.logD(TAG, sb.toString());
4929         }
4930     }
4931 
formatNodeChangeForLogs(TreeNodeWithChange change)4932     private static String formatNodeChangeForLogs(TreeNodeWithChange change) {
4933         return "PosId: "
4934                 + change.getPosId()
4935                 + " | Fingerprint: "
4936                 + change.getFingerprint().getSelfTypeValue()
4937                 + " | isSelfOnlyChange: "
4938                 + change.isSelfOnlyChange();
4939     }
4940 
4941     /** Implementation of ClickableSpan for ProtoLayout's Clickables. */
4942     private class ProtoLayoutClickableSpan extends ClickableSpan {
4943         private final Clickable mClickable;
4944 
ProtoLayoutClickableSpan(@onNull Clickable clickable)4945         ProtoLayoutClickableSpan(@NonNull Clickable clickable) {
4946             this.mClickable = clickable;
4947         }
4948 
4949         @Override
onClick(@onNull View widget)4950         public void onClick(@NonNull View widget) {
4951             Action action = mClickable.getOnClick();
4952 
4953             switch (action.getValueCase()) {
4954                 case LAUNCH_ACTION:
4955                     Intent i =
4956                             buildLaunchActionIntent(
4957                                     action.getLaunchAction(),
4958                                     mClickable.getId(),
4959                                     mClickableIdExtra);
4960                     if (i != null) {
4961                         dispatchLaunchActionIntent(i);
4962                     }
4963                     break;
4964                 case LOAD_ACTION:
4965                     if (mLoadActionExecutor == null) {
4966                         Log.w(TAG, "Ignoring load action since an executor has not been provided.");
4967                         break;
4968                     }
4969                     mLoadActionExecutor.execute(
4970                             () ->
4971                                     mLoadActionListener.onClick(
4972                                             buildState(
4973                                                     action.getLoadAction(), mClickable.getId())));
4974                     break;
4975                 case VALUE_NOT_SET:
4976                     break;
4977             }
4978         }
4979 
4980         @Override
updateDrawState(@onNull TextPaint ds)4981         public void updateDrawState(@NonNull TextPaint ds) {
4982             // Don't change the underlying text appearance.
4983         }
4984     }
4985 
4986     /** Implementation of {@link Scroller} which inhibits all scrolling. */
4987     private static class InhibitingScroller extends Scroller {
InhibitingScroller(Context context)4988         InhibitingScroller(Context context) {
4989             super(context);
4990         }
4991 
4992         @Override
startScroll(int startX, int startY, int dx, int dy)4993         public void startScroll(int startX, int startY, int dx, int dy) {}
4994 
4995         @Override
startScroll(int startX, int startY, int dx, int dy, int duration)4996         public void startScroll(int startX, int startY, int dx, int dy, int duration) {}
4997     }
4998 }
4999