Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit fe72d07d authored by Abodunrinwa Toki's avatar Abodunrinwa Toki
Browse files

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
parent f0059d21
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -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>
+124 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}
+144 −0
Original line number Diff line number Diff line
/*
 * 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;
        }
    }
}
+70 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}
+134 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}