From fe361a526e2880f62750285058097f18e154d893 Mon Sep 17 00:00:00 2001 From: chenjean Date: Wed, 27 Nov 2024 23:28:26 +0800 Subject: [PATCH] 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 --- AndroidManifest.xml | 10 + res/values/strings.xml | 4 + .../HighContrastTextMigrationReceiver.java | 158 ++++++++++++ .../HighTextContrastPreferenceController.java | 15 ++ ...HighContrastTextMigrationReceiverTest.java | 241 ++++++++++++++++++ 5 files changed, 428 insertions(+) create mode 100644 src/com/android/settings/accessibility/HighContrastTextMigrationReceiver.java create mode 100644 tests/robotests/src/com/android/settings/accessibility/HighContrastTextMigrationReceiverTest.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 2295ee3dd1a..551a7dedb69 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -5439,6 +5439,16 @@ + + + + + + + High contrast text Change text color to black or white. Maximizes contrast with the background. + + High contrast text has a new look and feel. + + Open Settings Maximize text contrast diff --git a/src/com/android/settings/accessibility/HighContrastTextMigrationReceiver.java b/src/com/android/settings/accessibility/HighContrastTextMigrationReceiver.java new file mode 100644 index 00000000000..ee3537bedd3 --- /dev/null +++ b/src/com/android/settings/accessibility/HighContrastTextMigrationReceiver.java @@ -0,0 +1,158 @@ +/* + * 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()); + } +} diff --git a/src/com/android/settings/accessibility/HighTextContrastPreferenceController.java b/src/com/android/settings/accessibility/HighTextContrastPreferenceController.java index 7a3f4f6e912..c28af910beb 100644 --- a/src/com/android/settings/accessibility/HighTextContrastPreferenceController.java +++ b/src/com/android/settings/accessibility/HighTextContrastPreferenceController.java @@ -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)); } diff --git a/tests/robotests/src/com/android/settings/accessibility/HighContrastTextMigrationReceiverTest.java b/tests/robotests/src/com/android/settings/accessibility/HighContrastTextMigrationReceiverTest.java new file mode 100644 index 00000000000..0fedddc7416 --- /dev/null +++ b/tests/robotests/src/com/android/settings/accessibility/HighContrastTextMigrationReceiverTest.java @@ -0,0 +1,241 @@ +/* + * 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); + } +}