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

Commit 956bb57e authored by Ivan Chiang's avatar Ivan Chiang
Browse files

[PM] Add invisible label detection in Launcher apps

Add the detection to let the user can see the label correctly. If the
label of the activity is empty, use the label of the application. If
it is still empty, use the package name instead.

Test: atest LauncherActivityInfoTest
Test: atest CtsPackageManagerTestCases:LauncherAppsTest
Bug: 299586370
Bug: 309896458
Bug: 307309770
Change-Id: I7b28ffb0604a5f5211c18276a13da3ae49222775
parent fee79584
Loading
Loading
Loading
Loading
+174 −3
Original line number Diff line number Diff line
@@ -22,11 +22,18 @@ import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.res.Resources;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.icu.text.UnicodeSet;
import android.os.UserHandle;
import android.os.UserManager;
import android.text.TextUtils;
import android.util.DisplayMetrics;

import com.android.internal.annotations.VisibleForTesting;

import java.util.Objects;

/**
 * A representation of an activity that can belong to this user or a managed
 * profile associated with this user. It can be used to query the label, icon
@@ -36,6 +43,10 @@ public class LauncherActivityInfo {
    private final PackageManager mPm;
    private final LauncherActivityInfoInternal mInternal;

    private static final UnicodeSet TRIMMABLE_CHARACTERS =
            new UnicodeSet("[[:White_Space:][:Default_Ignorable_Code_Point:][:gc=Cc:]]",
                    /* ignoreWhitespace= */ false).freeze();

    /**
     * Create a launchable activity object for a given ResolveInfo and user.
     *
@@ -72,13 +83,28 @@ public class LauncherActivityInfo {
    }

    /**
     * Retrieves the label for the activity.
     * Retrieves the label for the activity. The returned label can be different
     * from {@link ActivityInfo#loadLabel(PackageManager)} or
     * {@link PackageItemInfo#loadLabel(PackageManager)}. The returned result is trimmed.
     * If the activity's label is empty, use the application's label instead.
     * If the application's label is still empty, use the package name instead.
     *
     * @return The label for the activity.
     * @return The label for the activity. If the activity's label is empty,
     *         return the application's label instead. If the application's label
     *         is still empty, return the package name instead.
     */
    public CharSequence getLabel() {
        CharSequence label = trim(getActivityInfo().loadLabel(mPm));
        // If the trimmed label is empty, use application's label instead
        if (TextUtils.isEmpty(label)) {
            label = trim(getApplicationInfo().loadLabel(mPm));
            // If the trimmed label is still empty, use package name instead
            if (TextUtils.isEmpty(label)) {
                label = getComponentName().getPackageName();
            }
        }
        // TODO: Go through LauncherAppsService
        return getActivityInfo().loadLabel(mPm);
        return label;
    }

    /**
@@ -180,4 +206,149 @@ public class LauncherActivityInfo {

        return mPm.getUserBadgedIcon(originalIcon, mInternal.getUser());
    }

    /**
     * If the {@code ch} is trimmable, return {@code true}. Otherwise, return
     * {@code false}. If the count of the code points of {@code ch} doesn't
     * equal 1, return {@code false}.
     * <p>
     * There are two types of the trimmable characters.
     * 1. The character is one of the Default_Ignorable_Code_Point in
     * <a href="
     * https://www.unicode.org/Public/UCD/latest/ucd/DerivedCoreProperties.txt">
     * DerivedCoreProperties.txt</a>, the White_Space in <a href=
     * "https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt">PropList.txt
     * </a> or category Cc.
     * <p>
     * 2. The character is not supported in the current system font.
     * {@link android.graphics.Paint#hasGlyph(String)}
     * <p>
     *
     */
    private static boolean isTrimmable(@NonNull Paint paint, @NonNull CharSequence ch) {
        Objects.requireNonNull(paint);
        Objects.requireNonNull(ch);

        // if ch is empty or it is not a character (i,e, the count of code
        // point doesn't equal one), return false
        if (TextUtils.isEmpty(ch)
                || Character.codePointCount(ch, /* beginIndex= */ 0, ch.length()) != 1) {
            return false;
        }

        // Return true for the cases as below:
        // 1. The character is in the TRIMMABLE_CHARACTERS set
        // 2. The character is not supported in the system font
        return TRIMMABLE_CHARACTERS.contains(ch) || !paint.hasGlyph(ch.toString());
    }

    /**
     * If the {@code sequence} has some leading trimmable characters, creates a new copy
     * and removes the trimmable characters from the copy. Otherwise the given
     * {@code sequence} is returned as it is. Use {@link #isTrimmable(Paint, CharSequence)}
     * to determine whether the character is trimmable or not.
     *
     * @return the trimmed string or the original string that has no
     *         leading trimmable characters.
     * @see    #isTrimmable(Paint, CharSequence)
     * @see    #trim(CharSequence)
     * @see    #trimEnd(CharSequence)
     *
     * @hide
     */
    @VisibleForTesting
    @NonNull
    public static CharSequence trimStart(@NonNull CharSequence sequence) {
        Objects.requireNonNull(sequence);

        if (TextUtils.isEmpty(sequence)) {
            return sequence;
        }

        final Paint paint = new Paint();
        int trimCount = 0;
        final int[] codePoints = sequence.codePoints().toArray();
        for (int i = 0, length = codePoints.length; i < length; i++) {
            String ch = Character.toString(codePoints[i]);
            if (!isTrimmable(paint, ch)) {
                break;
            }
            trimCount += ch.length();
        }
        if (trimCount == 0) {
            return sequence;
        }
        return sequence.subSequence(trimCount, sequence.length());
    }

    /**
     * If the {@code sequence} has some trailing trimmable characters, creates a new copy
     * and removes the trimmable characters from the copy. Otherwise the given
     * {@code sequence} is returned as it is. Use {@link #isTrimmable(Paint, CharSequence)}
     * to determine whether the character is trimmable or not.
     *
     * @return the trimmed sequence or the original sequence that has no
     *         trailing trimmable characters.
     * @see    #isTrimmable(Paint, CharSequence)
     * @see    #trimStart(CharSequence)
     * @see    #trim(CharSequence)
     *
     * @hide
     */
    @VisibleForTesting
    @NonNull
    public static CharSequence trimEnd(@NonNull CharSequence sequence) {
        Objects.requireNonNull(sequence);

        if (TextUtils.isEmpty(sequence)) {
            return sequence;
        }

        final Paint paint = new Paint();
        int trimCount = 0;
        final int[] codePoints = sequence.codePoints().toArray();
        for (int i = codePoints.length - 1; i >= 0; i--) {
            String ch = Character.toString(codePoints[i]);
            if (!isTrimmable(paint, ch)) {
                break;
            }
            trimCount += ch.length();
        }

        if (trimCount == 0) {
            return sequence;
        }
        return sequence.subSequence(0, sequence.length() - trimCount);
    }

    /**
     * If the {@code sequence} has some leading or trailing trimmable characters, creates
     * a new copy and removes the trimmable characters from the copy. Otherwise the given
     * {@code sequence} is returned as it is. Use {@link #isTrimmable(Paint, CharSequence)}
     * to determine whether the character is trimmable or not.
     *
     * @return the trimmed sequence or the original sequence that has no leading or
     *         trailing trimmable characters.
     * @see    #isTrimmable(Paint, CharSequence)
     * @see    #trimStart(CharSequence)
     * @see    #trimEnd(CharSequence)
     *
     * @hide
     */
    @VisibleForTesting
    @NonNull
    public static CharSequence trim(@NonNull CharSequence sequence) {
        Objects.requireNonNull(sequence);

        if (TextUtils.isEmpty(sequence)) {
            return sequence;
        }

        CharSequence result = trimStart(sequence);
        if (TextUtils.isEmpty(result)) {
            return result;
        }

        return trimEnd(result);
    }
}
+103 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.content.pm;

