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

Commit fe361a52 authored by chenjean's avatar chenjean Committed by Chun-Ku Lin
Browse files

feat(HCT): Perform custom migration logic for existing HCT users

This logic is triggered by two scenarios:
(A) During first bootup after OTA update to Android 16, if the
    user had HCT enabled.
  - Trigger: ACTION_PRE_BOOT_COMPLETED.
  - Migration: HCT is disabled and notification is shown.
(B) Restore backup from Android 15 (or earlier), if the backup
    had HCT enabled and new device does not.
  - Trigger: SettingsProvider's restore process.
  - Migration: HCT is not restored and notification is shown.

We store whether the user has seen this notification in a new secure
setting ACCESSIBILITY_HCT_SHOW_PROMPT. This setting is also backed up.

Bug: 369906140
Flag: com.android.graphics.hwui.flags.high_contrast_text_small_text_rect
Test: atest SettingsRoboTests:com.android.settings.accessibility.HighContrastTextMigrationReceiverTest
Test: flash an incremental update on a build with HCT enabled;
      observe HCT is disabled and a notification is sent.
Test: flash an incremental update on a build with HCT disabled;
      observe no change to HCT and no notification.

Change-Id: I4d294ffc0b2eabc59ee7988a579d678975a16380
parent 97072e43
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -5439,6 +5439,16 @@
            </intent-filter>
        </receiver>

        <receiver
            android:name=".accessibility.HighContrastTextMigrationReceiver"
            android:permission="android.permission.MANAGE_ACCESSIBILITY"
            android:exported="true"> <!-- Exported for SettingsProvider restore from backup. -->
            <intent-filter>
                <action android:name="android.intent.action.PRE_BOOT_COMPLETED"/>
                <action android:name="com.android.settings.accessibility.ACTION_HIGH_CONTRAST_TEXT_RESTORED"/>
            </intent-filter>
        </receiver>

        <activity
            android:name="Settings$ChangeNfcTagAppsActivity"
            android:exported="true"
+4 −0
Original line number Diff line number Diff line
@@ -5387,6 +5387,10 @@
    <string name="accessibility_toggle_high_text_contrast_preference_title">High contrast text</string>
    <!-- Summary for the accessibility preference to high contrast text. [CHAR LIMIT=NONE] -->
    <string name="accessibility_toggle_high_text_contrast_preference_summary">Change text color to black or white. Maximizes contrast with the background.</string>
    <!-- Content for the notification to high contrast text. [CHAR LIMIT=NONE] -->
    <string name="accessibility_notification_high_contrast_text_content">High contrast text has a new look and feel.</string>
    <!-- Action for the notification to high contrast text. [CHAR LIMIT=35] -->
    <string name="accessibility_notification_high_contrast_text_action">Open Settings</string>
    <!-- Title for the accessibility preference to high contrast text. [CHAR LIMIT=35] -->
    <string name="accessibility_toggle_maximize_text_contrast_preference_title">Maximize text contrast</string>
    <!-- Summary for the accessibility preference to high contrast text. [CHAR LIMIT=NONE] -->
+158 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.settings.accessibility;

import static com.android.settings.SettingsActivity.EXTRA_FRAGMENT_ARG_KEY;
import static com.android.settings.SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS;
import static com.android.settings.accessibility.AccessibilityUtil.State.OFF;
import static com.android.settings.accessibility.AccessibilityUtil.State.ON;

import android.annotation.IntDef;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;

import androidx.annotation.NonNull;

import com.android.graphics.hwui.flags.Flags;
import com.android.settings.R;

import com.google.common.annotations.VisibleForTesting;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Handling smooth migration to the new high contrast text appearance
 */
public class HighContrastTextMigrationReceiver extends BroadcastReceiver {
    private static final String TAG = HighContrastTextMigrationReceiver.class.getSimpleName();
    @VisibleForTesting
    static final String NOTIFICATION_CHANNEL = "high_contrast_text_notification_channel";
    @VisibleForTesting
    static final String ACTION_RESTORED =
            "com.android.settings.accessibility.ACTION_HIGH_CONTRAST_TEXT_RESTORED";
    @VisibleForTesting
    static final int NOTIFICATION_ID = 1;

