blob: d36dea8295f139def58550b2a2421eb57f9dd50a [file] [log] [blame]
Jason Monk8f5f7ff2017-10-17 14:12:42 -04001/*
2 * Copyright (C) 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 android.app.slice;
18
Jason Monke2c64512017-12-11 15:14:54 -050019import android.annotation.NonNull;
Jason Monkb9e06a82018-01-16 15:32:53 -050020import android.annotation.Nullable;
Jason Monk5e676a22018-03-08 14:18:55 -050021import android.annotation.SdkConstant;
22import android.annotation.SdkConstant.SdkConstantType;
Jason Monk8f5f7ff2017-10-17 14:12:42 -040023import android.annotation.SystemService;
Jason Monk632def12018-02-01 15:21:16 -050024import android.content.ContentProviderClient;
Jason Monkb9e06a82018-01-16 15:32:53 -050025import android.content.ContentResolver;
Jason Monk8f5f7ff2017-10-17 14:12:42 -040026import android.content.Context;
Jason Monkb9e06a82018-01-16 15:32:53 -050027import android.content.Intent;
Jason Monkf2008872018-02-23 08:59:31 -050028import android.content.pm.PackageManager;
Jason Monkb9e06a82018-01-16 15:32:53 -050029import android.content.pm.ResolveInfo;
Jason Monk74f5e362017-12-06 08:56:33 -050030import android.net.Uri;
Jason Monk38df2802018-02-22 19:28:12 -050031import android.os.Binder;
Jason Monkb9e06a82018-01-16 15:32:53 -050032import android.os.Bundle;
Jason Monk8f5f7ff2017-10-17 14:12:42 -040033import android.os.Handler;
Jason Monk38df2802018-02-22 19:28:12 -050034import android.os.IBinder;
Jason Monkac112382018-03-23 15:06:35 -040035import android.os.Process;
Jason Monk74f5e362017-12-06 08:56:33 -050036import android.os.RemoteException;
Jason Monk8f5f7ff2017-10-17 14:12:42 -040037import android.os.ServiceManager;
38import android.os.ServiceManager.ServiceNotFoundException;
Jason Monkac112382018-03-23 15:06:35 -040039import android.os.UserHandle;
Jason Monk5f8cc272018-01-16 17:57:20 -050040import android.util.Log;
Jason Monke2c64512017-12-11 15:14:54 -050041
Jason Monkb9e06a82018-01-16 15:32:53 -050042import com.android.internal.util.Preconditions;
43
44import java.util.ArrayList;
Jason Monke2c64512017-12-11 15:14:54 -050045import java.util.Arrays;
Jason Monk5f8cc272018-01-16 17:57:20 -050046import java.util.Collection;
47import java.util.Collections;
Jason Monke2c64512017-12-11 15:14:54 -050048import java.util.List;
Jason Monk8f5f7ff2017-10-17 14:12:42 -040049
50/**
Jason Monke2c64512017-12-11 15:14:54 -050051 * Class to handle interactions with {@link Slice}s.
52 * <p>
53 * The SliceManager manages permissions and pinned state for slices.
Jason Monk8f5f7ff2017-10-17 14:12:42 -040054 */
55@SystemService(Context.SLICE_SERVICE)
56public class SliceManager {
57
Jason Monk5f8cc272018-01-16 17:57:20 -050058 private static final String TAG = "SliceManager";
59
Jason Monke8f8be72018-01-21 10:10:35 -050060 /**
61 * @hide
62 */
63 public static final String ACTION_REQUEST_SLICE_PERMISSION =
64 "android.intent.action.REQUEST_SLICE_PERMISSION";
65
Mady Mellor3267ed82018-02-21 11:42:31 -080066 /**
Jason Monk5e676a22018-03-08 14:18:55 -050067 * Category used to resolve intents that can be rendered as slices.
68 * <p>
69 * This category should be included on intent filters on providers that extend
70 * {@link SliceProvider}.
71 * @see SliceProvider
72 * @see SliceProvider#onMapIntentToUri(Intent)
73 * @see #mapIntentToUri(Intent)
74 */
75 @SdkConstant(SdkConstantType.INTENT_CATEGORY)
76 public static final String CATEGORY_SLICE = "android.app.slice.category.SLICE";
77
78 /**
Mady Mellor3267ed82018-02-21 11:42:31 -080079 * The meta-data key that allows an activity to easily be linked directly to a slice.
80 * <p>
81 * An activity can be statically linked to a slice uri by including a meta-data item
82 * for this key that contains a valid slice uri for the same application declaring
83 * the activity.
84 */
85 public static final String SLICE_METADATA_KEY = "android.metadata.SLICE_URI";
86
Jason Monk8f5f7ff2017-10-17 14:12:42 -040087 private final ISliceManager mService;
88 private final Context mContext;
Jason Monk38df2802018-02-22 19:28:12 -050089 private final IBinder mToken = new Binder();
Jason Monk8f5f7ff2017-10-17 14:12:42 -040090
Jason Monke2c64512017-12-11 15:14:54 -050091 /**
Jason Monke8f8be72018-01-21 10:10:35 -050092 * Permission denied.
93 * @hide
94 */
95 public static final int PERMISSION_DENIED = -1;
96 /**
97 * Permission granted.
98 * @hide
99 */
100 public static final int PERMISSION_GRANTED = 0;
101 /**
102 * Permission just granted by the user, and should be granted uri permission as well.
103 * @hide
104 */
105 public static final int PERMISSION_USER_GRANTED = 1;
106
107 /**
Jason Monke2c64512017-12-11 15:14:54 -0500108 * @hide
109 */
Jason Monk8f5f7ff2017-10-17 14:12:42 -0400110 public SliceManager(Context context, Handler handler) throws ServiceNotFoundException {
111 mContext = context;
112 mService = ISliceManager.Stub.asInterface(
113 ServiceManager.getServiceOrThrow(Context.SLICE_SERVICE));
114 }
Jason Monk74f5e362017-12-06 08:56:33 -0500115
116 /**
Jason Monke2c64512017-12-11 15:14:54 -0500117 * Ensures that a slice is in a pinned state.
118 * <p>
119 * Pinned state is not persisted across reboots, so apps are expected to re-pin any slices
120 * they still care about after a reboot.
Jason Monk632def12018-02-01 15:21:16 -0500121 * <p>
122 * This may only be called by apps that are the default launcher for the device
123 * or the default voice interaction service. Otherwise will throw {@link SecurityException}.
Jason Monke2c64512017-12-11 15:14:54 -0500124 *
125 * @param uri The uri of the slice being pinned.
126 * @param specs The list of supported {@link SliceSpec}s of the callback.
127 * @see SliceProvider#onSlicePinned(Uri)
Jason Monk632def12018-02-01 15:21:16 -0500128 * @see Intent#ACTION_ASSIST
129 * @see Intent#CATEGORY_HOME
Jason Monk74f5e362017-12-06 08:56:33 -0500130 */
Jason Monke2c64512017-12-11 15:14:54 -0500131 public void pinSlice(@NonNull Uri uri, @NonNull List<SliceSpec> specs) {
Jason Monk74f5e362017-12-06 08:56:33 -0500132 try {
Jason Monke2c64512017-12-11 15:14:54 -0500133 mService.pinSlice(mContext.getPackageName(), uri,
Jason Monk38df2802018-02-22 19:28:12 -0500134 specs.toArray(new SliceSpec[specs.size()]), mToken);
Jason Monk74f5e362017-12-06 08:56:33 -0500135 } catch (RemoteException e) {
136 throw e.rethrowFromSystemServer();
137 }
138 }
139
140 /**
Jason Monke2c64512017-12-11 15:14:54 -0500141 * Remove a pin for a slice.
142 * <p>
143 * If the slice has no other pins/callbacks then the slice will be unpinned.
Jason Monk632def12018-02-01 15:21:16 -0500144 * <p>
145 * This may only be called by apps that are the default launcher for the device
146 * or the default voice interaction service. Otherwise will throw {@link SecurityException}.
Jason Monke2c64512017-12-11 15:14:54 -0500147 *
148 * @param uri The uri of the slice being unpinned.
149 * @see #pinSlice
150 * @see SliceProvider#onSliceUnpinned(Uri)
Jason Monk632def12018-02-01 15:21:16 -0500151 * @see Intent#ACTION_ASSIST
152 * @see Intent#CATEGORY_HOME
Jason Monk74f5e362017-12-06 08:56:33 -0500153 */
Jason Monke2c64512017-12-11 15:14:54 -0500154 public void unpinSlice(@NonNull Uri uri) {
Jason Monk74f5e362017-12-06 08:56:33 -0500155 try {
Jason Monk38df2802018-02-22 19:28:12 -0500156 mService.unpinSlice(mContext.getPackageName(), uri, mToken);
Jason Monk74f5e362017-12-06 08:56:33 -0500157 } catch (RemoteException e) {
158 throw e.rethrowFromSystemServer();
159 }
160 }
161
162 /**
Jason Monke2c64512017-12-11 15:14:54 -0500163 * @hide
Jason Monk74f5e362017-12-06 08:56:33 -0500164 */
165 public boolean hasSliceAccess() {
166 try {
167 return mService.hasSliceAccess(mContext.getPackageName());
168 } catch (RemoteException e) {
169 throw e.rethrowFromSystemServer();
170 }
171 }
172
173 /**
Jason Monke2c64512017-12-11 15:14:54 -0500174 * Get the current set of specs for a pinned slice.
175 * <p>
176 * This is the set of specs supported for a specific pinned slice. It will take
177 * into account all clients and returns only specs supported by all.
178 * @see SliceSpec
Jason Monk74f5e362017-12-06 08:56:33 -0500179 */
Jason Monke2c64512017-12-11 15:14:54 -0500180 public @NonNull List<SliceSpec> getPinnedSpecs(Uri uri) {
Jason Monk74f5e362017-12-06 08:56:33 -0500181 try {
Jason Monke2c64512017-12-11 15:14:54 -0500182 return Arrays.asList(mService.getPinnedSpecs(uri, mContext.getPackageName()));
Jason Monk74f5e362017-12-06 08:56:33 -0500183 } catch (RemoteException e) {
184 throw e.rethrowFromSystemServer();
185 }
186 }
187
188 /**
Jason Monkf88d25e2018-03-06 20:13:24 -0500189 * Get the list of currently pinned slices for this app.
190 * @see SliceProvider#onSlicePinned
191 */
192 public @NonNull List<Uri> getPinnedSlices() {
193 try {
194 return Arrays.asList(mService.getPinnedSlices(mContext.getPackageName()));
195 } catch (RemoteException e) {
196 throw e.rethrowFromSystemServer();
197 }
198 }
199
200 /**
Jason Monk5f8cc272018-01-16 17:57:20 -0500201 * Obtains a list of slices that are descendants of the specified Uri.
202 * <p>
203 * Not all slice providers will implement this functionality, in which case,
204 * an empty collection will be returned.
205 *
206 * @param uri The uri to look for descendants under.
207 * @return All slices within the space.
208 * @see SliceProvider#onGetSliceDescendants(Uri)
209 */
210 public @NonNull Collection<Uri> getSliceDescendants(@NonNull Uri uri) {
211 ContentResolver resolver = mContext.getContentResolver();
Jason Monk632def12018-02-01 15:21:16 -0500212 try (ContentProviderClient provider = resolver.acquireContentProviderClient(uri)) {
Jason Monk5f8cc272018-01-16 17:57:20 -0500213 Bundle extras = new Bundle();
214 extras.putParcelable(SliceProvider.EXTRA_BIND_URI, uri);
Jason Monk632def12018-02-01 15:21:16 -0500215 final Bundle res = provider.call(SliceProvider.METHOD_GET_DESCENDANTS, null, extras);
Jason Monk5f8cc272018-01-16 17:57:20 -0500216 return res.getParcelableArrayList(SliceProvider.EXTRA_SLICE_DESCENDANTS);
217 } catch (RemoteException e) {
218 Log.e(TAG, "Unable to get slice descendants", e);
Jason Monk5f8cc272018-01-16 17:57:20 -0500219 }
220 return Collections.emptyList();
221 }
222
223 /**
Jason Monkb9e06a82018-01-16 15:32:53 -0500224 * Turns a slice Uri into slice content.
225 *
226 * @param uri The URI to a slice provider
227 * @param supportedSpecs List of supported specs.
228 * @return The Slice provided by the app or null if none is given.
229 * @see Slice
230 */
231 public @Nullable Slice bindSlice(@NonNull Uri uri, @NonNull List<SliceSpec> supportedSpecs) {
232 Preconditions.checkNotNull(uri, "uri");
233 ContentResolver resolver = mContext.getContentResolver();
Jason Monk632def12018-02-01 15:21:16 -0500234 try (ContentProviderClient provider = resolver.acquireContentProviderClient(uri)) {
235 if (provider == null) {
236 throw new IllegalArgumentException("Unknown URI " + uri);
237 }
Jason Monkb9e06a82018-01-16 15:32:53 -0500238 Bundle extras = new Bundle();
239 extras.putParcelable(SliceProvider.EXTRA_BIND_URI, uri);
240 extras.putParcelableArrayList(SliceProvider.EXTRA_SUPPORTED_SPECS,
241 new ArrayList<>(supportedSpecs));
Jason Monk632def12018-02-01 15:21:16 -0500242 final Bundle res = provider.call(SliceProvider.METHOD_SLICE, null, extras);
Jason Monkb9e06a82018-01-16 15:32:53 -0500243 Bundle.setDefusable(res, true);
244 if (res == null) {
245 return null;
246 }
247 return res.getParcelable(SliceProvider.EXTRA_SLICE);
248 } catch (RemoteException e) {
249 // Arbitrary and not worth documenting, as Activity
250 // Manager will kill this process shortly anyway.
251 return null;
Jason Monkb9e06a82018-01-16 15:32:53 -0500252 }
253 }
254
255 /**
Jason Monkf2008872018-02-23 08:59:31 -0500256 * Turns a slice intent into a slice uri. Expects an explicit intent.
Jason Monk5e676a22018-03-08 14:18:55 -0500257 * <p>
258 * This goes through a several stage resolution process to determine if any slice
259 * can represent this intent.
260 * - If the intent contains data that {@link ContentResolver#getType} is
261 * {@link SliceProvider#SLICE_TYPE} then the data will be returned.
262 * - If the intent with {@link #CATEGORY_SLICE} added resolves to a provider, then
263 * the provider will be asked to {@link SliceProvider#onMapIntentToUri} and that result
264 * will be returned.
265 * - Lastly, if the intent explicitly points at an activity, and that activity has
266 * meta-data for key {@link #SLICE_METADATA_KEY}, then the Uri specified there will be
267 * returned.
268 * - If no slice is found, then {@code null} is returned.
Jason Monk7b8fef22018-01-30 16:04:14 -0500269 *
270 * @param intent The intent associated with a slice.
Jason Monkf2008872018-02-23 08:59:31 -0500271 * @return The Slice Uri provided by the app or null if none exists.
Jason Monk7b8fef22018-01-30 16:04:14 -0500272 * @see Slice
273 * @see SliceProvider#onMapIntentToUri(Intent)
274 * @see Intent
275 */
276 public @Nullable Uri mapIntentToUri(@NonNull Intent intent) {
277 Preconditions.checkNotNull(intent, "intent");
Jason Monk135f4172018-03-15 17:48:47 -0400278 Preconditions.checkArgument(intent.getComponent() != null || intent.getPackage() != null
279 || intent.getData() != null,
Jason Monk7b8fef22018-01-30 16:04:14 -0500280 "Slice intent must be explicit %s", intent);
281 ContentResolver resolver = mContext.getContentResolver();
282
283 // Check if the intent has data for the slice uri on it and use that
284 final Uri intentData = intent.getData();
285 if (intentData != null && SliceProvider.SLICE_TYPE.equals(resolver.getType(intentData))) {
286 return intentData;
287 }
288 // Otherwise ask the app
Jason Monk5e676a22018-03-08 14:18:55 -0500289 Intent queryIntent = new Intent(intent);
290 if (!queryIntent.hasCategory(CATEGORY_SLICE)) {
291 queryIntent.addCategory(CATEGORY_SLICE);
292 }
Jason Monk7b8fef22018-01-30 16:04:14 -0500293 List<ResolveInfo> providers =
Jason Monk5e676a22018-03-08 14:18:55 -0500294 mContext.getPackageManager().queryIntentContentProviders(queryIntent, 0);
Jason Monk7b8fef22018-01-30 16:04:14 -0500295 if (providers == null || providers.isEmpty()) {
Jason Monkf2008872018-02-23 08:59:31 -0500296 // There are no providers, see if this activity has a direct link.
297 ResolveInfo resolve = mContext.getPackageManager().resolveActivity(intent,
298 PackageManager.GET_META_DATA);
299 if (resolve != null && resolve.activityInfo != null
300 && resolve.activityInfo.metaData != null
301 && resolve.activityInfo.metaData.containsKey(SLICE_METADATA_KEY)) {
302 return Uri.parse(
303 resolve.activityInfo.metaData.getString(SLICE_METADATA_KEY));
304 }
305 return null;
Jason Monk7b8fef22018-01-30 16:04:14 -0500306 }
307 String authority = providers.get(0).providerInfo.authority;
308 Uri uri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
309 .authority(authority).build();
Jason Monk632def12018-02-01 15:21:16 -0500310 try (ContentProviderClient provider = resolver.acquireContentProviderClient(uri)) {
311 if (provider == null) {
312 throw new IllegalArgumentException("Unknown URI " + uri);
313 }
Jason Monk7b8fef22018-01-30 16:04:14 -0500314 Bundle extras = new Bundle();
315 extras.putParcelable(SliceProvider.EXTRA_INTENT, intent);
Jason Monk632def12018-02-01 15:21:16 -0500316 final Bundle res = provider.call(SliceProvider.METHOD_MAP_ONLY_INTENT, null, extras);
Jason Monk7b8fef22018-01-30 16:04:14 -0500317 if (res == null) {
318 return null;
319 }
320 return res.getParcelable(SliceProvider.EXTRA_SLICE);
321 } catch (RemoteException e) {
322 // Arbitrary and not worth documenting, as Activity
323 // Manager will kill this process shortly anyway.
324 return null;
Jason Monk7b8fef22018-01-30 16:04:14 -0500325 }
326 }
327
328 /**
Jason Monkb9e06a82018-01-16 15:32:53 -0500329 * Turns a slice intent into slice content. Expects an explicit intent. If there is no
330 * {@link android.content.ContentProvider} associated with the given intent this will throw
331 * {@link IllegalArgumentException}.
332 *
333 * @param intent The intent associated with a slice.
334 * @param supportedSpecs List of supported specs.
335 * @return The Slice provided by the app or null if none is given.
336 * @see Slice
337 * @see SliceProvider#onMapIntentToUri(Intent)
338 * @see Intent
339 */
340 public @Nullable Slice bindSlice(@NonNull Intent intent,
341 @NonNull List<SliceSpec> supportedSpecs) {
342 Preconditions.checkNotNull(intent, "intent");
Jason Monk135f4172018-03-15 17:48:47 -0400343 Preconditions.checkArgument(intent.getComponent() != null || intent.getPackage() != null
344 || intent.getData() != null,
Jason Monk7b8fef22018-01-30 16:04:14 -0500345 "Slice intent must be explicit %s", intent);
Jason Monkb9e06a82018-01-16 15:32:53 -0500346 ContentResolver resolver = mContext.getContentResolver();
347
348 // Check if the intent has data for the slice uri on it and use that
349 final Uri intentData = intent.getData();
350 if (intentData != null && SliceProvider.SLICE_TYPE.equals(resolver.getType(intentData))) {
351 return bindSlice(intentData, supportedSpecs);
352 }
353 // Otherwise ask the app
354 List<ResolveInfo> providers =
355 mContext.getPackageManager().queryIntentContentProviders(intent, 0);
Jason Monk7b8fef22018-01-30 16:04:14 -0500356 if (providers == null || providers.isEmpty()) {
Jason Monkf2008872018-02-23 08:59:31 -0500357 // There are no providers, see if this activity has a direct link.
358 ResolveInfo resolve = mContext.getPackageManager().resolveActivity(intent,
359 PackageManager.GET_META_DATA);
360 if (resolve != null && resolve.activityInfo != null
361 && resolve.activityInfo.metaData != null
362 && resolve.activityInfo.metaData.containsKey(SLICE_METADATA_KEY)) {
363 return bindSlice(Uri.parse(resolve.activityInfo.metaData
364 .getString(SLICE_METADATA_KEY)), supportedSpecs);
365 }
366 return null;
Jason Monkb9e06a82018-01-16 15:32:53 -0500367 }
368 String authority = providers.get(0).providerInfo.authority;
369 Uri uri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
370 .authority(authority).build();
Jason Monk632def12018-02-01 15:21:16 -0500371 try (ContentProviderClient provider = resolver.acquireContentProviderClient(uri)) {
372 if (provider == null) {
373 throw new IllegalArgumentException("Unknown URI " + uri);
374 }
Jason Monkb9e06a82018-01-16 15:32:53 -0500375 Bundle extras = new Bundle();
376 extras.putParcelable(SliceProvider.EXTRA_INTENT, intent);
377 extras.putParcelableArrayList(SliceProvider.EXTRA_SUPPORTED_SPECS,
378 new ArrayList<>(supportedSpecs));
Jason Monk632def12018-02-01 15:21:16 -0500379 final Bundle res = provider.call(SliceProvider.METHOD_MAP_INTENT, null, extras);
Jason Monkb9e06a82018-01-16 15:32:53 -0500380 if (res == null) {
381 return null;
382 }
383 return res.getParcelable(SliceProvider.EXTRA_SLICE);
384 } catch (RemoteException e) {
385 // Arbitrary and not worth documenting, as Activity
386 // Manager will kill this process shortly anyway.
387 return null;
Jason Monkb9e06a82018-01-16 15:32:53 -0500388 }
389 }
390
391 /**
Jason Monke8f8be72018-01-21 10:10:35 -0500392 * Does the permission check to see if a caller has access to a specific slice.
393 * @hide
394 */
Jason Monk42e03f82018-03-30 11:26:56 -0400395 public void enforceSlicePermission(Uri uri, String pkg, int pid, int uid,
396 String[] autoGrantPermissions) {
Jason Monke8f8be72018-01-21 10:10:35 -0500397 try {
Jason Monkac112382018-03-23 15:06:35 -0400398 if (UserHandle.isSameApp(uid, Process.myUid())) {
399 return;
400 }
Jason Monke8f8be72018-01-21 10:10:35 -0500401 if (pkg == null) {
402 throw new SecurityException("No pkg specified");
403 }
Jason Monk42e03f82018-03-30 11:26:56 -0400404 int result = mService.checkSlicePermission(uri, pkg, pid, uid, autoGrantPermissions);
Jason Monke8f8be72018-01-21 10:10:35 -0500405 if (result == PERMISSION_DENIED) {
406 throw new SecurityException("User " + uid + " does not have slice permission for "
407 + uri + ".");
408 }
409 if (result == PERMISSION_USER_GRANTED) {
410 // We just had a user grant of this permission and need to grant this to the app
411 // permanently.
412 mContext.grantUriPermission(pkg, uri.buildUpon().path("").build(),
413 Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
414 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
415 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
Jason Monk42e03f82018-03-30 11:26:56 -0400416 // Notify a change has happened because we just granted a permission.
417 mContext.getContentResolver().notifyChange(uri, null);
Jason Monke8f8be72018-01-21 10:10:35 -0500418 }
419 } catch (RemoteException e) {
420 throw e.rethrowFromSystemServer();
421 }
422 }
423
424 /**
425 * Called by SystemUI to grant a slice permission after a dialog is shown.
426 * @hide
427 */
428 public void grantPermissionFromUser(Uri uri, String pkg, boolean allSlices) {
429 try {
430 mService.grantPermissionFromUser(uri, pkg, mContext.getPackageName(), allSlices);
431 } catch (RemoteException e) {
432 throw e.rethrowFromSystemServer();
433 }
434 }
Jason Monk8f5f7ff2017-10-17 14:12:42 -0400435}