1 /*
2  * Copyright 2017 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.slice.widget;
18 
19 import static android.app.slice.Slice.HINT_HORIZONTAL;
20 import static android.app.slice.Slice.HINT_PARTIAL;
21 import static android.app.slice.Slice.HINT_SEE_MORE;
22 import static android.app.slice.Slice.HINT_SHORTCUT;
23 import static android.app.slice.Slice.HINT_SUMMARY;
24 import static android.app.slice.Slice.HINT_TITLE;
25 import static android.app.slice.Slice.SUBTYPE_CONTENT_DESCRIPTION;
26 import static android.app.slice.Slice.SUBTYPE_RANGE;
27 import static android.app.slice.SliceItem.FORMAT_ACTION;
28 import static android.app.slice.SliceItem.FORMAT_IMAGE;
29 import static android.app.slice.SliceItem.FORMAT_INT;
30 import static android.app.slice.SliceItem.FORMAT_LONG;
31 import static android.app.slice.SliceItem.FORMAT_REMOTE_INPUT;
32 import static android.app.slice.SliceItem.FORMAT_SLICE;
33 import static android.app.slice.SliceItem.FORMAT_TEXT;
34 
35 import static androidx.slice.core.SliceHints.HINT_KEYWORDS;
36 import static androidx.slice.core.SliceHints.HINT_LAST_UPDATED;
37 import static androidx.slice.core.SliceHints.HINT_TTL;
38 
39 import android.content.Context;
40 import android.text.TextUtils;
41 import android.util.Log;
42 
43 import androidx.annotation.NonNull;
44 import androidx.annotation.Nullable;
45 import androidx.annotation.RestrictTo;
46 import androidx.slice.SliceItem;
47 import androidx.slice.core.SliceAction;
48 import androidx.slice.core.SliceActionImpl;
49 import androidx.slice.core.SliceQuery;
50 import androidx.slice.view.R;
51 
52 import java.util.ArrayList;
53 import java.util.List;
54 
55 /**
56  * Extracts information required to present content in a row format from a slice.
57  * @hide
58  */
59 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
60 public class RowContent {
61     private static final String TAG = "RowContent";
62 
63     private SliceItem mPrimaryAction;
64     private SliceItem mRowSlice;
65     private SliceItem mStartItem;
66     private SliceItem mTitleItem;
67     private SliceItem mSubtitleItem;
68     private SliceItem mSummaryItem;
69     private ArrayList<SliceItem> mEndItems = new ArrayList<>();
70     private ArrayList<SliceAction> mToggleItems = new ArrayList<>();
71     private SliceItem mRange;
72     private SliceItem mContentDescr;
73     private boolean mEndItemsContainAction;
74     private boolean mIsHeader;
75     private int mLineCount = 0;
76     private int mMaxHeight;
77     private int mMinHeight;
78     private int mRangeHeight;
79 
RowContent(Context context, SliceItem rowSlice, boolean isHeader)80     public RowContent(Context context, SliceItem rowSlice, boolean isHeader) {
81         populate(rowSlice, isHeader);
82         mMaxHeight = context.getResources().getDimensionPixelSize(R.dimen.abc_slice_row_max_height);
83         mMinHeight = context.getResources().getDimensionPixelSize(R.dimen.abc_slice_row_min_height);
84         mRangeHeight = context.getResources().getDimensionPixelSize(
85                 R.dimen.abc_slice_row_range_height);
86     }
87 
88     /**
89      * @return whether this row has content that is valid to display.
90      */
populate(SliceItem rowSlice, boolean isHeader)91     private boolean populate(SliceItem rowSlice, boolean isHeader) {
92         mIsHeader = isHeader;
93         mRowSlice = rowSlice;
94         if (!isValidRow(rowSlice)) {
95             Log.w(TAG, "Provided SliceItem is invalid for RowContent");
96             return false;
97         }
98         determineStartAndPrimaryAction(rowSlice);
99 
100         mContentDescr = SliceQuery.findSubtype(rowSlice, FORMAT_TEXT, SUBTYPE_CONTENT_DESCRIPTION);
101 
102         // Filter anything not viable for displaying in a row
103         ArrayList<SliceItem> rowItems = filterInvalidItems(rowSlice);
104         // If we've only got one item that's a slice / action use those items instead
105         if (rowItems.size() == 1 && (FORMAT_ACTION.equals(rowItems.get(0).getFormat())
106                 || FORMAT_SLICE.equals(rowItems.get(0).getFormat()))
107                 && !rowItems.get(0).hasAnyHints(HINT_SHORTCUT, HINT_TITLE)) {
108             if (isValidRow(rowItems.get(0))) {
109                 rowSlice = rowItems.get(0);
110                 rowItems = filterInvalidItems(rowSlice);
111             }
112         }
113         if (SUBTYPE_RANGE.equals(rowSlice.getSubType())) {
114             mRange = rowSlice;
115         }
116         if (rowItems.size() > 0) {
117             // Remove the things we already know about
118             if (mStartItem != null) {
119                 rowItems.remove(mStartItem);
120             }
121             if (mPrimaryAction != null) {
122                 rowItems.remove(mPrimaryAction);
123             }
124 
125             // Text + end items
126             ArrayList<SliceItem> endItems = new ArrayList<>();
127             for (int i = 0; i < rowItems.size(); i++) {
128                 final SliceItem item = rowItems.get(i);
129                 if (FORMAT_TEXT.equals(item.getFormat())) {
130                     if ((mTitleItem == null || !mTitleItem.hasHint(HINT_TITLE))
131                             && item.hasHint(HINT_TITLE) && !item.hasHint(HINT_SUMMARY)) {
132                         mTitleItem = item;
133                     } else if (mSubtitleItem == null && !item.hasHint(HINT_SUMMARY)) {
134                         mSubtitleItem = item;
135                     } else if (mSummaryItem == null && item.hasHint(HINT_SUMMARY)) {
136                         mSummaryItem = item;
137                     }
138                 } else {
139                     endItems.add(item);
140                 }
141             }
142             if (hasText(mTitleItem)) {
143                 mLineCount++;
144             }
145             if (hasText(mSubtitleItem)) {
146                 mLineCount++;
147             }
148             // Special rules for end items: only one timestamp
149             boolean hasTimestamp = mStartItem != null
150                     && FORMAT_LONG.equals(mStartItem.getFormat());
151             for (int i = 0; i < endItems.size(); i++) {
152                 final SliceItem item = endItems.get(i);
153                 boolean isAction = SliceQuery.find(item, FORMAT_ACTION) != null;
154                 if (FORMAT_LONG.equals(item.getFormat())) {
155                     if (!hasTimestamp) {
156                         hasTimestamp = true;
157                         mEndItems.add(item);
158                     }
159                 } else {
160                     processContent(item, isAction);
161                 }
162             }
163         }
164         return isValid();
165     }
166 
processContent(@onNull SliceItem item, boolean isAction)167     private void processContent(@NonNull SliceItem item, boolean isAction) {
168         if (isAction) {
169             SliceAction ac = new SliceActionImpl(item);
170             if (ac.isToggle()) {
171                 mToggleItems.add(ac);
172             }
173         }
174         mEndItems.add(item);
175         mEndItemsContainAction |= isAction;
176     }
177 
178     /**
179      * Sets the {@link #getPrimaryAction()} and {@link #getStartItem()} for this row.
180      */
determineStartAndPrimaryAction(@onNull SliceItem rowSlice)181     private void determineStartAndPrimaryAction(@NonNull SliceItem rowSlice) {
182         List<SliceItem> possibleStartItems = SliceQuery.findAll(rowSlice, null, HINT_TITLE, null);
183         if (possibleStartItems.size() > 0) {
184             // The start item will be at position 0 if it exists
185             String format = possibleStartItems.get(0).getFormat();
186             if ((FORMAT_ACTION.equals(format)
187                     && SliceQuery.find(possibleStartItems.get(0), FORMAT_IMAGE) != null)
188                     || FORMAT_SLICE.equals(format)
189                     || FORMAT_LONG.equals(format)
190                     || FORMAT_IMAGE.equals(format)) {
191                 mStartItem = possibleStartItems.get(0);
192             }
193         }
194 
195         String[] hints = new String[] {HINT_SHORTCUT, HINT_TITLE};
196         List<SliceItem> possiblePrimaries = SliceQuery.findAll(rowSlice, FORMAT_SLICE, hints, null);
197         if (possiblePrimaries.isEmpty() && FORMAT_ACTION.equals(rowSlice.getFormat())
198                 && rowSlice.getSlice().getItems().size() == 1) {
199             mPrimaryAction = rowSlice;
200         } else if (mStartItem != null && possiblePrimaries.size() > 1
201                 && possiblePrimaries.get(0) == mStartItem) {
202             // Next item is the primary action
203             mPrimaryAction = possiblePrimaries.get(1);
204         } else if (possiblePrimaries.size() > 0) {
205             mPrimaryAction = possiblePrimaries.get(0);
206         }
207     }
208 
209     /**
210      * @return the {@link SliceItem} used to populate this row.
211      */
212     @NonNull
getSlice()213     public SliceItem getSlice() {
214         return mRowSlice;
215     }
216 
217     /**
218      * @return the {@link SliceItem} representing the range in the row; can be null.
219      */
220     @Nullable
getRange()221     public SliceItem getRange() {
222         return mRange;
223     }
224 
225     /**
226      * @return the {@link SliceItem} for the icon to use for the input range thumb drawable.
227      */
228     @Nullable
getInputRangeThumb()229     public SliceItem getInputRangeThumb() {
230         if (mRange != null) {
231             List<SliceItem> items = mRange.getSlice().getItems();
232             for (int i = 0; i < items.size(); i++) {
233                 if (FORMAT_IMAGE.equals(items.get(i).getFormat())) {
234                     return items.get(i);
235                 }
236             }
237         }
238         return null;
239     }
240 
241     /**
242      * @return the {@link SliceItem} used for the main intent for this row; can be null.
243      */
244     @Nullable
getPrimaryAction()245     public SliceItem getPrimaryAction() {
246         return mPrimaryAction;
247     }
248 
249     /**
250      * @return the {@link SliceItem} to display at the start of this row; can be null.
251      */
252     @Nullable
getStartItem()253     public SliceItem getStartItem() {
254         return mIsHeader ? null : mStartItem;
255     }
256 
257     /**
258      * @return the {@link SliceItem} representing the title text for this row; can be null.
259      */
260     @Nullable
getTitleItem()261     public SliceItem getTitleItem() {
262         return mTitleItem;
263     }
264 
265     /**
266      * @return the {@link SliceItem} representing the subtitle text for this row; can be null.
267      */
268     @Nullable
getSubtitleItem()269     public SliceItem getSubtitleItem() {
270         return mSubtitleItem;
271     }
272 
273     @Nullable
getSummaryItem()274     public SliceItem getSummaryItem() {
275         return mSummaryItem == null ? mSubtitleItem : mSummaryItem;
276     }
277 
278     /**
279      * @return the list of {@link SliceItem} that can be shown as items at the end of the row.
280      */
getEndItems()281     public ArrayList<SliceItem> getEndItems() {
282         return mEndItems;
283     }
284 
285     /**
286      * @return a list of toggles associated with this row.
287      */
getToggleItems()288     public ArrayList<SliceAction> getToggleItems() {
289         return mToggleItems;
290     }
291 
292     /**
293      * @return the content description to use for this row.
294      */
295     @Nullable
getContentDescription()296     public CharSequence getContentDescription() {
297         return mContentDescr != null ? mContentDescr.getText() : null;
298     }
299 
300     /**
301      * @return whether {@link #getEndItems()} contains a SliceItem with FORMAT_SLICE, HINT_SHORTCUT
302      */
endItemsContainAction()303     public boolean endItemsContainAction() {
304         return mEndItemsContainAction;
305     }
306 
307     /**
308      * @return the number of lines of text contained in this row.
309      */
getLineCount()310     public int getLineCount() {
311         return mLineCount;
312     }
313 
314     /**
315      * @return the height to display a row at when it is used as a small template.
316      */
getSmallHeight()317     public int getSmallHeight() {
318         return getRange() != null
319                 ? getActualHeight()
320                 : mMaxHeight;
321     }
322 
323     /**
324      * @return the height the content in this template requires to be displayed.
325      */
getActualHeight()326     public int getActualHeight() {
327         if (!isValid()) {
328             return 0;
329         }
330         int rowHeight = (getLineCount() > 1 || mIsHeader) ? mMaxHeight : mMinHeight;
331         if (getRange() != null) {
332             if (getLineCount() > 0) {
333                 rowHeight += mRangeHeight;
334             } else {
335                 rowHeight = mIsHeader ? mMaxHeight : mRangeHeight;
336             }
337         }
338         return rowHeight;
339     }
340 
hasText(SliceItem textSlice)341     private static boolean hasText(SliceItem textSlice) {
342         return textSlice != null
343                 && (textSlice.hasHint(HINT_PARTIAL)
344                     || !TextUtils.isEmpty(textSlice.getText()));
345     }
346 
347     /**
348      * @return whether this row content represents a default see more item.
349      */
isDefaultSeeMore()350     public boolean isDefaultSeeMore() {
351         return FORMAT_ACTION.equals(mRowSlice.getFormat())
352                 && mRowSlice.getSlice().hasHint(HINT_SEE_MORE)
353                 && mRowSlice.getSlice().getItems().isEmpty();
354     }
355 
356     /**
357      * @return whether this row has content that is valid to display.
358      */
isValid()359     public boolean isValid() {
360         return mStartItem != null
361                 || mPrimaryAction != null
362                 || mTitleItem != null
363                 || mSubtitleItem != null
364                 || mEndItems.size() > 0
365                 || mRange != null
366                 || isDefaultSeeMore();
367     }
368 
369     /**
370      * @return whether this is a valid item to use to populate a row of content.
371      */
isValidRow(SliceItem rowSlice)372     private static boolean isValidRow(SliceItem rowSlice) {
373         if (rowSlice == null) {
374             return false;
375         }
376         // Must be slice or action
377         if (FORMAT_SLICE.equals(rowSlice.getFormat())
378                 || FORMAT_ACTION.equals(rowSlice.getFormat())) {
379             List<SliceItem> rowItems = rowSlice.getSlice().getItems();
380             // Special case: default see more just has an action but no other items
381             if (rowSlice.hasHint(HINT_SEE_MORE) && rowItems.isEmpty()) {
382                 return true;
383             }
384             // Must have at least one legitimate child
385             for (int i = 0; i < rowItems.size(); i++) {
386                 if (isValidRowContent(rowSlice, rowItems.get(i))) {
387                     return true;
388                 }
389             }
390         }
391         return false;
392     }
393 
394     /**
395      * @return list of {@link SliceItem}s that are valid to display in a row according
396      * to {@link #isValidRowContent(SliceItem, SliceItem)}.
397      */
filterInvalidItems(SliceItem rowSlice)398     private static ArrayList<SliceItem> filterInvalidItems(SliceItem rowSlice) {
399         ArrayList<SliceItem> filteredList = new ArrayList<>();
400         for (SliceItem i : rowSlice.getSlice().getItems()) {
401             if (isValidRowContent(rowSlice, i)) {
402                 filteredList.add(i);
403             }
404         }
405         return filteredList;
406     }
407 
408     /**
409      * @return whether this item is valid content to visibly appear in a row.
410      */
isValidRowContent(SliceItem slice, SliceItem item)411     private static boolean isValidRowContent(SliceItem slice, SliceItem item) {
412         if (item.hasAnyHints(HINT_KEYWORDS, HINT_TTL, HINT_LAST_UPDATED, HINT_HORIZONTAL)
413                 || SUBTYPE_CONTENT_DESCRIPTION.equals(item.getSubType())) {
414             return false;
415         }
416         final String itemFormat = item.getFormat();
417         return FORMAT_IMAGE.equals(itemFormat)
418                 || FORMAT_TEXT.equals(itemFormat)
419                 || FORMAT_LONG.equals(itemFormat)
420                 || FORMAT_ACTION.equals(itemFormat)
421                 || FORMAT_REMOTE_INPUT.equals(itemFormat)
422                 || FORMAT_SLICE.equals(itemFormat)
423                 || (FORMAT_INT.equals(itemFormat) && SUBTYPE_RANGE.equals(slice.getSubType()));
424     }
425 }
426