    @Retention(RetentionPolicy.SOURCE)
    @IntDef({
            PromptState.UNKNOWN,
            PromptState.PROMPT_SHOWN,
            PromptState.PROMPT_UNNECESSARY,
    })
    public @interface PromptState {
        int UNKNOWN = 0;
        int PROMPT_SHOWN = 1;
        int PROMPT_UNNECESSARY = 2;
    }

    @Override
    public void onReceive(@NonNull Context context, @NonNull Intent intent) {
        if (!Flags.highContrastTextSmallTextRect()) {
            return;
        }

        if (ACTION_RESTORED.equals(intent.getAction())) {
            Log.i(TAG, "HCT attempted to be restored from backup; showing notification for userId: "
                    + context.getUserId());
            Settings.Secure.putInt(context.getContentResolver(),
                    Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS,
                    PromptState.PROMPT_SHOWN);
            showNotification(context);
        } else if (Intent.ACTION_PRE_BOOT_COMPLETED.equals(intent.getAction())) {
            final boolean hasSeenPromptIfNecessary = Settings.Secure.getInt(
                    context.getContentResolver(),
                    Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS, PromptState.UNKNOWN)
                    != PromptState.UNKNOWN;
            if (hasSeenPromptIfNecessary) {
                Log.i(TAG, "Has seen HCT prompt if necessary; skip HCT migration for userId: "
                        + context.getUserId());
                return;
            }

            final boolean isHctEnabled = Settings.Secure.getInt(context.getContentResolver(),
                    Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED, OFF) == ON;
            if (isHctEnabled) {
                Log.i(TAG, "HCT enabled before OTA update; performing migration for userId: "
                        + context.getUserId());
                Settings.Secure.putInt(context.getContentResolver(),
                        Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED,
                        OFF);
                Settings.Secure.putInt(context.getContentResolver(),
                        Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS,
                        PromptState.PROMPT_SHOWN);
                showNotification(context);
            } else {
                Log.i(TAG,
                        "HCT was not enabled before OTA update; not performing migration for "
                                + "userId: " + context.getUserId());
                Settings.Secure.putInt(context.getContentResolver(),
                        Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS,
                        PromptState.PROMPT_UNNECESSARY);
            }
        }
    }

    private void showNotification(Context context) {
        Notification.Builder notificationBuilder = new Notification.Builder(context,
                NOTIFICATION_CHANNEL)
                .setSmallIcon(R.drawable.ic_settings_24dp)
                .setContentTitle(context.getString(
                        R.string.accessibility_toggle_high_text_contrast_preference_title))
                .setContentText(context.getString(
                        R.string.accessibility_notification_high_contrast_text_content))
                .setAutoCancel(true);

        Intent settingsIntent = new Intent(Settings.ACTION_TEXT_READING_SETTINGS);
        settingsIntent.setPackage(context.getPackageName());
        if (settingsIntent.resolveActivity(context.getPackageManager()) != null) {
            Bundle fragmentArgs = new Bundle();
            fragmentArgs.putString(EXTRA_FRAGMENT_ARG_KEY,
                    TextReadingPreferenceFragment.HIGH_TEXT_CONTRAST_KEY);
            settingsIntent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArgs);
            PendingIntent settingsPendingIntent = PendingIntent.getActivity(context,
                    /* requestCode = */ 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE);

            Notification.Action settingsAction = new Notification.Action.Builder(
                    /* icon= */ null,
                    context.getString(
                            R.string.accessibility_notification_high_contrast_text_action),
                    settingsPendingIntent
            ).build();

            notificationBuilder.addAction(settingsAction);
        }

        NotificationManager notificationManager =
                context.getSystemService(NotificationManager.class);
        NotificationChannel notificationChannel = new NotificationChannel(
                NOTIFICATION_CHANNEL,
                context.getString(
                        R.string.accessibility_toggle_high_text_contrast_preference_title),
                NotificationManager.IMPORTANCE_LOW);
        notificationManager.createNotificationChannel(notificationChannel);
        notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
    }
}
+15 −0
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import android.provider.Settings;
import androidx.preference.PreferenceScreen;
import androidx.preference.TwoStatePreference;

