From fe72d07de0bc9af9cc6c3cee4755f423c78c76cb Mon Sep 17 00:00:00 2001 From: Abodunrinwa Toki Date: Mon, 6 Apr 2020 03:43:07 +0100 Subject: [PATCH] Introduce an IconsContentProvider. Because of the new app visibility constraints introduced in Android R, apps may be unable to load icons from the TextClassifierService (TCS). This is because the TCS returns "Icons" based on resources to the apps. These resources were globally available until this new constraint. The IconsContentProvider (IconsCP) provides access to the icons returned by the TCS. For each icon the TCS intends to return to a client app, the IconsCP creates a proxy content URI for that icon "resource" and forwards an Icon object based on that URI. If/when rendering of the icon is requested from this URI, the IconsCP loads the icon and returns a PNG representation of the icon. Bug: 151847511 Test: atest services/tests/servicestests/src/com/android/server/textclassifier Change-Id: I25219138c81e8978fc4af9f8c61821c487bd7b77 --- core/res/AndroidManifest.xml | 7 + .../textclassifier/IconsContentProvider.java | 124 +++++++++++++++ .../server/textclassifier/IconsUriHelper.java | 144 ++++++++++++++++++ .../IconsContentProviderTest.java | 70 +++++++++ .../textclassifier/IconsUriHelperTest.java | 134 ++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 services/core/java/com/android/server/textclassifier/IconsContentProvider.java create mode 100644 services/core/java/com/android/server/textclassifier/IconsUriHelper.java create mode 100644 services/tests/servicestests/src/com/android/server/textclassifier/IconsContentProviderTest.java create mode 100644 services/tests/servicestests/src/com/android/server/textclassifier/IconsUriHelperTest.java diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index 451363f6bd3d5..70097d09fe3f9 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -5454,6 +5454,13 @@ + + + diff --git a/services/core/java/com/android/server/textclassifier/IconsContentProvider.java b/services/core/java/com/android/server/textclassifier/IconsContentProvider.java new file mode 100644 index 0000000000000..d19a707770e2f --- /dev/null +++ b/services/core/java/com/android/server/textclassifier/IconsContentProvider.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2020 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.textclassifier; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.os.ParcelFileDescriptor.AutoCloseOutputStream; +import android.util.Log; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.textclassifier.IconsUriHelper.ResourceInfo; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * A content provider that is used to access icons returned from the TextClassifier service. + * + *

Use {@link IconsUriHelper#getContentUri(String, int)} to access a uri for a specific resource. + * The uri may be passed to other processes to access the specified resource. + * + *

NOTE: Care must be taken to avoid leaking resources to non-permitted apps via this provider. + */ +public final class IconsContentProvider extends ContentProvider { + + private static final String TAG = "IconsContentProvider"; + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) { + try { + final ResourceInfo res = IconsUriHelper.getInstance().getResourceInfo(uri); + final Drawable drawable = Icon.createWithResource(res.packageName, res.id) + .loadDrawable(getContext()); + final byte[] data = getBitmapData(drawable); + final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor readSide = pipe[0]; + final ParcelFileDescriptor writeSide = pipe[1]; + try (OutputStream out = new AutoCloseOutputStream(writeSide)) { + out.write(data); + return readSide; + } + } catch (IOException | RuntimeException e) { + Log.e(TAG, "Error retrieving icon for uri: " + uri, e); + } + return null; + } + + /** + * Returns the bitmap data for the specified drawable. + */ + @VisibleForTesting + public static byte[] getBitmapData(Drawable drawable) { + if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { + throw new IllegalStateException("The icon is zero-sized"); + } + + final Bitmap bitmap = Bitmap.createBitmap( + drawable.getIntrinsicWidth(), + drawable.getIntrinsicHeight(), + Bitmap.Config.ARGB_8888); + + final Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); + final byte[] byteArray = stream.toByteArray(); + bitmap.recycle(); + return byteArray; + } + + @Override + public String getType(Uri uri) { + return "image/png"; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } +} diff --git a/services/core/java/com/android/server/textclassifier/IconsUriHelper.java b/services/core/java/com/android/server/textclassifier/IconsUriHelper.java new file mode 100644 index 0000000000000..f17b0f14bd0ea --- /dev/null +++ b/services/core/java/com/android/server/textclassifier/IconsUriHelper.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2020 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.textclassifier; + +import android.annotation.Nullable; +import android.net.Uri; +import android.util.ArrayMap; +import android.util.Log; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.annotations.VisibleForTesting.Visibility; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * A helper for mapping an icon resource to a content uri. + * + *

