diff options
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(); + } +} + |