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
17package androidx.slice;
18
19import static org.xmlpull.v1.XmlPullParser.START_TAG;
20import static org.xmlpull.v1.XmlPullParser.TEXT;
21
22import android.annotation.SuppressLint;
23import android.content.ContentResolver;
24import android.content.Context;
25import android.content.pm.PackageManager;
26import android.content.res.Resources;
27import android.graphics.Bitmap;
28import android.graphics.BitmapFactory;
29import android.graphics.Canvas;
30import android.graphics.drawable.Drawable;
31import android.graphics.drawable.Icon;
32import android.net.Uri;
33import android.os.Build;
34import android.text.Html;
35import android.text.Spanned;
36import android.text.TextUtils;
37import android.util.Base64;
38
39import androidx.annotation.RestrictTo;
40import androidx.core.graphics.drawable.IconCompat;
41import androidx.core.util.Consumer;
42
43import org.xmlpull.v1.XmlPullParser;
44import org.xmlpull.v1.XmlPullParserException;
45import org.xmlpull.v1.XmlPullParserFactory;
46import org.xmlpull.v1.XmlSerializer;
47
48import java.io.ByteArrayOutputStream;
49import java.io.IOException;
50import java.io.InputStream;
51import java.io.OutputStream;
52import java.util.List;
53
54/**
55 * @hide
56 */
57@RestrictTo(RestrictTo.Scope.LIBRARY)
58class SliceXml {
59
60    private static final String NAMESPACE = null;
61
62    private static final String TAG_SLICE = "slice";
63    private static final String TAG_ACTION = "action";
64    private static final String TAG_ITEM = "item";
65
66    private static final String ATTR_URI = "uri";
67    private static final String ATTR_FORMAT = "format";
68    private static final String ATTR_SUBTYPE = "subtype";
69    private static final String ATTR_HINTS = "hints";
70    private static final String ATTR_ICON_TYPE = "iconType";
71    private static final String ATTR_ICON_PACKAGE = "pkg";
72    private static final String ATTR_ICON_RES_TYPE = "resType";
73
74    private static final String ICON_TYPE_RES = "res";
75    private static final String ICON_TYPE_URI = "uri";
76    private static final String ICON_TYPE_DEFAULT = "def";
77
78    public static Slice parseSlice(Context context, InputStream input,
79            String encoding, SliceUtils.SliceActionListener listener)
80            throws IOException, SliceUtils.SliceParseException {
81        try {
82            XmlPullParser parser = XmlPullParserFactory.newInstance().newPullParser();
83            parser.setInput(input, encoding);
84
85            int outerDepth = parser.getDepth();
86            int type;
87            Slice s = null;
88            while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
89                    && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
90                if (type != START_TAG) {
91                    continue;
92                }
93                s = parseSlice(context, parser, listener);
94            }
95            return s;
96        } catch (XmlPullParserException e) {
97            throw new IOException("Unable to init XML Serialization", e);
98        }
99    }
100
101    @SuppressLint("WrongConstant")
102    private static Slice parseSlice(Context context, XmlPullParser parser,
103            SliceUtils.SliceActionListener listener)
104            throws IOException, XmlPullParserException, SliceUtils.SliceParseException {
105        if (!TAG_SLICE.equals(parser.getName()) && !TAG_ACTION.equals(parser.getName())) {
106            throw new IOException("Unexpected tag " + parser.getName());
107        }
108        int outerDepth = parser.getDepth();
109        int type;
110        String uri = parser.getAttributeValue(NAMESPACE, ATTR_URI);
111        Slice.Builder b = new Slice.Builder(Uri.parse(uri));
112        String[] hints = hints(parser.getAttributeValue(NAMESPACE, ATTR_HINTS));
113        b.addHints(hints);
114
115        while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
116                && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
117            if (type == START_TAG && TAG_ITEM.equals(parser.getName())) {
118                parseItem(context, b, parser, listener);
119            }
120        }
121        return b.build();
122    }
123
124    @SuppressLint("DefaultCharset")
125    private static void parseItem(Context context, Slice.Builder b,
126            XmlPullParser parser, final SliceUtils.SliceActionListener listener)
127            throws IOException, XmlPullParserException, SliceUtils.SliceParseException {
128        int type;
129        int outerDepth = parser.getDepth();
130        String format = parser.getAttributeValue(NAMESPACE, ATTR_FORMAT);
131        String subtype = parser.getAttributeValue(NAMESPACE, ATTR_SUBTYPE);
132        String hintStr = parser.getAttributeValue(NAMESPACE, ATTR_HINTS);
133        String iconType = parser.getAttributeValue(NAMESPACE, ATTR_ICON_TYPE);
134        String pkg = parser.getAttributeValue(NAMESPACE, ATTR_ICON_PACKAGE);
135        String resType = parser.getAttributeValue(NAMESPACE, ATTR_ICON_RES_TYPE);
136        @Slice.SliceHint String[] hints = hints(hintStr);
137        String v;
138        while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
139                && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) {
140            if (type == TEXT) {
141                switch (format) {
142                    case android.app.slice.SliceItem.FORMAT_REMOTE_INPUT:
143                        // Nothing for now.
144                        break;
145                    case android.app.slice.SliceItem.FORMAT_IMAGE:
146                        switch (iconType) {
147                            case ICON_TYPE_RES:
148                                String resName = parser.getText();
149                                try {
150                                    Resources r = context.getPackageManager()
151                                                .getResourcesForApplication(pkg);
152                                    int id = r.getIdentifier(resName, resType, pkg);
153                                    if (id != 0) {
154                                        b.addIcon(IconCompat.createWithResource(
155                                                context.createPackageContext(pkg, 0), id), subtype,
156                                                hints);
157                                    } else {
158                                        throw new SliceUtils.SliceParseException(
159                                                "Cannot find resource " + pkg + ":" + resType
160                                                        + "/" + resName);
161                                    }
162                                } catch (PackageManager.NameNotFoundException e) {
163                                    throw new SliceUtils.SliceParseException(
164                                            "Invalid icon package " + pkg, e);
165                                }
166                                break;
167                            case ICON_TYPE_URI:
168                                v = parser.getText();
169                                b.addIcon(IconCompat.createWithContentUri(v), subtype, hints);
170                                break;
171                            default:
172                                v = parser.getText();
173                                byte[] data = Base64.decode(v, Base64.NO_WRAP);
174                                Bitmap image = BitmapFactory.decodeByteArray(data, 0, data.length);
175                                b.addIcon(IconCompat.createWithBitmap(image), subtype, hints);
176                                break;
177                        }
178                        break;
179                    case android.app.slice.SliceItem.FORMAT_INT:
180                        v = parser.getText();
181                        b.addInt(Integer.parseInt(v), subtype, hints);
182                        break;
183                    case android.app.slice.SliceItem.FORMAT_TEXT:
184                        v = parser.getText();
185                        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
186                            // 19-21 don't allow special characters in XML, so we base64 encode it.
187                            v = new String(Base64.decode(v, Base64.NO_WRAP));
188                        }
189                        b.addText(Html.fromHtml(v), subtype, hints);
190                        break;
191                    case android.app.slice.SliceItem.FORMAT_LONG:
192                        v = parser.getText();
193                        b.addLong(Long.parseLong(v), subtype, hints);
194                        break;
195                    default:
196                        throw new IllegalArgumentException("Unrecognized format " + format);
197                }
198            } else if (type == START_TAG && TAG_SLICE.equals(parser.getName())) {
199                b.addSubSlice(parseSlice(context, parser, listener), subtype);
200            } else if (type == START_TAG && TAG_ACTION.equals(parser.getName())) {
201                b.addAction(new Consumer<Uri>() {
202                    @Override
203                    public void accept(Uri uri) {
204                        listener.onSliceAction(uri);
205                    }
206                }, parseSlice(context, parser, listener), subtype);
207            }
208        }
209    }
210
211    @Slice.SliceHint
212    private static String[] hints(String hintStr) {
213        return TextUtils.isEmpty(hintStr) ? new String[0] : hintStr.split(",");
214    }
215
216    public static void serializeSlice(Slice s, Context context, OutputStream output,
217            String encoding, SliceUtils.SerializeOptions options) throws IOException {
218        try {
219            XmlSerializer serializer = XmlPullParserFactory.newInstance().newSerializer();
220            serializer.setOutput(output, encoding);
221            serializer.startDocument(encoding, null);
222
223            serialize(s, context, options, serializer, false, null);
224
225            serializer.endDocument();
226            serializer.flush();
227        } catch (XmlPullParserException e) {
228            throw new IOException("Unable to init XML Serialization", e);
229        }
230    }
231
232    private static void serialize(Slice s, Context context, SliceUtils.SerializeOptions options,
233            XmlSerializer serializer, boolean isAction, String subType) throws IOException {
234        serializer.startTag(NAMESPACE, isAction ? TAG_ACTION : TAG_SLICE);
235        serializer.attribute(NAMESPACE, ATTR_URI, s.getUri().toString());
236        if (subType != null) {
237            serializer.attribute(NAMESPACE, ATTR_SUBTYPE, subType);
238        }
239        if (!s.getHints().isEmpty()) {
240            serializer.attribute(NAMESPACE, ATTR_HINTS, hintStr(s.getHints()));
241        }
242        for (SliceItem item : s.getItems()) {
243            serialize(item, context, options, serializer);
244        }
245
246        serializer.endTag(NAMESPACE, isAction ? TAG_ACTION : TAG_SLICE);
247    }
248
249    @SuppressWarnings("DefaultCharset")
250    private static void serialize(SliceItem item, Context context,
251            SliceUtils.SerializeOptions options, XmlSerializer serializer) throws IOException {
252        String format = item.getFormat();
253        options.checkThrow(format);
254
255        serializer.startTag(NAMESPACE, TAG_ITEM);
256        serializer.attribute(NAMESPACE, ATTR_FORMAT, format);
257        if (item.getSubType() != null) {
258            serializer.attribute(NAMESPACE, ATTR_SUBTYPE, item.getSubType());
259        }
260        if (!item.getHints().isEmpty()) {
261            serializer.attribute(NAMESPACE, ATTR_HINTS, hintStr(item.getHints()));
262        }
263
264        switch (format) {
265            case android.app.slice.SliceItem.FORMAT_ACTION:
266                if (options.getActionMode() == SliceUtils.SerializeOptions.MODE_CONVERT) {
267                    serialize(item.getSlice(), context, options, serializer, true,
268                            item.getSubType());
269                } else if (options.getActionMode() == SliceUtils.SerializeOptions.MODE_THROW) {
270                    throw new IllegalArgumentException("Slice contains an action " + item);
271                }
272                break;
273            case android.app.slice.SliceItem.FORMAT_REMOTE_INPUT:
274                // Nothing for now.
275                break;
276            case android.app.slice.SliceItem.FORMAT_IMAGE:
277                if (options.getImageMode() == SliceUtils.SerializeOptions.MODE_CONVERT) {
278                    IconCompat icon = item.getIcon();
279
280                    switch (icon.getType()) {
281                        case Icon.TYPE_RESOURCE:
282                            serializeResIcon(serializer, icon, context);
283                            break;
284                        case Icon.TYPE_URI:
285                            Uri uri = icon.getUri();
286                            if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
287                                serializeFileIcon(serializer, icon, context);
288                            } else {
289                                serializeIcon(serializer, icon, context, options);
290                            }
291                            break;
292                        default:
293                            serializeIcon(serializer, icon, context, options);
294                            break;
295                    }
296                } else if (options.getImageMode() == SliceUtils.SerializeOptions.MODE_THROW) {
297                    throw new IllegalArgumentException("Slice contains an image " + item);
298                }
299                break;
300            case android.app.slice.SliceItem.FORMAT_INT:
301                serializer.text(String.valueOf(item.getInt()));
302                break;
303            case android.app.slice.SliceItem.FORMAT_SLICE:
304                serialize(item.getSlice(), context, options, serializer, false, item.getSubType());
305                break;
306            case android.app.slice.SliceItem.FORMAT_TEXT:
307                if (item.getText() instanceof Spanned) {
308                    String text = Html.toHtml((Spanned) item.getText());
309                    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
310                        // 19-21 don't allow special characters in XML, so we base64 encode it.
311                        text = Base64.encodeToString(text.getBytes(), Base64.NO_WRAP);
312                    }
313                    serializer.text(text);
314                } else {
315                    String text = String.valueOf(item.getText());
316                    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) {
317                        // 19-21 don't allow special characters in XML, so we base64 encode it.
318                        text = Base64.encodeToString(text.getBytes(), Base64.NO_WRAP);
319                    }
320                    serializer.text(text);
321                }
322                break;
323            case android.app.slice.SliceItem.FORMAT_LONG:
324                serializer.text(String.valueOf(item.getLong()));
325                break;
326            default:
327                throw new IllegalArgumentException("Unrecognized format " + format);
328        }
329        serializer.endTag(NAMESPACE, TAG_ITEM);
330    }
331
332    private static void serializeResIcon(XmlSerializer serializer, IconCompat icon, Context context)
333            throws IOException {
334        try {
335            Resources res = context.getPackageManager().getResourcesForApplication(
336                    icon.getResPackage());
337            int id = icon.getResId();
338            serializer.attribute(NAMESPACE, ATTR_ICON_TYPE, ICON_TYPE_RES);
339            serializer.attribute(NAMESPACE, ATTR_ICON_PACKAGE, res.getResourcePackageName(id));
340            serializer.attribute(NAMESPACE, ATTR_ICON_RES_TYPE, res.getResourceTypeName(id));
341            serializer.text(res.getResourceEntryName(id));
342        } catch (PackageManager.NameNotFoundException e) {
343            throw new IllegalArgumentException("Slice contains invalid icon", e);
344        }
345    }
346
347    private static void serializeFileIcon(XmlSerializer serializer, IconCompat icon,
348            Context context) throws IOException {
349        serializer.attribute(NAMESPACE, ATTR_ICON_TYPE, ICON_TYPE_URI);
350        serializer.text(icon.getUri().toString());
351    }
352
353    @SuppressWarnings("DefaultCharset")
354    private static void serializeIcon(XmlSerializer serializer, IconCompat icon,
355            Context context, SliceUtils.SerializeOptions options) throws IOException {
356        Drawable d = icon.loadDrawable(context);
357        int width = d.getIntrinsicWidth();
358        int height = d.getIntrinsicHeight();
359        if (width > options.getMaxWidth()) {
360            height = (int) (options.getMaxWidth() * height / (double) width);
361            width = options.getMaxWidth();
362        }
363        if (height > options.getMaxHeight()) {
364            width = (int) (options.getMaxHeight() * width / (double) height);
365            height = options.getMaxHeight();
366        }
367        Bitmap b = Bitmap.createBitmap(width, height,
368                Bitmap.Config.ARGB_8888);
369        Canvas c = new Canvas(b);
370        d.setBounds(0, 0, c.getWidth(), c.getHeight());
371        d.draw(c);
372        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
373        b.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
374        b.recycle();
375
376        serializer.attribute(NAMESPACE, ATTR_ICON_TYPE, ICON_TYPE_DEFAULT);
377        serializer.text(new String(Base64.encode(outputStream.toByteArray(), Base64.NO_WRAP)));
378    }
379
380    private static String hintStr(List<String> hints) {
381        return TextUtils.join(",", hints);
382    }
383
384    private SliceXml() {
385    }
386}
387