import com.android.graphics.hwui.flags.Flags;
import com.android.settings.R;
import com.android.settings.accessibility.TextReadingPreferenceFragment.EntryPoint;
import com.android.settings.core.TogglePreferenceController;
@@ -60,6 +61,20 @@ public class HighTextContrastPreferenceController extends TogglePreferenceContro
                isChecked ? 1 : 0,
                AccessibilityStatsLogUtils.convertToEntryPoint(mEntryPoint));

        if (Flags.highContrastTextSmallTextRect()) {
            // Set PROMPT_UNNECESSARY when the user modifies the HighContrastText setting
            // This is needed for the following scenario:
            // On Android 16, create secondary user, ACTION_PRE_BOOT_COMPLETED won't be sent to
            // the secondary user. The user enables HCT.
            // When updating OS to Android 17, ACTION_PRE_BOOT_COMPLETED will be sent to the
            // secondary user when switch to the secondary user.
            // If the prompt status is not updated in Android 16, we would automatically disable
            // HCT and show the HCT prompt, which is an undesired behavior.
            Settings.Secure.putInt(mContext.getContentResolver(),
                    Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS,
                    HighContrastTextMigrationReceiver.PromptState.PROMPT_UNNECESSARY);
        }

        return Settings.Secure.putInt(mContext.getContentResolver(),
                Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED, (isChecked ? 1 : 0));
    }
+241 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.settings.accessibility;

import static com.android.settings.SettingsActivity.EXTRA_FRAGMENT_ARG_KEY;
import static com.android.settings.SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS;
import static com.android.settings.accessibility.AccessibilityUtil.State.OFF;
import static com.android.settings.accessibility.AccessibilityUtil.State.ON;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.ACTION_RESTORED;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.NOTIFICATION_CHANNEL;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.NOTIFICATION_ID;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.PromptState.PROMPT_SHOWN;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.PromptState.PROMPT_UNNECESSARY;
import static com.android.settings.accessibility.HighContrastTextMigrationReceiver.PromptState.UNKNOWN;

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

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.os.Bundle;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;
import android.provider.Settings;

import androidx.test.core.app.ApplicationProvider;

import com.android.graphics.hwui.flags.Flags;
import com.android.settings.R;
import com.android.settings.Utils;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotification;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.shadows.ShadowPackageManager;

/** Tests for {@link HighContrastTextMigrationReceiver}. */
@RunWith(RobolectricTestRunner.class)
public class HighContrastTextMigrationReceiverTest {

    @Rule
    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
    private final Context mContext = ApplicationProvider.getApplicationContext();
    private HighContrastTextMigrationReceiver mReceiver;
    private ShadowNotificationManager mShadowNotificationManager;

    @Before
    public void setUp() {
        NotificationManager notificationManager =
                mContext.getSystemService(NotificationManager.class);
        mShadowNotificationManager = Shadows.shadowOf(notificationManager);

        // Setup Settings app as a system app
        ShadowPackageManager shadowPm = Shadows.shadowOf(mContext.getPackageManager());
        ComponentName textReadingComponent = new ComponentName(Utils.SETTINGS_PACKAGE_NAME,
                com.android.settings.Settings.TextReadingSettingsActivity.class.getName());
        ActivityInfo activityInfo = shadowPm.addActivityIfNotPresent(textReadingComponent);
        activityInfo.applicationInfo.flags |= ApplicationInfo.FLAG_SYSTEM;
        shadowPm.addOrUpdateActivity(activityInfo);

        mReceiver = new HighContrastTextMigrationReceiver();
    }

