diff --git a/Android.bp b/Android.bp index 948b0a0682153..6a59abcbbf239 100644 --- a/Android.bp +++ b/Android.bp @@ -99,6 +99,7 @@ java_library { "core/java/android/app/backup/IRestoreSession.aidl", "core/java/android/app/backup/ISelectBackupTransportCallback.aidl", "core/java/android/app/slice/ISliceManager.aidl", + "core/java/android/app/slice/ISliceListener.aidl", "core/java/android/app/timezone/ICallback.aidl", "core/java/android/app/timezone/IRulesManager.aidl", "core/java/android/app/usage/ICacheQuotaService.aidl", diff --git a/Android.mk b/Android.mk index ec5fb841131ea..800b992e969ca 100644 --- a/Android.mk +++ b/Android.mk @@ -97,6 +97,7 @@ aidl_files := \ frameworks/base/core/java/android/app/admin/SystemUpdatePolicy.aidl \ frameworks/base/core/java/android/app/admin/PasswordMetrics.aidl \ frameworks/base/core/java/android/app/slice/ISliceManager.aidl \ + frameworks/base/core/java/android/app/slice/ISliceListener.aidl \ frameworks/base/core/java/android/print/PrintDocumentInfo.aidl \ frameworks/base/core/java/android/print/PageRange.aidl \ frameworks/base/core/java/android/print/PrintAttributes.aidl \ diff --git a/core/java/android/app/slice/ISliceListener.aidl b/core/java/android/app/slice/ISliceListener.aidl new file mode 100644 index 0000000000000..d293fd46d298c --- /dev/null +++ b/core/java/android/app/slice/ISliceListener.aidl @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2017, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.app.slice; + +import android.app.slice.ISliceManager; +import android.app.slice.Slice; + +/** @hide */ +oneway interface ISliceListener { + void onSliceUpdated(in Slice s); +} diff --git a/core/java/android/app/slice/ISliceManager.aidl b/core/java/android/app/slice/ISliceManager.aidl index 6e52f385bcf76..5f0e542f4b123 100644 --- a/core/java/android/app/slice/ISliceManager.aidl +++ b/core/java/android/app/slice/ISliceManager.aidl @@ -16,6 +16,17 @@ package android.app.slice; +import android.app.slice.ISliceListener; +import android.app.slice.SliceSpec; +import android.net.Uri; + /** @hide */ interface ISliceManager { + void addSliceListener(in Uri uri, String pkg, in ISliceListener listener, + in SliceSpec[] specs); + void removeSliceListener(in Uri uri, String pkg, in ISliceListener listener); + void pinSlice(String pkg, in Uri uri, in SliceSpec[] specs); + void unpinSlice(String pkg, in Uri uri); + boolean hasSliceAccess(String pkg); + SliceSpec[] getPinnedSpecs(in Uri uri, String pkg); } diff --git a/core/java/android/app/slice/Slice.aidl b/core/java/android/app/slice/Slice.aidl new file mode 100644 index 0000000000000..e097f9d09a9a0 --- /dev/null +++ b/core/java/android/app/slice/Slice.aidl @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2017, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.app.slice; + +parcelable Slice; diff --git a/core/java/android/app/slice/SliceManager.java b/core/java/android/app/slice/SliceManager.java index e99f67632712c..f8e19c12b8105 100644 --- a/core/java/android/app/slice/SliceManager.java +++ b/core/java/android/app/slice/SliceManager.java @@ -17,8 +17,11 @@ package android.app.slice; import android.annotation.SystemService; +import android.app.slice.ISliceListener.Stub; import android.content.Context; +import android.net.Uri; import android.os.Handler; +import android.os.RemoteException; import android.os.ServiceManager; import android.os.ServiceManager.ServiceNotFoundException; @@ -36,4 +39,93 @@ public class SliceManager { mService = ISliceManager.Stub.asInterface( ServiceManager.getServiceOrThrow(Context.SLICE_SERVICE)); } + + /** + */ + public void addSliceListener(Uri uri, SliceListener listener, SliceSpec[] specs) { + try { + mService.addSliceListener(uri, mContext.getPackageName(), listener.mStub, specs); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public void removeSliceListener(Uri uri, SliceListener listener) { + try { + mService.removeSliceListener(uri, mContext.getPackageName(), listener.mStub); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public void pinSlice(Uri uri, SliceSpec[] specs) { + try { + mService.pinSlice(mContext.getPackageName(), uri, specs); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public void unpinSlice(Uri uri) { + try { + mService.unpinSlice(mContext.getPackageName(), uri); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public boolean hasSliceAccess() { + try { + return mService.hasSliceAccess(mContext.getPackageName()); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public SliceSpec[] getPinnedSpecs(Uri uri) { + try { + return mService.getPinnedSpecs(uri, mContext.getPackageName()); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + + /** + */ + public abstract static class SliceListener { + private final Handler mHandler; + + /** + */ + public SliceListener() { + this(Handler.getMain()); + } + + /** + */ + public SliceListener(Handler h) { + mHandler = h; + } + + /** + */ + public abstract void onSliceUpdated(Slice s); + + private final ISliceListener.Stub mStub = new Stub() { + @Override + public void onSliceUpdated(Slice s) throws RemoteException { + mHandler.post(() -> SliceListener.this.onSliceUpdated(s)); + } + }; + } } diff --git a/core/java/android/app/slice/SliceProvider.java b/core/java/android/app/slice/SliceProvider.java index ac5365c35f497..7dcd2fead73f9 100644 --- a/core/java/android/app/slice/SliceProvider.java +++ b/core/java/android/app/slice/SliceProvider.java @@ -102,6 +102,14 @@ public abstract class SliceProvider extends ContentProvider { * @hide */ public static final String METHOD_MAP_INTENT = "map_slice"; + /** + * @hide + */ + public static final String METHOD_PIN = "pin"; + /** + * @hide + */ + public static final String METHOD_UNPIN = "unpin"; /** * @hide */ @@ -142,6 +150,18 @@ public abstract class SliceProvider extends ContentProvider { return null; } + /** + * @hide + */ + public void onSlicePinned(Uri sliceUri) { + } + + /** + * @hide + */ + public void onSliceUnpinned(Uri sliceUri) { + } + /** * This method must be overridden if an {@link IntentFilter} is specified on the SliceProvider. * In that case, this method can be called and is expected to return a non-null Uri representing @@ -221,6 +241,7 @@ public abstract class SliceProvider extends ContentProvider { getContext().enforceCallingPermission(permission.BIND_SLICE, "Slice binding requires the permission BIND_SLICE"); Intent intent = extras.getParcelable(EXTRA_INTENT); + if (intent == null) return null; Uri uri = onMapIntentToUri(intent); List supportedSpecs = extras.getParcelableArrayList(EXTRA_SUPPORTED_SPECS); Bundle b = new Bundle(); @@ -231,10 +252,62 @@ public abstract class SliceProvider extends ContentProvider { b.putParcelable(EXTRA_SLICE, null); } return b; + } else if (method.equals(METHOD_PIN)) { + Uri uri = extras.getParcelable(EXTRA_BIND_URI); + if (!UserHandle.isSameApp(Binder.getCallingUid(), Process.myUid())) { + getContext().enforceUriPermission(uri, permission.BIND_SLICE, + permission.BIND_SLICE, Binder.getCallingPid(), Binder.getCallingUid(), + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + "Slice binding requires the permission BIND_SLICE"); + } + handlePinSlice(uri); + } else if (method.equals(METHOD_UNPIN)) { + Uri uri = extras.getParcelable(EXTRA_BIND_URI); + if (!UserHandle.isSameApp(Binder.getCallingUid(), Process.myUid())) { + getContext().enforceUriPermission(uri, permission.BIND_SLICE, + permission.BIND_SLICE, Binder.getCallingPid(), Binder.getCallingUid(), + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + "Slice binding requires the permission BIND_SLICE"); + } + handleUnpinSlice(uri); } return super.call(method, arg, extras); } + private void handlePinSlice(Uri sliceUri) { + if (Looper.myLooper() == Looper.getMainLooper()) { + onSlicePinned(sliceUri); + } else { + CountDownLatch latch = new CountDownLatch(1); + Handler.getMain().post(() -> { + onSlicePinned(sliceUri); + latch.countDown(); + }); + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + private void handleUnpinSlice(Uri sliceUri) { + if (Looper.myLooper() == Looper.getMainLooper()) { + onSliceUnpinned(sliceUri); + } else { + CountDownLatch latch = new CountDownLatch(1); + Handler.getMain().post(() -> { + onSliceUnpinned(sliceUri); + latch.countDown(); + }); + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + private Slice handleBindSlice(Uri sliceUri, List supportedSpecs) { if (Looper.myLooper() == Looper.getMainLooper()) { return onBindSliceStrict(sliceUri, supportedSpecs); diff --git a/core/java/android/app/slice/SliceSpec.aidl b/core/java/android/app/slice/SliceSpec.aidl new file mode 100644 index 0000000000000..92e98b78a7e8e --- /dev/null +++ b/core/java/android/app/slice/SliceSpec.aidl @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2017, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.app.slice; + +parcelable SliceSpec; diff --git a/core/java/android/app/slice/SliceSpec.java b/core/java/android/app/slice/SliceSpec.java index 433b67e9aacbe..8cc0384c10072 100644 --- a/core/java/android/app/slice/SliceSpec.java +++ b/core/java/android/app/slice/SliceSpec.java @@ -103,6 +103,11 @@ public final class SliceSpec implements Parcelable { return mType.equals(other.mType) && mRevision == other.mRevision; } + @Override + public String toString() { + return String.format("SliceSpec{%s,%d}", mType, mRevision); + } + public static final Creator CREATOR = new Creator() { @Override public SliceSpec createFromParcel(Parcel source) { diff --git a/services/core/java/com/android/server/slice/PinnedSliceState.java b/services/core/java/com/android/server/slice/PinnedSliceState.java new file mode 100644 index 0000000000000..cf930f5cc6b79 --- /dev/null +++ b/services/core/java/com/android/server/slice/PinnedSliceState.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.android.server.slice; + +import android.app.slice.ISliceListener; +import android.app.slice.Slice; +import android.app.slice.SliceProvider; +import android.app.slice.SliceSpec; +import android.content.ContentProviderClient; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.ArraySet; +import android.util.Log; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Manages the state of a pinned slice. + */ +public class PinnedSliceState { + + private static final long SLICE_TIMEOUT = 5000; + private static final String TAG = "PinnedSliceState"; + + private final Object mLock; + + private final SliceManagerService mService; + private final Uri mUri; + @GuardedBy("mLock") + private final ArraySet mPinnedPkgs = new ArraySet<>(); + @GuardedBy("mLock") + private final ArraySet mListeners = new ArraySet<>(); + @GuardedBy("mLock") + private SliceSpec[] mSupportedSpecs = null; + + public PinnedSliceState(SliceManagerService service, Uri uri) { + mService = service; + mUri = uri; + mService.getHandler().post(this::handleSendPinned); + mLock = mService.getLock(); + } + + public SliceSpec[] getSpecs() { + return mSupportedSpecs; + } + + public void mergeSpecs(SliceSpec[] supportedSpecs) { + synchronized (mLock) { + if (mSupportedSpecs == null) { + mSupportedSpecs = supportedSpecs; + } else { + List specs = Arrays.asList(mSupportedSpecs); + mSupportedSpecs = specs.stream().map(s -> { + SliceSpec other = findSpec(supportedSpecs, s.getType()); + if (other == null) return null; + if (other.getRevision() < s.getRevision()) { + return other; + } + return s; + }).filter(s -> s != null).toArray(SliceSpec[]::new); + } + } + } + + private SliceSpec findSpec(SliceSpec[] specs, String type) { + for (SliceSpec spec : specs) { + if (Objects.equals(spec.getType(), type)) { + return spec; + } + } + return null; + } + + public Uri getUri() { + return mUri; + } + + public void destroy() { + mService.getHandler().post(this::handleSendUnpinned); + } + + public void onChange() { + mService.getHandler().post(this::handleBind); + } + + public void addSliceListener(ISliceListener listener, SliceSpec[] specs) { + synchronized (mLock) { + if (mListeners.add(listener) && mListeners.size() == 1) { + mService.listen(mUri); + } + mergeSpecs(specs); + } + } + + public boolean removeSliceListener(ISliceListener listener) { + synchronized (mLock) { + if (mListeners.remove(listener) && mListeners.size() == 0) { + mService.unlisten(mUri); + } + } + return !isPinned(); + } + + public void pin(String pkg, SliceSpec[] specs) { + synchronized (mLock) { + mPinnedPkgs.add(pkg); + mergeSpecs(specs); + } + } + + public boolean unpin(String pkg) { + synchronized (mLock) { + mPinnedPkgs.remove(pkg); + } + return !isPinned(); + } + + public boolean isListening() { + synchronized (mLock) { + return !mListeners.isEmpty(); + } + } + + @VisibleForTesting + public boolean isPinned() { + synchronized (mLock) { + return !mPinnedPkgs.isEmpty() || !mListeners.isEmpty(); + } + } + + ContentProviderClient getClient() { + ContentProviderClient client = + mService.getContext().getContentResolver().acquireContentProviderClient(mUri); + client.setDetectNotResponding(SLICE_TIMEOUT); + return client; + } + + private void handleBind() { + Slice s; + try (ContentProviderClient client = getClient()) { + Bundle extras = new Bundle(); + extras.putParcelable(SliceProvider.EXTRA_BIND_URI, mUri); + extras.putParcelableArrayList(SliceProvider.EXTRA_SUPPORTED_SPECS, + new ArrayList<>(Arrays.asList(mSupportedSpecs))); + final Bundle res; + try { + res = client.call(SliceProvider.METHOD_SLICE, null, extras); + } catch (RemoteException e) { + Log.e(TAG, "Unable to bind slice " + mUri, e); + return; + } + if (res == null) return; + Bundle.setDefusable(res, true); + s = res.getParcelable(SliceProvider.EXTRA_SLICE); + } + synchronized (mLock) { + mListeners.removeIf(l -> { + try { + l.onSliceUpdated(s); + return false; + } catch (RemoteException e) { + Log.e(TAG, "Unable to notify slice " + mUri, e); + return true; + } + }); + if (!isPinned()) { + // All the listeners died, remove from pinned state. + mService.removePinnedSlice(mUri); + } + } + } + + private void handleSendPinned() { + try (ContentProviderClient client = getClient()) { + Bundle b = new Bundle(); + b.putParcelable(SliceProvider.EXTRA_BIND_URI, mUri); + try { + client.call(SliceProvider.METHOD_PIN, null, b); + } catch (RemoteException e) { + Log.w(TAG, "Unable to contact " + mUri, e); + } + } + } + + private void handleSendUnpinned() { + try (ContentProviderClient client = getClient()) { + Bundle b = new Bundle(); + b.putParcelable(SliceProvider.EXTRA_BIND_URI, mUri); + try { + client.call(SliceProvider.METHOD_UNPIN, null, b); + } catch (RemoteException e) { + Log.w(TAG, "Unable to contact " + mUri, e); + } + } + } +} diff --git a/services/core/java/com/android/server/slice/SliceManagerService.java b/services/core/java/com/android/server/slice/SliceManagerService.java index 047e270f3fac8..2d9e772a6b0c6 100644 --- a/services/core/java/com/android/server/slice/SliceManagerService.java +++ b/services/core/java/com/android/server/slice/SliceManagerService.java @@ -16,21 +16,307 @@ package com.android.server.slice; -import android.app.slice.ISliceManager; -import android.content.Context; +import static android.content.ContentProvider.getUserIdFromUri; +import static android.content.ContentProvider.maybeAddUserId; +import android.Manifest.permission; +import android.app.AppOpsManager; +import android.app.slice.ISliceListener; +import android.app.slice.ISliceManager; +import android.app.slice.SliceSpec; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManagerInternal; +import android.content.pm.ResolveInfo; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Binder; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; +import android.os.RemoteException; +import android.util.ArrayMap; +import android.util.Log; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.app.AssistUtils; +import com.android.internal.util.Preconditions; +import com.android.server.LocalServices; +import com.android.server.ServiceThread; import com.android.server.SystemService; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + public class SliceManagerService extends ISliceManager.Stub { - public SliceManagerService(Context context) { + private static final String TAG = "SliceManagerService"; + private final Object mLock = new Object(); + private final Context mContext; + private final PackageManagerInternal mPackageManagerInternal; + private final AppOpsManager mAppOps; + private final AssistUtils mAssistUtils; + + @GuardedBy("mLock") + private final ArrayMap mPinnedSlicesByUri = new ArrayMap<>(); + private final Handler mHandler; + private final ContentObserver mObserver; + + public SliceManagerService(Context context) { + this(context, createHandler().getLooper()); } + @VisibleForTesting + SliceManagerService(Context context, Looper looper) { + mContext = context; + mPackageManagerInternal = Preconditions.checkNotNull( + LocalServices.getService(PackageManagerInternal.class)); + mAppOps = context.getSystemService(AppOpsManager.class); + mAssistUtils = new AssistUtils(context); + mHandler = new Handler(looper); + + mObserver = new ContentObserver(mHandler) { + @Override + public void onChange(boolean selfChange, Uri uri) { + try { + getPinnedSlice(uri).onChange(); + } catch (IllegalStateException e) { + Log.e(TAG, "Received change for unpinned slice " + uri, e); + } + } + }; + } + + /// ----- Lifecycle stuff ----- private void systemReady() { } - private void onUnlockUser(int userHandle) { + private void onUnlockUser(int userId) { + } + + private void onStopUser(int userId) { + synchronized (mLock) { + mPinnedSlicesByUri.values().removeIf(s -> getUserIdFromUri(s.getUri()) == userId); + } + } + + /// ----- ISliceManager stuff ----- + @Override + public void addSliceListener(Uri uri, String pkg, ISliceListener listener, SliceSpec[] specs) + throws RemoteException { + verifyCaller(pkg); + uri = maybeAddUserId(uri, Binder.getCallingUserHandle().getIdentifier()); + enforceAccess(pkg, uri); + getOrCreatePinnedSlice(uri).addSliceListener(listener, specs); + } + + @Override + public void removeSliceListener(Uri uri, String pkg, ISliceListener listener) + throws RemoteException { + verifyCaller(pkg); + uri = maybeAddUserId(uri, Binder.getCallingUserHandle().getIdentifier()); + enforceAccess(pkg, uri); + if (getPinnedSlice(uri).removeSliceListener(listener)) { + removePinnedSlice(uri); + } + } + + @Override + public void pinSlice(String pkg, Uri uri, SliceSpec[] specs) throws RemoteException { + verifyCaller(pkg); + enforceFullAccess(pkg, "pinSlice", uri); + uri = maybeAddUserId(uri, Binder.getCallingUserHandle().getIdentifier()); + getOrCreatePinnedSlice(uri).pin(pkg, specs); + } + + @Override + public void unpinSlice(String pkg, Uri uri) throws RemoteException { + verifyCaller(pkg); + enforceFullAccess(pkg, "unpinSlice", uri); + uri = maybeAddUserId(uri, Binder.getCallingUserHandle().getIdentifier()); + if (getPinnedSlice(uri).unpin(pkg)) { + removePinnedSlice(uri); + } + } + + @Override + public boolean hasSliceAccess(String pkg) throws RemoteException { + verifyCaller(pkg); + return hasFullSliceAccess(pkg, Binder.getCallingUserHandle().getIdentifier()); + } + + @Override + public SliceSpec[] getPinnedSpecs(Uri uri, String pkg) throws RemoteException { + verifyCaller(pkg); + enforceAccess(pkg, uri); + return getPinnedSlice(uri).getSpecs(); + } + + /// ----- internal code ----- + void removePinnedSlice(Uri uri) { + synchronized (mLock) { + mPinnedSlicesByUri.remove(uri).destroy(); + } + } + + private PinnedSliceState getPinnedSlice(Uri uri) { + synchronized (mLock) { + PinnedSliceState manager = mPinnedSlicesByUri.get(uri); + if (manager == null) { + throw new IllegalStateException(String.format("Slice %s not pinned", + uri.toString())); + } + return manager; + } + } + + private PinnedSliceState getOrCreatePinnedSlice(Uri uri) { + synchronized (mLock) { + PinnedSliceState manager = mPinnedSlicesByUri.get(uri); + if (manager == null) { + manager = createPinnedSlice(uri); + mPinnedSlicesByUri.put(uri, manager); + } + return manager; + } + } + + @VisibleForTesting + PinnedSliceState createPinnedSlice(Uri uri) { + return new PinnedSliceState(this, uri); + } + + public Object getLock() { + return mLock; + } + + public Context getContext() { + return mContext; + } + + public Handler getHandler() { + return mHandler; + } + + private void enforceAccess(String pkg, Uri uri) { + getContext().enforceUriPermission(uri, permission.BIND_SLICE, + permission.BIND_SLICE, Binder.getCallingPid(), Binder.getCallingUid(), + Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + "Slice binding requires the permission BIND_SLICE"); + int user = Binder.getCallingUserHandle().getIdentifier(); + if (getUserIdFromUri(uri, user) != user) { + getContext().enforceCallingOrSelfPermission(permission.INTERACT_ACROSS_USERS_FULL, + "Slice interaction across users requires INTERACT_ACROSS_USERS_FULL"); + } + } + + private void enforceFullAccess(String pkg, String name, Uri uri) { + int user = Binder.getCallingUserHandle().getIdentifier(); + if (!hasFullSliceAccess(pkg, user)) { + throw new SecurityException(String.format("Call %s requires full slice access", name)); + } + if (getUserIdFromUri(uri, user) != user) { + getContext().enforceCallingOrSelfPermission(permission.INTERACT_ACROSS_USERS_FULL, + "Slice interaction across users requires INTERACT_ACROSS_USERS_FULL"); + } + } + + private void verifyCaller(String pkg) { + mAppOps.checkPackage(Binder.getCallingUid(), pkg); + } + + private boolean hasFullSliceAccess(String pkg, int userId) { + return isDefaultHomeApp(pkg, userId) || isAssistant(pkg, userId) + || isGrantedFullAccess(pkg, userId); + } + + private boolean isAssistant(String pkg, int userId) { + final ComponentName cn = mAssistUtils.getAssistComponentForUser(userId); + if (cn == null) { + return false; + } + return cn.getPackageName().equals(pkg); + } + + public void listen(Uri uri) { + mContext.getContentResolver().registerContentObserver(uri, true, mObserver); + } + + public void unlisten(Uri uri) { + mContext.getContentResolver().unregisterContentObserver(mObserver); + synchronized (mLock) { + mPinnedSlicesByUri.forEach((u, s) -> { + if (s.isListening()) { + listen(u); + } + }); + } + } + + private boolean isDefaultHomeApp(String pkg, int userId) { + String defaultHome = getDefaultHome(userId); + return Objects.equals(pkg, defaultHome); + } + + // Based on getDefaultHome in ShortcutService. + // TODO: Unify if possible + @VisibleForTesting + String getDefaultHome(int userId) { + final long token = Binder.clearCallingIdentity(); + try { + final List allHomeCandidates = new ArrayList<>(); + + // Default launcher from package manager. + final ComponentName defaultLauncher = mPackageManagerInternal + .getHomeActivitiesAsUser(allHomeCandidates, userId); + + ComponentName detected = null; + if (defaultLauncher != null) { + detected = defaultLauncher; + } + + if (detected == null) { + // If we reach here, that means it's the first check since the user was created, + // and there's already multiple launchers and there's no default set. + // Find the system one with the highest priority. + // (We need to check the priority too because of FallbackHome in Settings.) + // If there's no system launcher yet, then no one can access slices, until + // the user explicitly sets one. + final int size = allHomeCandidates.size(); + + int lastPriority = Integer.MIN_VALUE; + for (int i = 0; i < size; i++) { + final ResolveInfo ri = allHomeCandidates.get(i); + if (!ri.activityInfo.applicationInfo.isSystemApp()) { + continue; + } + if (ri.priority < lastPriority) { + continue; + } + detected = ri.activityInfo.getComponentName(); + lastPriority = ri.priority; + } + } + return detected.getPackageName(); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + private boolean isGrantedFullAccess(String pkg, int userId) { + // TODO: This will be user granted access, if we allow this through a prompt. + return false; + } + + private static ServiceThread createHandler() { + ServiceThread handlerThread = new ServiceThread(TAG, + Process.THREAD_PRIORITY_BACKGROUND, true /*allowIo*/); + handlerThread.start(); + return handlerThread; } public static class Lifecycle extends SystemService { @@ -57,5 +343,10 @@ public class SliceManagerService extends ISliceManager.Stub { public void onUnlockUser(int userHandle) { mService.onUnlockUser(userHandle); } + + @Override + public void onStopUser(int userHandle) { + mService.onStopUser(userHandle); + } } } diff --git a/services/tests/uiservicestests/Android.mk b/services/tests/uiservicestests/Android.mk index 40e78785b9f5a..d8e14ecab1cda 100644 --- a/services/tests/uiservicestests/Android.mk +++ b/services/tests/uiservicestests/Android.mk @@ -10,7 +10,8 @@ LOCAL_MODULE_TAGS := tests # Include test java files and source from notifications package. LOCAL_SRC_FILES := $(call all-java-files-under, src) \ - $(call all-java-files-under, ../../core/java/com/android/server/notification) + $(call all-java-files-under, ../../core/java/com/android/server/notification) \ + $(call all-java-files-under, ../../core/java/com/android/server/slice) \ LOCAL_STATIC_JAVA_LIBRARIES := \ frameworks-base-testutils \ diff --git a/services/tests/uiservicestests/AndroidManifest.xml b/services/tests/uiservicestests/AndroidManifest.xml index 621b4572c467a..f022dcf376a66 100644 --- a/services/tests/uiservicestests/AndroidManifest.xml +++ b/services/tests/uiservicestests/AndroidManifest.xml @@ -28,6 +28,9 @@ + + { + assertEquals(TEST_URI, b.getParcelable(SliceProvider.EXTRA_BIND_URI)); + return true; + })); + } + + @Test + public void testSendUnpinnedOnDestroy() throws RemoteException { + TestableLooper.get(this).processAllMessages(); + clearInvocations(mIContentProvider); + + mPinnedSliceManager.destroy(); + TestableLooper.get(this).processAllMessages(); + + verify(mIContentProvider).call(anyString(), eq(SliceProvider.METHOD_UNPIN), eq(null), + argThat(b -> { + assertEquals(TEST_URI, b.getParcelable(SliceProvider.EXTRA_BIND_URI)); + return true; + })); + } + + @Test + public void testPkgPin() { + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.pin("pkg", FIRST_SPECS); + assertTrue(mPinnedSliceManager.isPinned()); + + assertTrue(mPinnedSliceManager.unpin("pkg")); + assertFalse(mPinnedSliceManager.isPinned()); + } + + @Test + public void testMultiPkgPin() { + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.pin("pkg", FIRST_SPECS); + assertTrue(mPinnedSliceManager.isPinned()); + mPinnedSliceManager.pin("pkg2", FIRST_SPECS); + + assertFalse(mPinnedSliceManager.unpin("pkg")); + assertTrue(mPinnedSliceManager.unpin("pkg2")); + assertFalse(mPinnedSliceManager.isPinned()); + } + + @Test + public void testListenerPin() { + ISliceListener listener = mock(ISliceListener.class); + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.addSliceListener(listener, FIRST_SPECS); + assertTrue(mPinnedSliceManager.isPinned()); + + assertTrue(mPinnedSliceManager.removeSliceListener(listener)); + assertFalse(mPinnedSliceManager.isPinned()); + } + + @Test + public void testMultiListenerPin() { + ISliceListener listener = mock(ISliceListener.class); + ISliceListener listener2 = mock(ISliceListener.class); + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.addSliceListener(listener, FIRST_SPECS); + assertTrue(mPinnedSliceManager.isPinned()); + mPinnedSliceManager.addSliceListener(listener2, FIRST_SPECS); + + assertFalse(mPinnedSliceManager.removeSliceListener(listener)); + assertTrue(mPinnedSliceManager.removeSliceListener(listener2)); + assertFalse(mPinnedSliceManager.isPinned()); + } + + @Test + public void testPkgListenerPin() { + ISliceListener listener = mock(ISliceListener.class); + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.addSliceListener(listener, FIRST_SPECS); + assertTrue(mPinnedSliceManager.isPinned()); + mPinnedSliceManager.pin("pkg", FIRST_SPECS); + + assertFalse(mPinnedSliceManager.removeSliceListener(listener)); + assertTrue(mPinnedSliceManager.unpin("pkg")); + assertFalse(mPinnedSliceManager.isPinned()); + } + + @Test + public void testBind() throws RemoteException { + TestableLooper.get(this).processAllMessages(); + clearInvocations(mIContentProvider); + + ISliceListener listener = mock(ISliceListener.class); + Slice s = new Slice.Builder(TEST_URI).build(); + Bundle b = new Bundle(); + b.putParcelable(SliceProvider.EXTRA_SLICE, s); + when(mIContentProvider.call(anyString(), eq(SliceProvider.METHOD_SLICE), eq(null), + any())).thenReturn(b); + + assertFalse(mPinnedSliceManager.isPinned()); + + mPinnedSliceManager.addSliceListener(listener, FIRST_SPECS); + + mPinnedSliceManager.onChange(); + TestableLooper.get(this).processAllMessages(); + + verify(mIContentProvider).call(anyString(), eq(SliceProvider.METHOD_SLICE), eq(null), + argThat(bundle -> { + assertEquals(TEST_URI, bundle.getParcelable(SliceProvider.EXTRA_BIND_URI)); + return true; + })); + verify(listener).onSliceUpdated(eq(s)); + } +} \ No newline at end of file diff --git a/services/tests/uiservicestests/src/com/android/server/slice/SliceManagerServiceTest.java b/services/tests/uiservicestests/src/com/android/server/slice/SliceManagerServiceTest.java new file mode 100644 index 0000000000000..fe9ea7a1bb9b3 --- /dev/null +++ b/services/tests/uiservicestests/src/com/android/server/slice/SliceManagerServiceTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.android.server.slice; + +import static android.content.ContentProvider.maybeAddUserId; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.AppOpsManager; +import android.app.slice.ISliceListener; +import android.app.slice.SliceSpec; +import android.content.pm.PackageManagerInternal; +import android.net.Uri; +import android.os.RemoteException; +import android.support.test.filters.SmallTest; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.testing.TestableLooper.RunWithLooper; + +import com.android.server.LocalServices; +import com.android.server.UiServiceTestCase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +@RunWithLooper +public class SliceManagerServiceTest extends UiServiceTestCase { + + private static final String AUTH = "com.android.services.uitests"; + private static final Uri TEST_URI = maybeAddUserId(Uri.parse("content://" + AUTH + "/path"), 0); + + private static final SliceSpec[] EMPTY_SPECS = new SliceSpec[]{ + }; + + private SliceManagerService mService; + private PinnedSliceState mCreatedSliceState; + + @Before + public void setup() { + LocalServices.addService(PackageManagerInternal.class, mock(PackageManagerInternal.class)); + mContext.addMockSystemService(AppOpsManager.class, mock(AppOpsManager.class)); + mContext.getTestablePermissions().setPermission(TEST_URI, PERMISSION_GRANTED); + + mService = spy(new SliceManagerService(mContext, TestableLooper.get(this).getLooper())); + mCreatedSliceState = mock(PinnedSliceState.class); + doReturn(mCreatedSliceState).when(mService).createPinnedSlice(eq(TEST_URI)); + } + + @After + public void teardown() { + LocalServices.removeServiceForTest(PackageManagerInternal.class); + } + + @Test + public void testAddListenerCreatesPinned() throws RemoteException { + mService.addSliceListener(TEST_URI, "pkg", mock(ISliceListener.class), EMPTY_SPECS); + verify(mService, times(1)).createPinnedSlice(eq(TEST_URI)); + } + + @Test + public void testAddListenerCreatesOnePinned() throws RemoteException { + mService.addSliceListener(TEST_URI, "pkg", mock(ISliceListener.class), EMPTY_SPECS); + mService.addSliceListener(TEST_URI, "pkg", mock(ISliceListener.class), EMPTY_SPECS); + verify(mService, times(1)).createPinnedSlice(eq(TEST_URI)); + } + + @Test + public void testRemoveListenerDestroysPinned() throws RemoteException { + ISliceListener listener = mock(ISliceListener.class); + mService.addSliceListener(TEST_URI, "pkg", listener, EMPTY_SPECS); + + when(mCreatedSliceState.removeSliceListener(eq(listener))).thenReturn(false); + mService.removeSliceListener(TEST_URI, "pkg", listener); + verify(mCreatedSliceState, never()).destroy(); + + when(mCreatedSliceState.removeSliceListener(eq(listener))).thenReturn(true); + mService.removeSliceListener(TEST_URI, "pkg", listener); + verify(mCreatedSliceState).destroy(); + } + + @Test(expected = IllegalStateException.class) + public void testUnrecognizedThrows() throws RemoteException { + mService.removeSliceListener(TEST_URI, "pkg", mock(ISliceListener.class)); + } + + @Test + public void testAddPinCreatesPinned() throws RemoteException { + doReturn("pkg").when(mService).getDefaultHome(anyInt()); + + mService.pinSlice("pkg", TEST_URI, EMPTY_SPECS); + mService.pinSlice("pkg", TEST_URI, EMPTY_SPECS); + verify(mService, times(1)).createPinnedSlice(eq(TEST_URI)); + } + + @Test + public void testRemovePinDestroysPinned() throws RemoteException { + doReturn("pkg").when(mService).getDefaultHome(anyInt()); + + mService.pinSlice("pkg", TEST_URI, EMPTY_SPECS); + + when(mCreatedSliceState.unpin(eq("pkg"))).thenReturn(false); + mService.unpinSlice("pkg", TEST_URI); + verify(mCreatedSliceState, never()).destroy(); + + when(mCreatedSliceState.unpin(eq("pkg"))).thenReturn(true); + mService.unpinSlice("pkg", TEST_URI); + verify(mCreatedSliceState).destroy(); + } + +} \ No newline at end of file