summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/res/AndroidManifest.xml7
-rw-r--r--services/core/java/com/android/server/textclassifier/IconsContentProvider.java124
-rw-r--r--services/core/java/com/android/server/textclassifier/IconsUriHelper.java144
-rw-r--r--services/tests/servicestests/src/com/android/server/textclassifier/IconsContentProviderTest.java70
-rw-r--r--services/tests/servicestests/src/com/android/server/textclassifier/IconsUriHelperTest.java134
5 files changed, 479 insertions, 0 deletions
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index 451363f6bd3d..70097d09fe3f 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -5454,6 +5454,13 @@
</intent-filter>
</service>
+ <provider
+ android:name="com.android.server.textclassifier.IconsContentProvider"
+ android:authorities="com.android.textclassifier.icons"
+ android:enabled="true"
+ android:exported="true">
+ </provider>
+
</application>
</manifest>
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 000000000000..d19a707770e2
--- /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.
+ *
+ * <p>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.
+ *
+ * <p>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 000000000000..f17b0f14bd0e
--- /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.
+ *
+ * <p>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<String> 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<String, String> mPackageIds = new ArrayMap<>();
+
+ private final Supplier<String> mIdSupplier;
+
+ private static final IconsUriHelper sSingleton = new IconsUriHelper(null);
+
+ private IconsUriHelper(@Nullable Supplier<String> 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<String> 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<String> 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 000000000000..72580a3b98c2
--- /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 000000000000..96f09d965b13
--- /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();
+ }
+}
+