import static com.google.common.truth.Truth.assertThat;

import android.platform.test.annotations.Presubmit;

import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.junit.Test;
import org.junit.runner.RunWith;

/**
 * Tests for {@link android.content.pm.LauncherActivityInfo}
 */
@Presubmit
@RunWith(AndroidJUnit4.class)
public class LauncherActivityInfoTest {

    @Test
    public void testTrimStart() {
        // Invisible case
        assertThat(LauncherActivityInfo.trimStart("\u0009").toString()).isEmpty();
        // It is not supported in the system font
        assertThat(LauncherActivityInfo.trimStart("\u0FE1").toString()).isEmpty();
        // Surrogates case
        assertThat(LauncherActivityInfo.trimStart("\uD83E\uDD36").toString())
                .isEqualTo("\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trimStart("\u0009\u0FE1\uD83E\uDD36A").toString())
                .isEqualTo("\uD83E\uDD36A");
        assertThat(LauncherActivityInfo.trimStart("\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\uD83E\uDD36A\u0009\u0FE1");
        assertThat(LauncherActivityInfo.trimStart("A\uD83E\uDD36\u0009\u0FE1A").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A");
        assertThat(LauncherActivityInfo.trimStart(
                "A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trimStart(
                "\u0009\u0FE1\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\uD83E\uDD36A\u0009\u0FE1");
    }

    @Test
    public void testTrimEnd() {
        // Invisible case
        assertThat(LauncherActivityInfo.trimEnd("\u0009").toString()).isEmpty();
        // It is not supported in the system font
        assertThat(LauncherActivityInfo.trimEnd("\u0FE1").toString()).isEmpty();
        // Surrogates case
        assertThat(LauncherActivityInfo.trimEnd("\uD83E\uDD36").toString())
                .isEqualTo("\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trimEnd("\u0009\u0FE1\uD83E\uDD36A").toString())
                .isEqualTo("\u0009\u0FE1\uD83E\uDD36A");
        assertThat(LauncherActivityInfo.trimEnd("\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\uD83E\uDD36A");
        assertThat(LauncherActivityInfo.trimEnd("A\uD83E\uDD36\u0009\u0FE1A").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A");
        assertThat(LauncherActivityInfo.trimEnd(
                "A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trimEnd(
                "\u0009\u0FE1\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\u0009\u0FE1\uD83E\uDD36A");
    }

    @Test
    public void testTrim() {
        // Invisible case
        assertThat(LauncherActivityInfo.trim("\u0009").toString()).isEmpty();
        // It is not supported in the system font
        assertThat(LauncherActivityInfo.trim("\u0FE1").toString()).isEmpty();
        // Surrogates case
        assertThat(LauncherActivityInfo.trim("\uD83E\uDD36").toString())
                .isEqualTo("\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trim("\u0009\u0FE1\uD83E\uDD36A").toString())
                .isEqualTo("\uD83E\uDD36A");
        assertThat(LauncherActivityInfo.trim("\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\uD83E\uDD36A");
        assertThat(LauncherActivityInfo.trim("A\uD83E\uDD36\u0009\u0FE1A").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A");
        assertThat(LauncherActivityInfo.trim(
                "A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36").toString())
                .isEqualTo("A\uD83E\uDD36\u0009\u0FE1A\uD83E\uDD36");
        assertThat(LauncherActivityInfo.trim(
                "\u0009\u0FE1\uD83E\uDD36A\u0009\u0FE1").toString())
                .isEqualTo("\uD83E\uDD36A");
    }
}