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