    @Test
    @DisableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onReceive_flagOff_settingsNotSet() {
        mReceiver.onReceive(mContext, new Intent(ACTION_RESTORED));

        assertPromptStateAndHctState(/* promptState= */ UNKNOWN, /* hctState= */ OFF);
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onRestored_hctStateOn_showPromptHctKeepsOn() {
        setPromptStateAndHctState(/* promptState= */ UNKNOWN, /* hctState= */ ON);

        mReceiver.onReceive(mContext, new Intent(ACTION_RESTORED));

        assertPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, ON);
        verifyNotificationSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onRestored_hctStateOff_showPromptHctKeepsOff() {
        setPromptStateAndHctState(/* promptState= */ UNKNOWN, /* hctState= */ OFF);

        mReceiver.onReceive(mContext, new Intent(ACTION_RESTORED));

        assertPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, OFF);
        verifyNotificationSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateUnknownHctOn_showPromptAndAutoDisableHct() {
        setPromptStateAndHctState(/* promptState= */ UNKNOWN, /* hctState= */ ON);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, /* hctState= */ OFF);
        verifyNotificationSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateUnknownAndHctOff_promptIsUnnecessaryHctKeepsOff() {
        setPromptStateAndHctState(/* promptState= */ UNKNOWN, /* hctState= */ OFF);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_UNNECESSARY, /* hctState= */ OFF);
        verifyNotificationNotSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateShownAndHctOn_promptStateUnchangedHctKeepsOn() {
        setPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, /* hctState= */ ON);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, /* hctState= */ ON);
        verifyNotificationNotSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateShownAndHctOff_promptStateUnchangedHctKeepsOff() {
        setPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, /* hctState= */ OFF);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_SHOWN, /* hctState= */ OFF);
        verifyNotificationNotSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateUnnecessaryAndHctOn_promptStateUnchangedHctKeepsOn() {
        setPromptStateAndHctState(/* promptState= */ PROMPT_UNNECESSARY, /* hctState= */ ON);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_UNNECESSARY, /* hctState= */ ON);
        verifyNotificationNotSent();
    }

    @Test
    @EnableFlags(Flags.FLAG_HIGH_CONTRAST_TEXT_SMALL_TEXT_RECT)
    public void onPreBootCompleted_promptStateUnnecessaryHctOff_promptStateUnchangedHctKeepsOff() {
        setPromptStateAndHctState(/* promptState= */ PROMPT_UNNECESSARY, /* hctState= */ OFF);

        Intent intent = new Intent(Intent.ACTION_PRE_BOOT_COMPLETED);
        mReceiver.onReceive(mContext, intent);

        assertPromptStateAndHctState(/* promptState= */ PROMPT_UNNECESSARY, /* hctState= */ OFF);
        verifyNotificationNotSent();
    }

    private void verifyNotificationNotSent() {
        Notification notification = mShadowNotificationManager.getNotification(NOTIFICATION_ID);
        assertThat(notification).isNull();
    }

    private void verifyNotificationSent() {
        // Verify hct channel created
        assertThat(mShadowNotificationManager.getNotificationChannels().stream().anyMatch(
                channel -> channel.getId().equals(NOTIFICATION_CHANNEL))).isTrue();

        // Verify hct notification is sent with correct content
        Notification notification = mShadowNotificationManager.getNotification(NOTIFICATION_ID);
        assertThat(notification).isNotNull();

        ShadowNotification shadowNotification = Shadows.shadowOf(notification);
        assertThat(shadowNotification.getContentTitle()).isEqualTo(mContext.getString(
                R.string.accessibility_toggle_high_text_contrast_preference_title));
        assertThat(shadowNotification.getContentText()).isEqualTo(
                mContext.getString(R.string.accessibility_notification_high_contrast_text_content));

        assertThat(notification.actions.length).isEqualTo(1);
        assertThat(notification.actions[0].title.toString()).isEqualTo(
                mContext.getString(R.string.accessibility_notification_high_contrast_text_action));

        PendingIntent pendingIntent = notification.actions[0].actionIntent;
        Intent settingsIntent = Shadows.shadowOf(pendingIntent).getSavedIntent();
        Bundle fragmentArgs = settingsIntent.getBundleExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS);
        assertThat(fragmentArgs).isNotNull();
        assertThat(fragmentArgs.getString(EXTRA_FRAGMENT_ARG_KEY))
                .isEqualTo(TextReadingPreferenceFragment.HIGH_TEXT_CONTRAST_KEY);
    }

    private void assertPromptStateAndHctState(
            @HighContrastTextMigrationReceiver.PromptState int promptState,
            @AccessibilityUtil.State int hctState) {
        assertThat(Settings.Secure.getInt(mContext.getContentResolver(),
                Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS, UNKNOWN))
                .isEqualTo(promptState);
        assertThat(Settings.Secure.getInt(mContext.getContentResolver(),
                Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED, OFF))
                .isEqualTo(hctState);
    }

    private void setPromptStateAndHctState(
            @HighContrastTextMigrationReceiver.PromptState int promptState,
            @AccessibilityUtil.State int hctState) {
        Settings.Secure.putInt(mContext.getContentResolver(),
                Settings.Secure.ACCESSIBILITY_HCT_RECT_PROMPT_STATUS, promptState);
        Settings.Secure.putInt(mContext.getContentResolver(),
                Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED, hctState);
    }
}