1 /*
2  * Copyright 2021 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.appsearch.app;
18 
19 import android.content.Context;
20 import android.content.Intent;
21 import android.net.Uri;
22 import android.os.Build;
23 import android.os.Bundle;
24 import android.text.TextUtils;
25 
26 import androidx.annotation.DoNotInline;
27 import androidx.annotation.RequiresApi;
28 import androidx.annotation.RestrictTo;
29 import androidx.appsearch.exceptions.AppSearchException;
30 import androidx.appsearch.safeparcel.GenericDocumentParcel;
31 import androidx.core.content.pm.ShortcutInfoCompat;
32 import androidx.core.util.Preconditions;
33 
34 import org.jspecify.annotations.NonNull;
35 import org.jspecify.annotations.Nullable;
36 
37 /**
38  * Util methods for Document <-> shortcut conversion.
39  */
40 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
41 @ExperimentalAppSearchApi
42 public class ShortcutAdapter {
43 
ShortcutAdapter()44     private ShortcutAdapter() {
45         // Hide constructor as utility classes are not meant to be instantiated.
46     }
47 
48     /** @exportToFramework:hide */
49     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
50     public static final String DEFAULT_DATABASE = "__shortcut_adapter_db__";
51 
52     /**
53      * Represents the default namespace which should be used as the
54      * {@link androidx.appsearch.annotation.Document.Namespace} for documents that
55      * are meant to be donated as a shortcut through
56      * {@link androidx.core.content.pm.ShortcutManagerCompat}.
57      */
58     public static final String DEFAULT_NAMESPACE = "__shortcut_adapter_ns__";
59 
60     private static final String FIELD_NAME = "name";
61 
62     private static final String SCHEME_APPSEARCH = "appsearch";
63     private static final String NAMESPACE_CHECK_ERROR_MESSAGE = "Namespace of the document does "
64             + "not match androidx.appsearch.app.ShortcutAdapter.DEFAULT_NAMESPACE."
65             + "Please use androidx.appsearch.app.ShortcutAdapter.DEFAULT_NAMESPACE as the "
66             + "namespace of the document if it will be used to create a shortcut.";
67 
68     private static final String APPSEARCH_GENERIC_DOC_PARCEL_NAME_IN_BUNDLE =
69             "appsearch_generic_doc_parcel";
70 
71     /**
72      * Converts given document to a {@link ShortcutInfoCompat.Builder}, which can be used to
73      * construct a shortcut for donation through
74      * {@link androidx.core.content.pm.ShortcutManagerCompat}. Applicable data in the given
75      * document will be used to populate corresponding fields in {@link ShortcutInfoCompat.Builder}.
76      *
77      * <p>Note: Namespace of the given document is required to be set to {@link #DEFAULT_NAMESPACE}
78      * if it will be used to create a shortcut; Otherwise an exception would be thrown.
79      *
80      * <p>See {@link androidx.appsearch.annotation.Document.Namespace}
81      *
82      * <p>Note: The ShortcutID in {@link ShortcutInfoCompat.Builder} will be set to match the id
83      * of given document. So an unique id across all documents should be chosen if the document
84      * is to be used to create a shortcut.
85      *
86      * <p>see {@link ShortcutInfoCompat#getId()}
87      * <p>see {@link androidx.appsearch.annotation.Document.Id}
88      *
89      * <p>{@link ShortcutInfoCompat.Builder} created this way by default will be set to hidden
90      * from launcher. If remain hidden, they will not appear in launcher's surfaces (e.g. long
91      * press menu) nor do they count toward the quota defined in
92      * {@link androidx.core.content.pm.ShortcutManagerCompat#getMaxShortcutCountPerActivity(Context)}
93      *
94      * <p>See {@link ShortcutInfoCompat.Builder#setExcludedFromSurfaces(int)}.
95      *
96      * <p>Given document object will be stored in the form of {@link Bundle} in
97      * {@link ShortcutInfoCompat}.
98      *
99      * <p>The document that was stored in {@link ShortcutInfoCompat} is discarded when the
100      * shortcut is converted into {@link android.content.pm.ShortcutInfo}, meaning that the
101      * document will not be persisted in the shortcut object itself once the shortcut is
102      * published. i.e. Any shortcut returned from queries toward
103      * {@link androidx.core.content.pm.ShortcutManagerCompat} would not carry any document at all.
104      *
105      * @param context the context used to provide the package and resources
106      * @param document a document object annotated with
107      *                 {@link androidx.appsearch.annotation.Document} that carries structured
108      *                 data in a pre-defined format.
109      * @return a {@link ShortcutInfoCompat.Builder} which can be used to construct a shortcut
110      *         for donation through {@link androidx.core.content.pm.ShortcutManagerCompat}.
111      * @throws IllegalArgumentException An exception would be thrown if the namespace in the given
112      *                                  document object does not match {@link #DEFAULT_NAMESPACE}.
113      * @throws AppSearchException An exception would be thrown if the given document object is not
114      *                            annotated with {@link androidx.appsearch.annotation.Document} or
115      *                            encountered an unexpected error during the conversion to
116      *                            {@link GenericDocument}.
117      */
createShortcutBuilderFromDocument( final @NonNull Context context, @NonNull Object document)118     public static ShortcutInfoCompat.@NonNull Builder createShortcutBuilderFromDocument(
119             final @NonNull Context context, @NonNull Object document) throws AppSearchException {
120         Preconditions.checkNotNull(context);
121         Preconditions.checkNotNull(document);
122         final GenericDocument doc = GenericDocument.fromDocumentClass(document);
123         if (!DEFAULT_NAMESPACE.equals(doc.getNamespace())) {
124             throw new IllegalArgumentException(NAMESPACE_CHECK_ERROR_MESSAGE);
125         }
126         final String name = doc.getPropertyString(FIELD_NAME);
127         final Bundle extras = new Bundle();
128         extras.putParcelable(APPSEARCH_GENERIC_DOC_PARCEL_NAME_IN_BUNDLE, doc.getDocumentParcel());
129         return new ShortcutInfoCompat.Builder(context, doc.getId())
130                 .setShortLabel(!TextUtils.isEmpty(name) ? name : doc.getId())
131                 .setIntent(new Intent(Intent.ACTION_VIEW, getDocumentUri(doc)))
132                 .setExcludedFromSurfaces(ShortcutInfoCompat.SURFACE_LAUNCHER)
133                 .setTransientExtras(extras);
134     }
135 
136     /**
137      * Extracts {@link GenericDocument} from given {@link ShortcutInfoCompat} if applicable.
138      * Returns null if document cannot be found in the given shortcut.
139      *
140      * @exportToFramework:hide
141      */
142     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
extractDocument( final @NonNull ShortcutInfoCompat shortcut)143     public static @Nullable GenericDocument extractDocument(
144             final @NonNull ShortcutInfoCompat shortcut) {
145         Preconditions.checkNotNull(shortcut);
146         final Bundle extras = shortcut.getTransientExtras();
147         if (extras == null) {
148             return null;
149         }
150 
151         GenericDocumentParcel genericDocParcel;
152         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
153             genericDocParcel = Api33Impl.getParcelableFromBundle(extras,
154                     APPSEARCH_GENERIC_DOC_PARCEL_NAME_IN_BUNDLE, GenericDocumentParcel.class);
155         } else {
156             @SuppressWarnings("deprecation")
157             GenericDocumentParcel tmp = (GenericDocumentParcel) extras.getParcelable(
158                     APPSEARCH_GENERIC_DOC_PARCEL_NAME_IN_BUNDLE);
159             genericDocParcel = tmp;
160         }
161         if (genericDocParcel == null) {
162             return null;
163         }
164         return new GenericDocument(genericDocParcel);
165     }
166 
167     /**
168      * Returns an uri that uniquely identifies the given document object.
169      *
170      * @param document a document object annotated with
171      *                 {@link androidx.appsearch.annotation.Document} that carries structured
172      *                 data in a pre-defined format.
173      * @throws AppSearchException if the given document object is not annotated with
174      *                            {@link androidx.appsearch.annotation.Document} or encountered an
175      *                            unexpected error during the conversion to {@link GenericDocument}.
176      */
getDocumentUri(final @NonNull Object document)177     public static @NonNull Uri getDocumentUri(final @NonNull Object document)
178             throws AppSearchException {
179         Preconditions.checkNotNull(document);
180         return getDocumentUri(GenericDocument.fromDocumentClass(document));
181     }
182 
getDocumentUri(final @NonNull GenericDocument obj)183     private static @NonNull Uri getDocumentUri(final @NonNull GenericDocument obj) {
184         Preconditions.checkNotNull(obj);
185         return getDocumentUri(obj.getId());
186     }
187 
188     /**
189      * Returns an uri that identifies to the document associated with given document id.
190      *
191      * @param id id of the document.
192      */
getDocumentUri(final @NonNull String id)193     public static @NonNull Uri getDocumentUri(final @NonNull String id) {
194         Preconditions.checkNotNull(id);
195         return new Uri.Builder()
196                 .scheme(SCHEME_APPSEARCH)
197                 .authority(DEFAULT_DATABASE)
198                 .path(DEFAULT_NAMESPACE + "/" + id)
199                 .build();
200     }
201     @RequiresApi(33)
202     static class Api33Impl {
Api33Impl()203         private Api33Impl() {
204             // This class is not instantiable.
205         }
206 
207         @DoNotInline
getParcelableFromBundle( @onNull Bundle bundle, @NonNull String key, @NonNull Class<T> clazz)208         static <T> T getParcelableFromBundle(
209                 @NonNull Bundle bundle,
210                 @NonNull String key,
211                 @NonNull Class<T> clazz) {
212             Preconditions.checkNotNull(bundle);
213             Preconditions.checkNotNull(key);
214             Preconditions.checkNotNull(clazz);
215             return bundle.getParcelable(key, clazz);
216         }
217     }
218 }
219