NOTE: Care must be taken to avoid passing resource uris to non-permitted apps via this helper. + */ +@VisibleForTesting(visibility = Visibility.PACKAGE) +public final class IconsUriHelper { + + public static final String AUTHORITY = "com.android.textclassifier.icons"; + + private static final String TAG = "IconsUriHelper"; + private static final Supplier DEFAULT_ID_SUPPLIER = () -> UUID.randomUUID().toString(); + + // TODO: Consider using an LRU cache to limit resource usage. + // This may depend on the expected number of packages that a device typically has. + @GuardedBy("mPackageIds") + private final Map mPackageIds = new ArrayMap<>(); + + private final Supplier mIdSupplier; + + private static final IconsUriHelper sSingleton = new IconsUriHelper(null); + + private IconsUriHelper(@Nullable Supplier idSupplier) { + mIdSupplier = (idSupplier != null) ? idSupplier : DEFAULT_ID_SUPPLIER; + + // Useful for testing: + // Magic id for the android package so it is the same across classloaders. + // This is okay as this package does not have access restrictions, and + // the TextClassifierService hardly returns icons from this package. + mPackageIds.put("android", "android"); + } + + /** + * Returns a new instance of this object for testing purposes. + */ + public static IconsUriHelper newInstanceForTesting(@Nullable Supplier idSupplier) { + return new IconsUriHelper(idSupplier); + } + + static IconsUriHelper getInstance() { + return sSingleton; + } + + /** + * Returns a Uri for the specified icon resource. + * + * @param packageName the resource's package name + * @param resId the resource id + * @see #getResourceInfo(Uri) + */ + public Uri getContentUri(String packageName, int resId) { + Objects.requireNonNull(packageName); + synchronized (mPackageIds) { + if (!mPackageIds.containsKey(packageName)) { + // TODO: Ignore packages that don't actually exist on the device. + mPackageIds.put(packageName, mIdSupplier.get()); + } + return new Uri.Builder() + .scheme("content") + .authority(AUTHORITY) + .path(mPackageIds.get(packageName)) + .appendPath(Integer.toString(resId)) + .build(); + } + } + + /** + * Returns a valid {@link ResourceInfo} for the specified uri. Returns {@code null} if a valid + * {@link ResourceInfo} cannot be returned for the specified uri. + * + * @see #getContentUri(String, int); + */ + @Nullable + public ResourceInfo getResourceInfo(Uri uri) { + if (!"content".equals(uri.getScheme())) { + return null; + } + if (!AUTHORITY.equals(uri.getAuthority())) { + return null; + } + + final List pathItems = uri.getPathSegments(); + try { + synchronized (mPackageIds) { + final String packageId = pathItems.get(0); + final int resId = Integer.parseInt(pathItems.get(1)); + for (String packageName : mPackageIds.keySet()) { + if (packageId.equals(mPackageIds.get(packageName))) { + return new ResourceInfo(packageName, resId); + } + } + } + } catch (Exception e) { + Log.v(TAG, "Could not get resource info. Reason: " + e.getMessage()); + } + return null; + } + + /** + * A holder for a resource's package name and id. + */ + public static final class ResourceInfo { + + public final String packageName; + public final int id; + + private ResourceInfo(String packageName, int id) { + this.packageName = Objects.requireNonNull(packageName); + this.id = id; + } + } +} diff --git a/services/tests/servicestests/src/com/android/server/textclassifier/IconsContentProviderTest.java b/services/tests/servicestests/src/com/android/server/textclassifier/IconsContentProviderTest.java new file mode 100644 index 0000000000000..72580a3b98c23 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/textclassifier/IconsContentProviderTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2020 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.textclassifier; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Sanity test for {@link IconsContentProvider}. + */ +@RunWith(AndroidJUnit4.class) +public final class IconsContentProviderTest { + + @Test + public void testLoadResource() { + final Context context = ApplicationProvider.getApplicationContext(); + // Testing with the android package name because this is the only package name + // that returns the same uri across multiple classloaders. + final String packageName = "android"; + final int resId = android.R.drawable.btn_star; + final Uri uri = IconsUriHelper.getInstance().getContentUri(packageName, resId); + + final Drawable expected = Icon.createWithResource(packageName, resId).loadDrawable(context); + // Ensure we are testing with a non-empty image. + assertThat(expected.getIntrinsicWidth()).isGreaterThan(0); + assertThat(expected.getIntrinsicHeight()).isGreaterThan(0); + + final Drawable actual = Icon.createWithContentUri(uri).loadDrawable(context); + assertThat(actual).isNotNull(); + assertThat(IconsContentProvider.getBitmapData(actual)) + .isEqualTo(IconsContentProvider.getBitmapData(expected)); + } + + @Test + public void testLoadResource_badUri() { + final Uri badUri = new Uri.Builder() + .scheme("content") + .authority(IconsUriHelper.AUTHORITY) + .path("badPackageId") + .appendPath("1234") + .build(); + + final Context context = ApplicationProvider.getApplicationContext(); + assertThat(Icon.createWithContentUri(badUri).loadDrawable(context)).isNull(); + } +} + diff --git a/services/tests/servicestests/src/com/android/server/textclassifier/IconsUriHelperTest.java b/services/tests/servicestests/src/com/android/server/textclassifier/IconsUriHelperTest.java new file mode 100644 index 0000000000000..96f09d965b13a --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/textclassifier/IconsUriHelperTest.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2020 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.textclassifier; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; + +import androidx.test.runner.AndroidJUnit4; + +import com.android.server.textclassifier.IconsUriHelper.ResourceInfo; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests for {@link IconsUriHelper}. + */ +@RunWith(AndroidJUnit4.class) +public final class IconsUriHelperTest { + + private IconsUriHelper mIconsUriHelper; + + @Before + public void setUp() { + mIconsUriHelper = IconsUriHelper.newInstanceForTesting(null); + } + + @Test + public void testGetContentUri() { + final IconsUriHelper iconsUriHelper = IconsUriHelper.newInstanceForTesting(() -> "pkgId"); + final Uri expected = new Uri.Builder() + .scheme("content") + .authority(IconsUriHelper.AUTHORITY) + .path("pkgId") + .appendPath("1234") + .build(); + + final Uri actual = iconsUriHelper.getContentUri("com.package.name", 1234); + assertThat(actual).isEqualTo(expected); + } + + @Test + public void testGetContentUri_multiplePackages() { + final Uri uri1 = mIconsUriHelper.getContentUri("com.package.name1", 1234); + final Uri uri2 = mIconsUriHelper.getContentUri("com.package.name2", 5678); + + assertThat(uri1.getScheme()).isEqualTo("content"); + assertThat(uri2.getScheme()).isEqualTo("content"); + + assertThat(uri1.getAuthority()).isEqualTo(IconsUriHelper.AUTHORITY); + assertThat(uri2.getAuthority()).isEqualTo(IconsUriHelper.AUTHORITY); + + assertThat(uri1.getPathSegments().get(1)).isEqualTo("1234"); + assertThat(uri2.getPathSegments().get(1)).isEqualTo("5678"); + } + + @Test + public void testGetContentUri_samePackageIdForSamePackageName() { + final String packageName = "com.package.name"; + final Uri uri1 = mIconsUriHelper.getContentUri(packageName, 1234); + final Uri uri2 = mIconsUriHelper.getContentUri(packageName, 5678); + + final String id1 = uri1.getPathSegments().get(0); + final String id2 = uri2.getPathSegments().get(0); + + assertThat(id1).isEqualTo(id2); + } + + @Test + public void testGetResourceInfo() { + mIconsUriHelper.getContentUri("com.package.name1", 123); + final Uri uri = mIconsUriHelper.getContentUri("com.package.name2", 456); + mIconsUriHelper.getContentUri("com.package.name3", 789); + + final ResourceInfo res = mIconsUriHelper.getResourceInfo(uri); + assertThat(res.packageName).isEqualTo("com.package.name2"); + assertThat(res.id).isEqualTo(456); + } + + @Test + public void testGetResourceInfo_unrecognizedUri() { + final Uri uri = new Uri.Builder() + .scheme("content") + .authority(IconsUriHelper.AUTHORITY) + .path("unrecognized") + .appendPath("1234") + .build(); + assertThat(mIconsUriHelper.getResourceInfo(uri)).isNull(); + } + + @Test + public void testGetResourceInfo_invalidScheme() { + final IconsUriHelper iconsUriHelper = IconsUriHelper.newInstanceForTesting(() -> "pkgId"); + iconsUriHelper.getContentUri("com.package.name", 1234); + + final Uri uri = new Uri.Builder() + .scheme("file") + .authority(IconsUriHelper.AUTHORITY) + .path("pkgId") + .appendPath("1234") + .build(); + assertThat(iconsUriHelper.getResourceInfo(uri)).isNull(); + } + + @Test + public void testGetResourceInfo_invalidAuthority() { + final IconsUriHelper iconsUriHelper = IconsUriHelper.newInstanceForTesting(() -> "pkgId"); + iconsUriHelper.getContentUri("com.package.name", 1234); + + final Uri uri = new Uri.Builder() + .scheme("content") + .authority("invalid.authority") + .path("pkgId") + .appendPath("1234") + .build(); + assertThat(iconsUriHelper.getResourceInfo(uri)).isNull(); + } +} +