From 20d1a7d1b66ff4870990758882cea0e89484ab24 Mon Sep 17 00:00:00 2001 From: Yongshun Liu Date: Wed, 12 Feb 2025 10:28:11 +0000 Subject: [PATCH 1/9] a11y: Add cursor following mode dialog This a pure UI change that adds a new magnification cursor following mode dialog behind a flag. The framework support will be added separately later. There are 3 modes as the following: - continuous mode - center mode - edge mode It also renames magnification mode dialog xml file for general purpose within accessibility. NO_IFTTT=linter not working Bug: b/388335935 Flag: com.android.settings.accessibility.enable_magnification_cursor_following_dialog Test: SettingsRoboTests:com.android.settings.accessibility.ToggleScreenMagnificationPreferenceFragmentTest && SettingsRoboTests:com.android.settings.accessibility.MagnificationModePreferenceControllerTest && SettingsRoboTests:com.android.settings.accessibility.MagnificationCursorFollowingModePreferenceControllerTest Change-Id: If2672186faf7443cc210d79630b1ea4f3808d7e4 --- ...er.xml => accessibility_dialog_header.xml} | 2 +- res/values/strings.xml | 10 + .../AccessibilityDialogUtils.java | 5 + ...rsorFollowingModePreferenceController.java | 221 ++++++++++++++++++ ...MagnificationModePreferenceController.java | 8 +- ...ScreenMagnificationPreferenceFragment.java | 55 +++++ ...FollowingModePreferenceControllerTest.java | 200 ++++++++++++++++ ...enMagnificationPreferenceFragmentTest.java | 23 +- 8 files changed, 520 insertions(+), 4 deletions(-) rename res/layout/{accessibility_magnification_mode_header.xml => accessibility_dialog_header.xml} (92%) create mode 100644 src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceController.java create mode 100644 tests/robotests/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceControllerTest.java diff --git a/res/layout/accessibility_magnification_mode_header.xml b/res/layout/accessibility_dialog_header.xml similarity index 92% rename from res/layout/accessibility_magnification_mode_header.xml rename to res/layout/accessibility_dialog_header.xml index e4765535f27..ace8b239d0e 100644 --- a/res/layout/accessibility_magnification_mode_header.xml +++ b/res/layout/accessibility_dialog_header.xml @@ -21,9 +21,9 @@ android:padding="?android:attr/dialogPreferredPadding"> diff --git a/res/values/strings.xml b/res/values/strings.xml index eecbd79a502..1a904ade6c9 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -5217,6 +5217,16 @@ Magnification Magnification shortcut + + Cursor following + + Choose how Magnification follows your cursor. + + Move screen continuously as mouse moves + + Move screen keeping mouse at center of screen + + Move screen when mouse touches edges of screen Magnify typing diff --git a/src/com/android/settings/accessibility/AccessibilityDialogUtils.java b/src/com/android/settings/accessibility/AccessibilityDialogUtils.java index c89b8d7122a..dc4900861a2 100644 --- a/src/com/android/settings/accessibility/AccessibilityDialogUtils.java +++ b/src/com/android/settings/accessibility/AccessibilityDialogUtils.java @@ -104,6 +104,11 @@ public class AccessibilityDialogUtils { * screen / Switch between full and partial screen > Save. */ int DIALOG_MAGNIFICATION_TRIPLE_TAP_WARNING = 1011; + + /** + * OPEN: Settings > Accessibility > Magnification > Cursor following. + */ + int DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE = 1012; } /** diff --git a/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceController.java b/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceController.java new file mode 100644 index 00000000000..d217ead007f --- /dev/null +++ b/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceController.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2025 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 android.app.Dialog; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.content.DialogInterface; +import android.provider.Settings; +import android.provider.Settings.Secure.AccessibilityMagnificationCursorFollowingMode; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.util.Preconditions; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import com.android.settings.DialogCreatable; +import com.android.settings.R; +import com.android.settings.accessibility.AccessibilityDialogUtils.DialogEnums; +import com.android.settings.core.BasePreferenceController; + +import java.util.ArrayList; +import java.util.List; + +/** + * Controller that shows the magnification cursor following mode and the preference click behavior. + */ +public class MagnificationCursorFollowingModePreferenceController extends + BasePreferenceController implements DialogCreatable { + static final String PREF_KEY = + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE; + + private static final String TAG = + MagnificationCursorFollowingModePreferenceController.class.getSimpleName(); + + private final List mModeList = new ArrayList<>(); + @Nullable + private DialogHelper mDialogHelper; + @VisibleForTesting + @Nullable + ListView mModeListView; + @Nullable + private Preference mModePreference; + + public MagnificationCursorFollowingModePreferenceController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + initModeList(); + } + + public void setDialogHelper(@NonNull DialogHelper dialogHelper) { + mDialogHelper = dialogHelper; + } + + private void initModeList() { + mModeList.add(new ModeInfo(mContext.getString( + R.string.accessibility_magnification_cursor_following_continuous), + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS)); + mModeList.add(new ModeInfo( + mContext.getString(R.string.accessibility_magnification_cursor_following_center), + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER)); + mModeList.add(new ModeInfo( + mContext.getString(R.string.accessibility_magnification_cursor_following_edge), + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE)); + } + + @Override + public int getAvailabilityStatus() { + return AVAILABLE; + } + + @NonNull + @Override + public CharSequence getSummary() { + return getCursorFollowingModeSummary(getCurrentMagnificationCursorFollowingMode()); + } + + @Override + public void displayPreference(@NonNull PreferenceScreen screen) { + super.displayPreference(screen); + mModePreference = screen.findPreference(getPreferenceKey()); + } + + @Override + public boolean handlePreferenceTreeClick(@NonNull Preference preference) { + if (!TextUtils.equals(preference.getKey(), getPreferenceKey()) || mModePreference == null) { + return super.handlePreferenceTreeClick(preference); + } + + Preconditions.checkNotNull(mDialogHelper).showDialog( + DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE); + return true; + } + + @NonNull + @Override + public Dialog onCreateDialog(int dialogId) { + Preconditions.checkArgument( + dialogId == DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE, + "This only handles cursor following mode dialog"); + return createMagnificationCursorFollowingModeDialog(); + } + + @Override + public int getDialogMetricsCategory(int dialogId) { + Preconditions.checkArgument( + dialogId == DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE, + "This only handles cursor following mode dialog"); + return SettingsEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING; + } + + @NonNull + private Dialog createMagnificationCursorFollowingModeDialog() { + mModeListView = AccessibilityDialogUtils.createSingleChoiceListView(mContext, mModeList, + /* itemListener= */null); + final View headerView = LayoutInflater.from(mContext).inflate( + R.layout.accessibility_dialog_header, mModeListView, + /* attachToRoot= */false); + final TextView textView = Preconditions.checkNotNull(headerView.findViewById( + R.id.accessibility_dialog_header_text_view)); + textView.setText( + mContext.getString(R.string.accessibility_magnification_cursor_following_header)); + textView.setVisibility(View.VISIBLE); + mModeListView.addHeaderView(headerView, /* data= */null, /* isSelectable= */false); + final int selectionIndex = computeSelectionIndex(); + if (selectionIndex != AdapterView.INVALID_POSITION) { + mModeListView.setItemChecked(selectionIndex, /* value= */true); + } + final CharSequence title = mContext.getString( + R.string.accessibility_magnification_cursor_following_title); + final CharSequence positiveBtnText = mContext.getString(R.string.save); + final CharSequence negativeBtnText = mContext.getString(R.string.cancel); + return AccessibilityDialogUtils.createCustomDialog(mContext, title, mModeListView, + positiveBtnText, + this::onMagnificationCursorFollowingModeDialogPositiveButtonClicked, + negativeBtnText, /* negativeListener= */null); + } + + void onMagnificationCursorFollowingModeDialogPositiveButtonClicked( + DialogInterface dialogInterface, int which) { + ListView listView = Preconditions.checkNotNull(mModeListView); + final int selectionIndex = listView.getCheckedItemPosition(); + if (selectionIndex == AdapterView.INVALID_POSITION) { + Log.w(TAG, "Selected positive button with INVALID_POSITION index"); + return; + } + ModeInfo cursorFollowingMode = (ModeInfo) listView.getItemAtPosition(selectionIndex); + if (cursorFollowingMode != null) { + Preconditions.checkNotNull(mModePreference).setSummary( + getCursorFollowingModeSummary(cursorFollowingMode.mMode)); + Settings.Secure.putInt(mContext.getContentResolver(), PREF_KEY, + cursorFollowingMode.mMode); + } + } + + private int computeSelectionIndex() { + ListView listView = Preconditions.checkNotNull(mModeListView); + @AccessibilityMagnificationCursorFollowingMode + final int currentMode = getCurrentMagnificationCursorFollowingMode(); + for (int i = 0; i < listView.getCount(); i++) { + final ModeInfo mode = (ModeInfo) listView.getItemAtPosition(i); + if (mode != null && mode.mMode == currentMode) { + return i; + } + } + return AdapterView.INVALID_POSITION; + } + + @NonNull + private CharSequence getCursorFollowingModeSummary( + @AccessibilityMagnificationCursorFollowingMode int cursorFollowingMode) { + int stringId = switch (cursorFollowingMode) { + case Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER -> + R.string.accessibility_magnification_cursor_following_center; + case Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE -> + R.string.accessibility_magnification_cursor_following_edge; + default -> + R.string.accessibility_magnification_cursor_following_continuous; + }; + return mContext.getString(stringId); + } + + private @AccessibilityMagnificationCursorFollowingMode int + getCurrentMagnificationCursorFollowingMode() { + return Settings.Secure.getInt(mContext.getContentResolver(), PREF_KEY, + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS); + } + + static class ModeInfo extends ItemInfoArrayAdapter.ItemInfo { + @AccessibilityMagnificationCursorFollowingMode + public final int mMode; + + ModeInfo(@NonNull CharSequence title, + @AccessibilityMagnificationCursorFollowingMode int mode) { + super(title, /* summary= */null, /* drawableId= */null); + mMode = mode; + } + } +} diff --git a/src/com/android/settings/accessibility/MagnificationModePreferenceController.java b/src/com/android/settings/accessibility/MagnificationModePreferenceController.java index 71ea4c23958..93cb23b7e2a 100644 --- a/src/com/android/settings/accessibility/MagnificationModePreferenceController.java +++ b/src/com/android/settings/accessibility/MagnificationModePreferenceController.java @@ -176,8 +176,12 @@ public class MagnificationModePreferenceController extends BasePreferenceControl mContext, mModeInfos, this::onMagnificationModeSelected); final View headerView = LayoutInflater.from(mContext).inflate( - R.layout.accessibility_magnification_mode_header, - getMagnificationModesListView(), /* attachToRoot= */false); + R.layout.accessibility_dialog_header, getMagnificationModesListView(), + /* attachToRoot= */false); + final TextView textView = Preconditions.checkNotNull(headerView.findViewById( + R.id.accessibility_dialog_header_text_view)); + textView.setText( + mContext.getString(R.string.accessibility_magnification_area_settings_message)); getMagnificationModesListView().addHeaderView(headerView, /* data= */null, /* isSelectable= */false); diff --git a/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragment.java b/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragment.java index 8b525079bef..71c95c0c7bf 100644 --- a/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragment.java +++ b/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragment.java @@ -93,6 +93,8 @@ public class ToggleScreenMagnificationPreferenceFragment extends private TouchExplorationStateChangeListener mTouchExplorationStateChangeListener; @Nullable private DialogCreatable mMagnificationModeDialogDelegate; + @Nullable + private DialogCreatable mMagnificationCursorFollowingModeDialogDelegate; @Nullable MagnificationOneFingerPanningPreferenceController mOneFingerPanningPreferenceController; @@ -104,6 +106,12 @@ public class ToggleScreenMagnificationPreferenceFragment extends mMagnificationModeDialogDelegate = delegate; } + @VisibleForTesting + public void setMagnificationCursorFollowingModeDialogDelegate( + @NonNull DialogCreatable delegate) { + mMagnificationCursorFollowingModeDialogDelegate = delegate; + } + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -186,6 +194,9 @@ public class ToggleScreenMagnificationPreferenceFragment extends case DialogEnums.DIALOG_MAGNIFICATION_TRIPLE_TAP_WARNING: return Preconditions.checkNotNull(mMagnificationModeDialogDelegate) .onCreateDialog(dialogId); + case DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE: + return Preconditions.checkNotNull(mMagnificationCursorFollowingModeDialogDelegate) + .onCreateDialog(dialogId); case DialogEnums.GESTURE_NAVIGATION_TUTORIAL: return AccessibilityShortcutsTutorial .showAccessibilityGestureTutorialDialog(getPrefContext()); @@ -201,6 +212,11 @@ public class ToggleScreenMagnificationPreferenceFragment extends PackageManager.FEATURE_WINDOW_MAGNIFICATION); } + private static boolean isMagnificationCursorFollowingModeDialogSupported() { + // TODO(b/398066000): Hide the setting when no pointer device exists for most form factors. + return com.android.settings.accessibility.Flags.enableMagnificationCursorFollowingDialog(); + } + @Override protected void initSettingsPreference() { final PreferenceCategory generalCategory = findPreference(KEY_GENERAL_CATEGORY); @@ -213,6 +229,7 @@ public class ToggleScreenMagnificationPreferenceFragment extends addJoystickSetting(generalCategory); // LINT.ThenChange(:search_data) } + addCursorFollowingSetting(generalCategory); addFeedbackSetting(generalCategory); } @@ -286,6 +303,31 @@ public class ToggleScreenMagnificationPreferenceFragment extends addPreferenceController(magnificationModePreferenceController); } + private static Preference createCursorFollowingPreference(Context context) { + final Preference pref = new Preference(context); + pref.setTitle(R.string.accessibility_magnification_cursor_following_title); + pref.setKey(MagnificationCursorFollowingModePreferenceController.PREF_KEY); + pref.setPersistent(false); + return pref; + } + + private void addCursorFollowingSetting(PreferenceCategory generalCategory) { + if (!isMagnificationCursorFollowingModeDialogSupported()) { + return; + } + + generalCategory.addPreference(createCursorFollowingPreference(getPrefContext())); + + final MagnificationCursorFollowingModePreferenceController controller = + new MagnificationCursorFollowingModePreferenceController( + getContext(), + MagnificationCursorFollowingModePreferenceController.PREF_KEY); + controller.setDialogHelper(/* dialogHelper= */this); + mMagnificationCursorFollowingModeDialogDelegate = controller; + controller.displayPreference(getPreferenceScreen()); + addPreferenceController(controller); + } + private static Preference createFollowTypingPreference(Context context) { final Preference pref = new SwitchPreferenceCompat(context); pref.setTitle(R.string.accessibility_screen_magnification_follow_typing_title); @@ -510,6 +552,9 @@ public class ToggleScreenMagnificationPreferenceFragment extends case DialogEnums.DIALOG_MAGNIFICATION_TRIPLE_TAP_WARNING: return Preconditions.checkNotNull(mMagnificationModeDialogDelegate) .getDialogMetricsCategory(dialogId); + case DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE: + return Preconditions.checkNotNull(mMagnificationCursorFollowingModeDialogDelegate) + .getDialogMetricsCategory(dialogId); case DialogEnums.GESTURE_NAVIGATION_TUTORIAL: return SettingsEnums.DIALOG_TOGGLE_SCREEN_MAGNIFICATION_GESTURE_NAVIGATION; case DialogEnums.ACCESSIBILITY_BUTTON_TUTORIAL: @@ -667,6 +712,11 @@ public class ToggleScreenMagnificationPreferenceFragment extends return rawData; } + // Add all preferences to search raw data so that they are included in + // indexing, which happens infrequently. Irrelevant preferences should be + // hidden from the live returned search results by `getNonIndexableKeys`, + // which is called every time a search occurs. This allows for dynamic search + // entries that hide or show depending on current device state. rawData.add(createShortcutPreferenceSearchData(context)); Stream.of( createMagnificationModePreference(context), @@ -674,6 +724,7 @@ public class ToggleScreenMagnificationPreferenceFragment extends createOneFingerPanningPreference(context), createAlwaysOnPreference(context), createJoystickPreference(context), + createCursorFollowingPreference(context), createFeedbackPreference(context) ) .forEach(pref -> @@ -714,6 +765,10 @@ public class ToggleScreenMagnificationPreferenceFragment extends } } + if (!isMagnificationCursorFollowingModeDialogSupported()) { + niks.add(MagnificationCursorFollowingModePreferenceController.PREF_KEY); + } + if (!Flags.enableLowVisionHats()) { niks.add(MagnificationFeedbackPreferenceController.PREF_KEY); } diff --git a/tests/robotests/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceControllerTest.java new file mode 100644 index 00000000000..42efdfe6784 --- /dev/null +++ b/tests/robotests/src/com/android/settings/accessibility/MagnificationCursorFollowingModePreferenceControllerTest.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 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.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.provider.Settings; +import android.provider.Settings.Secure.AccessibilityMagnificationCursorFollowingMode; +import android.text.TextUtils; +import android.widget.AdapterView; +import android.widget.ListView; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.DialogCreatable; +import com.android.settings.R; +import com.android.settings.accessibility.MagnificationCursorFollowingModePreferenceController.ModeInfo; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; + +/** Tests for {@link MagnificationCursorFollowingModePreferenceController}. */ +@RunWith(RobolectricTestRunner.class) +public class MagnificationCursorFollowingModePreferenceControllerTest { + private static final String PREF_KEY = + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE; + + @Rule + public MockitoRule mocks = MockitoJUnit.rule(); + + @Spy + private TestDialogHelper mDialogHelper = new TestDialogHelper(); + + private PreferenceScreen mScreen; + private Context mContext; + private MagnificationCursorFollowingModePreferenceController mController; + private Preference mModePreference; + + @Before + public void setUp() { + mContext = ApplicationProvider.getApplicationContext(); + mContext.setTheme(androidx.appcompat.R.style.Theme_AppCompat); + final PreferenceManager preferenceManager = new PreferenceManager(mContext); + mScreen = preferenceManager.createPreferenceScreen(mContext); + mModePreference = new Preference(mContext); + mModePreference.setKey(PREF_KEY); + mScreen.addPreference(mModePreference); + mController = new MagnificationCursorFollowingModePreferenceController(mContext, PREF_KEY); + mController.setDialogHelper(mDialogHelper); + mDialogHelper.setDialogDelegate(mController); + showPreferenceOnTheScreen(); + } + + private void showPreferenceOnTheScreen() { + mController.displayPreference(mScreen); + } + + @AccessibilityMagnificationCursorFollowingMode + private int getCheckedModeFromDialog() { + final ListView listView = mController.mModeListView; + assertThat(listView).isNotNull(); + + final int checkedPosition = listView.getCheckedItemPosition(); + assertWithMessage("No mode is checked").that(checkedPosition) + .isNotEqualTo(AdapterView.INVALID_POSITION); + + final ModeInfo modeInfo = (ModeInfo) listView.getAdapter().getItem(checkedPosition); + return modeInfo.mMode; + } + + private void performItemClickWith(@AccessibilityMagnificationCursorFollowingMode int mode) { + final ListView listView = mController.mModeListView; + assertThat(listView).isNotNull(); + + int modeIndex = AdapterView.NO_ID; + for (int i = 0; i < listView.getAdapter().getCount(); i++) { + final ModeInfo modeInfo = (ModeInfo) listView.getAdapter().getItem(i); + if (modeInfo != null && modeInfo.mMode == mode) { + modeIndex = i; + break; + } + } + assertWithMessage("The mode could not be found").that(modeIndex) + .isNotEqualTo(AdapterView.NO_ID); + + listView.performItemClick(listView.getChildAt(modeIndex), modeIndex, modeIndex); + } + + @Test + public void clickPreference_defaultMode_selectionIsDefault() { + mController.handlePreferenceTreeClick(mModePreference); + + assertThat(getCheckedModeFromDialog()).isEqualTo( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS); + } + + @Test + public void clickPreference_nonDefaultMode_selectionIsExpected() { + Settings.Secure.putInt(mContext.getContentResolver(), PREF_KEY, + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER); + + mController.handlePreferenceTreeClick(mModePreference); + + assertThat(getCheckedModeFromDialog()).isEqualTo( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CENTER); + } + + @Test + public void selectItemInDialog_selectionIsExpected() { + mController.handlePreferenceTreeClick(mModePreference); + + performItemClickWith( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE); + + assertThat(getCheckedModeFromDialog()).isEqualTo( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE); + } + + @Test + public void selectItemInDialog_dismissWithoutSave_selectionNotPersists() { + mController.handlePreferenceTreeClick(mModePreference); + + performItemClickWith( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE); + + showPreferenceOnTheScreen(); + + mController.handlePreferenceTreeClick(mModePreference); + + assertThat(getCheckedModeFromDialog()).isEqualTo( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_CONTINUOUS); + assertThat(TextUtils.equals(mController.getSummary(), mContext.getString( + R.string.accessibility_magnification_cursor_following_continuous))).isTrue(); + } + + @Test + public void selectItemInDialog_saveAndDismiss_selectionPersists() { + mController.handlePreferenceTreeClick(mModePreference); + + performItemClickWith( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE); + mController.onMagnificationCursorFollowingModeDialogPositiveButtonClicked( + mDialogHelper.getDialog(), DialogInterface.BUTTON_POSITIVE); + + showPreferenceOnTheScreen(); + + mController.handlePreferenceTreeClick(mModePreference); + + assertThat(getCheckedModeFromDialog()).isEqualTo( + Settings.Secure.ACCESSIBILITY_MAGNIFICATION_CURSOR_FOLLOWING_MODE_EDGE); + assertThat(TextUtils.equals(mController.getSummary(), mContext.getString( + R.string.accessibility_magnification_cursor_following_edge))).isTrue(); + } + + private static class TestDialogHelper implements DialogHelper { + private DialogCreatable mDialogDelegate; + private Dialog mDialog; + + @Override + public void showDialog(int dialogId) { + mDialog = mDialogDelegate.onCreateDialog(dialogId); + } + + public void setDialogDelegate(@NonNull DialogCreatable delegate) { + mDialogDelegate = delegate; + } + + public Dialog getDialog() { + return mDialog; + } + } +} diff --git a/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java b/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java index 3c136f04356..6407c081aea 100644 --- a/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java +++ b/tests/robotests/src/com/android/settings/accessibility/ToggleScreenMagnificationPreferenceFragmentTest.java @@ -613,6 +613,24 @@ public class ToggleScreenMagnificationPreferenceFragmentTest { verify(dialogDelegate).getDialogMetricsCategory(dialogId); } + @Test + @EnableFlags(com.android.settings.accessibility.Flags + .FLAG_ENABLE_MAGNIFICATION_CURSOR_FOLLOWING_DIALOG) + public void onCreateDialog_setCursorFollowingModeDialogDelegate_invokeDialogDelegate() { + ToggleScreenMagnificationPreferenceFragment fragment = + mFragController.create( + R.id.main_content, /* bundle= */ null).start().resume().get(); + final DialogCreatable dialogDelegate = mock(DialogCreatable.class, RETURNS_DEEP_STUBS); + final int dialogId = DialogEnums.DIALOG_MAGNIFICATION_CURSOR_FOLLOWING_MODE; + when(dialogDelegate.getDialogMetricsCategory(anyInt())).thenReturn(dialogId); + fragment.setMagnificationCursorFollowingModeDialogDelegate(dialogDelegate); + + fragment.onCreateDialog(dialogId); + fragment.getDialogMetricsCategory(dialogId); + verify(dialogDelegate).onCreateDialog(dialogId); + verify(dialogDelegate).getDialogMetricsCategory(dialogId); + } + @Test public void getMetricsCategory_returnsCorrectCategory() { ToggleScreenMagnificationPreferenceFragment fragment = @@ -826,6 +844,7 @@ public class ToggleScreenMagnificationPreferenceFragmentTest { MagnificationOneFingerPanningPreferenceController.PREF_KEY, MagnificationAlwaysOnPreferenceController.PREF_KEY, MagnificationJoystickPreferenceController.PREF_KEY, + MagnificationCursorFollowingModePreferenceController.PREF_KEY, MagnificationFeedbackPreferenceController.PREF_KEY); final List rawData = ToggleScreenMagnificationPreferenceFragment @@ -881,7 +900,9 @@ public class ToggleScreenMagnificationPreferenceFragmentTest { @EnableFlags({ com.android.settings.accessibility.Flags.FLAG_FIX_A11Y_SETTINGS_SEARCH, Flags.FLAG_ENABLE_MAGNIFICATION_ONE_FINGER_PANNING_GESTURE, - Flags.FLAG_ENABLE_LOW_VISION_HATS}) + Flags.FLAG_ENABLE_LOW_VISION_HATS, + com.android.settings.accessibility.Flags + .FLAG_ENABLE_MAGNIFICATION_CURSOR_FOLLOWING_DIALOG}) public void getNonIndexableKeys_hasShortcutAndAllFeaturesEnabled_allItemsSearchable() { mShadowAccessibilityManager.setAccessibilityShortcutTargets( TRIPLETAP, List.of(MAGNIFICATION_CONTROLLER_NAME)); From e96d71fc41dc512e353f194d1152e9977def8c0b Mon Sep 17 00:00:00 2001 From: Aleksander Morgado Date: Wed, 5 Mar 2025 00:02:26 +0000 Subject: [PATCH 2/9] Hide 'Reset mobile network settings' if not telephony capable The entry is hidden either via the build-time config_show_sim_info=false boolean flag, or when not declaring the PackageManager.FEATURE_TELEPHONY_DATA feature flag. Bug: 399208058 Test: mm && atest NetworkResetPreferenceControllerTest Flag: EXEMPT bugfix Change-Id: I178a0cae9d4ffd1ea4492a6b1feca697373d6e20 --- .../NetworkResetPreferenceController.java | 6 ++- .../NetworkResetPreferenceControllerTest.java | 51 +++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/com/android/settings/network/NetworkResetPreferenceController.java b/src/com/android/settings/network/NetworkResetPreferenceController.java index af288fa88a5..ef3dca2fcd5 100644 --- a/src/com/android/settings/network/NetworkResetPreferenceController.java +++ b/src/com/android/settings/network/NetworkResetPreferenceController.java @@ -20,6 +20,7 @@ import android.content.Context; import com.android.settings.core.PreferenceControllerMixin; import com.android.settings.network.SubscriptionUtil; +import com.android.settingslib.Utils; import com.android.settingslib.core.AbstractPreferenceController; public class NetworkResetPreferenceController extends AbstractPreferenceController @@ -34,8 +35,9 @@ public class NetworkResetPreferenceController extends AbstractPreferenceControll @Override public boolean isAvailable() { - return (SubscriptionUtil.isSimHardwareVisible(mContext) && - (!mRestrictionChecker.hasUserRestriction())); + return (SubscriptionUtil.isSimHardwareVisible(mContext) + && !Utils.isWifiOnly(mContext) + && !mRestrictionChecker.hasUserRestriction()); } @Override diff --git a/tests/robotests/src/com/android/settings/network/NetworkResetPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/network/NetworkResetPreferenceControllerTest.java index 73f4b6a0b7b..e263ea7e1df 100644 --- a/tests/robotests/src/com/android/settings/network/NetworkResetPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/network/NetworkResetPreferenceControllerTest.java @@ -19,9 +19,16 @@ package com.android.settings.network; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.Context; +import android.content.res.Resources; +import android.telephony.TelephonyManager; + +import com.android.settings.R; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,28 +41,66 @@ import org.robolectric.util.ReflectionHelpers; @RunWith(RobolectricTestRunner.class) public class NetworkResetPreferenceControllerTest { + @Mock + private TelephonyManager mTelephonyManager; @Mock private NetworkResetRestrictionChecker mRestrictionChecker; private NetworkResetPreferenceController mController; + private Context mContext; + private Resources mResources; @Before public void setUp() { MockitoAnnotations.initMocks(this); - mController = new NetworkResetPreferenceController(RuntimeEnvironment.application); + mContext = spy(RuntimeEnvironment.application); + + mResources = spy(mContext.getResources()); + when(mContext.getResources()).thenReturn(mResources); + when(mContext.getSystemService(Context.TELEPHONY_SERVICE)).thenReturn(mTelephonyManager); + + mController = new NetworkResetPreferenceController(mContext); ReflectionHelpers.setField(mController, "mRestrictionChecker", mRestrictionChecker); + + // Availability defaults + when(mTelephonyManager.isDataCapable()).thenReturn(true); + when(mResources.getBoolean(R.bool.config_show_sim_info)).thenReturn(true); + when(mRestrictionChecker.isRestrictionEnforcedByAdmin()).thenReturn(false); } @Test - public void testIsAvailable_shouldReturnTrueWhenNoUserRestriction() { - when(mRestrictionChecker.isRestrictionEnforcedByAdmin()).thenReturn(true); + public void testIsAvailable_showSimInfo_notWifiOnly() { + assertThat(mController.isAvailable()).isTrue(); + } + @Test + public void testIsAvailable_hideSimInfo_notWifiOnly() { + when(mResources.getBoolean(R.bool.config_show_sim_info)).thenReturn(false); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void testIsAvailable_showSimInfo_wifiOnly() { + when(mTelephonyManager.isDataCapable()).thenReturn(false); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void testIsAvailable_userRestriction() { + when(mRestrictionChecker.isRestrictionEnforcedByAdmin()).thenReturn(true); when(mRestrictionChecker.hasUserRestriction()).thenReturn(true); assertThat(mController.isAvailable()).isFalse(); + verify(mRestrictionChecker, never()).isRestrictionEnforcedByAdmin(); + } + + @Test + public void testIsAvailable_noUserRestriction() { + when(mRestrictionChecker.isRestrictionEnforcedByAdmin()).thenReturn(true); when(mRestrictionChecker.hasUserRestriction()).thenReturn(false); assertThat(mController.isAvailable()).isTrue(); + verify(mRestrictionChecker, never()).isRestrictionEnforcedByAdmin(); } } From e853b6b267fe3dd1a0ca77abd50687aac7119903 Mon Sep 17 00:00:00 2001 From: mxyyiyi Date: Wed, 5 Mar 2025 19:19:09 +0800 Subject: [PATCH 3/9] [A11y] Add battery chart slot selected status in content description. - Let talkback annouce the slot status before battery slot info. "Selected": If current slot is selected or after the double-tap action to selected a slot. "Unselected": If current slot is not the seletectd one or after double-tapping an already selected slot(which will selected ALL instead) Fix: 386027256 Test: Talkback Flag: EXEMPT for simple fix Change-Id: I32a665cdf21620aade8397e5bbe4cb44964b0a39 --- res/values/strings.xml | 8 +- .../BatteryChartPreferenceController.java | 87 ++++++++++--------- .../batteryusage/BatteryChartView.java | 35 ++++---- 3 files changed, 71 insertions(+), 59 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index cbe56401774..619326382e8 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -6600,8 +6600,12 @@ %1$s to %2$s %1$s %2$s - - %1$s %2$s + + %1$s, %2$s %3$s + + Selected + + Unselected Battery usage chart diff --git a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceController.java b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceController.java index a248bdff16f..2681067000c 100644 --- a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceController.java +++ b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartPreferenceController.java @@ -16,6 +16,9 @@ package com.android.settings.fuelgauge.batteryusage; +import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.SELECTED_INDEX_ALL; +import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.SELECTED_INDEX_INVALID; + import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.app.settings.SettingsEnums; @@ -82,10 +85,10 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll @VisibleForTesting TextView mChartSummaryTextView; @VisibleForTesting BatteryChartView mDailyChartView; @VisibleForTesting BatteryChartView mHourlyChartView; - @VisibleForTesting int mDailyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; - @VisibleForTesting int mHourlyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; - @VisibleForTesting int mDailyHighlightSlotIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; - @VisibleForTesting int mHourlyHighlightSlotIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; + @VisibleForTesting int mDailyChartIndex = SELECTED_INDEX_ALL; + @VisibleForTesting int mHourlyChartIndex = SELECTED_INDEX_ALL; + @VisibleForTesting int mDailyHighlightSlotIndex = SELECTED_INDEX_INVALID; + @VisibleForTesting int mHourlyHighlightSlotIndex = SELECTED_INDEX_INVALID; private boolean mIs24HourFormat; private View mBatteryChartViewGroup; @@ -198,8 +201,8 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll getTotalHours(batteryLevelData)); if (batteryLevelData == null) { - mDailyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; - mHourlyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; + mDailyChartIndex = SELECTED_INDEX_ALL; + mHourlyChartIndex = SELECTED_INDEX_ALL; mDailyViewModel = null; mHourlyViewModels = null; refreshUi(); @@ -226,9 +229,9 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll } boolean isHighlightSlotFocused() { - return (mDailyHighlightSlotIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID + return (mDailyHighlightSlotIndex != SELECTED_INDEX_INVALID && mDailyHighlightSlotIndex == mDailyChartIndex - && mHourlyHighlightSlotIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID + && mHourlyHighlightSlotIndex != SELECTED_INDEX_INVALID && mHourlyHighlightSlotIndex == mHourlyChartIndex); } @@ -242,8 +245,8 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll } void selectHighlightSlotIndex() { - if (mDailyHighlightSlotIndex == BatteryChartViewModel.SELECTED_INDEX_INVALID - || mHourlyHighlightSlotIndex == BatteryChartViewModel.SELECTED_INDEX_INVALID) { + if (mDailyHighlightSlotIndex == SELECTED_INDEX_INVALID + || mHourlyHighlightSlotIndex == SELECTED_INDEX_INVALID) { return; } if (mDailyHighlightSlotIndex == mDailyChartIndex @@ -258,8 +261,11 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll "onDailyChartSelect:%d, onHourlyChartSelect:%d", mDailyChartIndex, mHourlyChartIndex)); refreshUi(); + // The highlight slot must be selected. mHandler.post( - () -> mDailyChartView.setAccessibilityPaneTitle(getAccessibilityAnnounceMessage())); + () -> + mDailyChartView.setAccessibilityPaneTitle( + getAccessibilityAnnounceMessage(/* isSlotSelected= */ true))); if (mOnSelectedIndexUpdatedListener != null) { mOnSelectedIndexUpdatedListener.onSelectedIndexUpdated(); } @@ -295,15 +301,16 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll } Log.d(TAG, "onDailyChartSelect:" + trapezoidIndex); mDailyChartIndex = trapezoidIndex; - mHourlyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; + mHourlyChartIndex = SELECTED_INDEX_ALL; refreshUi(); mHandler.post( () -> mDailyChartView.setAccessibilityPaneTitle( - getAccessibilityAnnounceMessage())); + getAccessibilityAnnounceMessage( + mDailyChartIndex != SELECTED_INDEX_ALL))); mMetricsFeatureProvider.action( mPrefContext, - trapezoidIndex == BatteryChartViewModel.SELECTED_INDEX_ALL + trapezoidIndex == SELECTED_INDEX_ALL ? SettingsEnums.ACTION_BATTERY_USAGE_DAILY_SHOW_ALL : SettingsEnums.ACTION_BATTERY_USAGE_DAILY_TIME_SLOT, mDailyChartIndex); @@ -314,7 +321,7 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll mHourlyChartView = hourlyChartView; mHourlyChartView.setOnSelectListener( trapezoidIndex -> { - if (mDailyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL) { + if (mDailyChartIndex == SELECTED_INDEX_ALL) { // This will happen when a daily slot and an hour slot are clicked together. return; } @@ -327,10 +334,11 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll mHandler.post( () -> mHourlyChartView.setAccessibilityPaneTitle( - getAccessibilityAnnounceMessage())); + getAccessibilityAnnounceMessage( + mHourlyChartIndex != SELECTED_INDEX_ALL))); mMetricsFeatureProvider.action( mPrefContext, - trapezoidIndex == BatteryChartViewModel.SELECTED_INDEX_ALL + trapezoidIndex == SELECTED_INDEX_ALL ? SettingsEnums.ACTION_BATTERY_USAGE_SHOW_ALL : SettingsEnums.ACTION_BATTERY_USAGE_TIME_SLOT, mHourlyChartIndex); @@ -378,27 +386,27 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll } else { mDailyChartView.setVisibility(View.VISIBLE); if (mDailyChartIndex >= mDailyViewModel.size()) { - mDailyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; + mDailyChartIndex = SELECTED_INDEX_ALL; } mDailyViewModel.setSelectedIndex(mDailyChartIndex); mDailyViewModel.setHighlightSlotIndex(mDailyHighlightSlotIndex); mDailyChartView.setViewModel(mDailyViewModel); } - if (mDailyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL) { + if (mDailyChartIndex == SELECTED_INDEX_ALL) { // Multiple days are selected, hide the hourly chart view. animateBatteryHourlyChartView(/* visible= */ false); } else { animateBatteryHourlyChartView(/* visible= */ true); final BatteryChartViewModel hourlyViewModel = mHourlyViewModels.get(mDailyChartIndex); if (mHourlyChartIndex >= hourlyViewModel.size()) { - mHourlyChartIndex = BatteryChartViewModel.SELECTED_INDEX_ALL; + mHourlyChartIndex = SELECTED_INDEX_ALL; } hourlyViewModel.setSelectedIndex(mHourlyChartIndex); hourlyViewModel.setHighlightSlotIndex( (mDailyChartIndex == mDailyHighlightSlotIndex) ? mHourlyHighlightSlotIndex - : BatteryChartViewModel.SELECTED_INDEX_INVALID); + : SELECTED_INDEX_INVALID); mHourlyChartView.setViewModel(hourlyViewModel); } } @@ -416,7 +424,7 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll isAccessibilityText ? mDailyViewModel.getContentDescription(mDailyChartIndex) : mDailyViewModel.getFullText(mDailyChartIndex); - if (mHourlyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL) { + if (mHourlyChartIndex == SELECTED_INDEX_ALL) { return selectedDayText; } @@ -441,15 +449,19 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll return ""; } - if (mDailyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL - || mHourlyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL) { + if (mDailyChartIndex == SELECTED_INDEX_ALL || mHourlyChartIndex == SELECTED_INDEX_ALL) { return mDailyViewModel.getSlotBatteryLevelText(mDailyChartIndex); } return mHourlyViewModels.get(mDailyChartIndex).getSlotBatteryLevelText(mHourlyChartIndex); } - private String getAccessibilityAnnounceMessage() { + private String getAccessibilityAnnounceMessage(final boolean isSlotSelected) { + final String selectedInformation = + mPrefContext.getString( + isSlotSelected + ? R.string.battery_chart_slot_status_selected + : R.string.battery_chart_slot_status_unselected); final String slotInformation = getSlotInformation(/* isAccessibilityText= */ true); final String slotInformationMessage = slotInformation == null @@ -460,7 +472,8 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll final String batteryLevelPercentageMessage = getBatteryLevelPercentageInfo(); return mPrefContext.getString( - R.string.battery_usage_time_info_and_battery_level, + R.string.battery_usage_status_time_info_and_battery_level, + selectedInformation, slotInformationMessage, batteryLevelPercentageMessage); } @@ -533,9 +546,8 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll } private boolean isAllSelected() { - return (isBatteryLevelDataInOneDay() - || mDailyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL) - && mHourlyChartIndex == BatteryChartViewModel.SELECTED_INDEX_ALL; + return (isBatteryLevelDataInOneDay() || mDailyChartIndex == SELECTED_INDEX_ALL) + && mHourlyChartIndex == SELECTED_INDEX_ALL; } @VisibleForTesting @@ -571,9 +583,7 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll return null; } BatteryDiffData allBatteryDiffData = - batteryUsageData - .get(BatteryChartViewModel.SELECTED_INDEX_ALL) - .get(BatteryChartViewModel.SELECTED_INDEX_ALL); + batteryUsageData.get(SELECTED_INDEX_ALL).get(SELECTED_INDEX_ALL); return allBatteryDiffData == null ? null : allBatteryDiffData.getAppDiffEntryList(); } @@ -613,12 +623,9 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll @Override public String generateSlotBatteryLevelText(List levels, int index) { - final int fromBatteryLevelIndex = - index == BatteryChartViewModel.SELECTED_INDEX_ALL ? 0 : index; + final int fromBatteryLevelIndex = index == SELECTED_INDEX_ALL ? 0 : index; final int toBatteryLevelIndex = - index == BatteryChartViewModel.SELECTED_INDEX_ALL - ? levels.size() - 1 - : index + 1; + index == SELECTED_INDEX_ALL ? levels.size() - 1 : index + 1; return mPrefContext.getString( R.string.battery_level_percentage, generateBatteryLevelText(levels.get(fromBatteryLevelIndex)), @@ -687,9 +694,9 @@ public class BatteryChartPreferenceController extends AbstractPreferenceControll return index == timestamps.size() - 1 ? generateText(timestamps, index) : mContext.getString( - R.string.battery_usage_timestamps_content_description, - generateText(timestamps, index), - generateText(timestamps, index + 1)); + R.string.battery_usage_timestamps_content_description, + generateText(timestamps, index), + generateText(timestamps, index + 1)); } HourlyChartLabelTextGenerator updateSpecialCaseContext( diff --git a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartView.java b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartView.java index eafccdb124e..393d751c0ae 100644 --- a/src/com/android/settings/fuelgauge/batteryusage/BatteryChartView.java +++ b/src/com/android/settings/fuelgauge/batteryusage/BatteryChartView.java @@ -17,6 +17,8 @@ package com.android.settings.fuelgauge.batteryusage; import static com.android.settings.Utils.formatPercentage; import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.AxisLabelPosition.BETWEEN_TRAPEZOIDS; +import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.SELECTED_INDEX_ALL; +import static com.android.settings.fuelgauge.batteryusage.BatteryChartViewModel.SELECTED_INDEX_INVALID; import static com.android.settingslib.fuelgauge.BatteryStatus.BATTERY_LEVEL_UNKNOWN; import static java.lang.Math.abs; @@ -81,7 +83,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick getContext().getResources().getConfiguration().getLayoutDirection(); private BatteryChartViewModel mViewModel; - private int mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; + private int mHoveredIndex = SELECTED_INDEX_INVALID; private int mDividerWidth; private int mDividerHeight; private float mTrapezoidVOffset; @@ -245,9 +247,9 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick // sent here. return true; case MotionEvent.ACTION_HOVER_EXIT: - if (mHoveredIndex != BatteryChartViewModel.SELECTED_INDEX_INVALID) { + if (mHoveredIndex != SELECTED_INDEX_INVALID) { sendAccessibilityEventForHover(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT); - mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset + mHoveredIndex = SELECTED_INDEX_INVALID; // reset invalidate(); } // Ignore the super.onHoverEvent() because the hovered trapezoid has already been @@ -262,7 +264,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick public void onHoverChanged(boolean hovered) { super.onHoverChanged(hovered); if (!hovered) { - mHoveredIndex = BatteryChartViewModel.SELECTED_INDEX_INVALID; // reset + mHoveredIndex = SELECTED_INDEX_INVALID; // reset invalidate(); } } @@ -295,9 +297,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick if (mOnSelectListener != null) { // Selects all if users click the same trapezoid item two times. mOnSelectListener.onSelect( - index == mViewModel.selectedIndex() - ? BatteryChartViewModel.SELECTED_INDEX_ALL - : index); + index == mViewModel.selectedIndex() ? SELECTED_INDEX_ALL : index); } view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); } @@ -332,8 +332,8 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick setBackgroundColor(Color.TRANSPARENT); mTrapezoidSolidColor = Utils.getColorAccentDefaultColor(context); mTrapezoidColor = Utils.getDisabled(context, mTrapezoidSolidColor); - mTrapezoidHoverColor = context.getColor( - com.android.internal.R.color.materialColorSecondaryContainer); + mTrapezoidHoverColor = + context.getColor(com.android.internal.R.color.materialColorSecondaryContainer); // Initializes the divider line paint. final Resources resources = getContext().getResources(); mDividerWidth = resources.getDimensionPixelSize(R.dimen.chartview_divider_width); @@ -623,8 +623,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick // Configures the trapezoid paint color. final int trapezoidColor = (mViewModel.selectedIndex() == index - || mViewModel.selectedIndex() - == BatteryChartViewModel.SELECTED_INDEX_ALL) + || mViewModel.selectedIndex() == SELECTED_INDEX_ALL) ? mTrapezoidSolidColor : mTrapezoidColor; final boolean isHoverState = @@ -659,9 +658,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick } private boolean isHighlightSlotValid() { - return mViewModel != null - && mViewModel.getHighlightSlotIndex() - != BatteryChartViewModel.SELECTED_INDEX_INVALID; + return mViewModel != null && mViewModel.getHighlightSlotIndex() != SELECTED_INDEX_INVALID; } private void drawTransomLine(Canvas canvas) { @@ -715,7 +712,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick // Searches the corresponding trapezoid index from x location. private int getTrapezoidIndex(float x) { if (mTrapezoidSlots == null) { - return BatteryChartViewModel.SELECTED_INDEX_INVALID; + return SELECTED_INDEX_INVALID; } for (int index = 0; index < mTrapezoidSlots.length; index++) { final TrapezoidSlot slot = mTrapezoidSlots[index]; @@ -723,7 +720,7 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick return index; } } - return BatteryChartViewModel.SELECTED_INDEX_INVALID; + return SELECTED_INDEX_INVALID; } private void initializeAxisLabelsBounds() { @@ -796,7 +793,11 @@ public class BatteryChartView extends AppCompatImageView implements View.OnClick childInfo.setText(slotTimeInfo); childInfo.setContentDescription( mContext.getString( - R.string.battery_usage_time_info_and_battery_level, + R.string.battery_usage_status_time_info_and_battery_level, + mContext.getString( + mViewModel.selectedIndex() == virtualViewId + ? R.string.battery_chart_slot_status_selected + : R.string.battery_chart_slot_status_unselected), slotTimeInfo, batteryLevelInfo)); childInfo.setAccessibilityFocused(virtualViewId == mAccessibilityFocusNodeViewId); From 61c41c9ef344b4b1b254160e074fe5ad6380ef3d Mon Sep 17 00:00:00 2001 From: chelseahao Date: Thu, 6 Mar 2025 14:51:58 +0800 Subject: [PATCH 4/9] [Audiosharing] Disable flag in test. Test: atest Flag: com.android.settingslib.flags.enable_temporary_bond_devices_ui Bug: 400879282 Change-Id: I0f1aeef5419afc0a23564f726e67d36563a91333 --- .../settings/bluetooth/BluetoothDevicePreferenceTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePreferenceTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePreferenceTest.java index 6a72c7d2e0c..d318e061656 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePreferenceTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePreferenceTest.java @@ -34,6 +34,7 @@ import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Looper; import android.os.UserManager; +import android.platform.test.annotations.DisableFlags; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; import android.util.Pair; @@ -175,6 +176,7 @@ public class BluetoothDevicePreferenceTest { } @Test + @DisableFlags(Flags.FLAG_ENABLE_TEMPORARY_BOND_DEVICES_UI) public void onClicked_deviceNotBonded_shouldLogBluetoothPairEvent() { when(mCachedBluetoothDevice.isConnected()).thenReturn(false); when(mCachedBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE); @@ -192,6 +194,7 @@ public class BluetoothDevicePreferenceTest { } @Test + @DisableFlags(Flags.FLAG_ENABLE_TEMPORARY_BOND_DEVICES_UI) public void onClicked_deviceNotBonded_shouldLogBluetoothPairEventAndPairWithoutNameEvent() { when(mCachedBluetoothDevice.isConnected()).thenReturn(false); when(mCachedBluetoothDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE); From 7f70f5b6335382c0f6f811a34ff2583384683662 Mon Sep 17 00:00:00 2001 From: Candice Date: Tue, 25 Feb 2025 07:20:31 +0000 Subject: [PATCH 5/9] Add a content description for the illustration if it is an animation Since `isAnimatable` is decided in `onBindViewHolder`, we need to register an onBindListener and update the content description if the current illustration is an animation. For the static images, we would like to skip them in Talkback since they are decorative images. Bug: 395882764 Test: manually. attach screenshot to the bug Test: atest ToggleFeaturePreferenceFragmentTest flag: EXEMPT. bugfix Change-Id: I57bf96d0891ba553ef29d25ae9489f34b2a832e9 --- res/values/strings.xml | 2 + .../ToggleFeaturePreferenceFragment.java | 42 +++++++++++++----- .../ToggleFeaturePreferenceFragmentTest.java | 43 +++++++++++++++++++ 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 003cd9bdc2a..24839ffb082 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -14293,4 +14293,6 @@ Data usage charges may apply. Forgot PIN Web content filters + + %1$s animation diff --git a/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java b/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java index 66c32df1798..d8c39856368 100644 --- a/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java +++ b/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragment.java @@ -68,6 +68,7 @@ import com.android.settings.dashboard.DashboardFragment; import com.android.settings.flags.Flags; import com.android.settings.widget.SettingsMainSwitchBar; import com.android.settings.widget.SettingsMainSwitchPreference; +import com.android.settingslib.utils.ThreadUtils; import com.android.settingslib.widget.IllustrationPreference; import com.android.settingslib.widget.TopIntroPreference; @@ -311,6 +312,11 @@ public abstract class ToggleFeaturePreferenceFragment extends DashboardFragment return getString(R.string.accessibility_shortcut_title, mFeatureName); } + @VisibleForTesting + CharSequence getContentDescriptionForAnimatedIllustration() { + return getString(R.string.accessibility_illustration_content_description, mFeatureName); + } + protected void onPreferenceToggled(String preferenceKey, boolean enabled) { } @@ -427,22 +433,38 @@ public abstract class ToggleFeaturePreferenceFragment extends DashboardFragment return drawable; } - private void initAnimatedImagePreference() { - if (mImageUri == null) { + initAnimatedImagePreference(mImageUri, new IllustrationPreference(getPrefContext())); + } + + @VisibleForTesting + void initAnimatedImagePreference( + @Nullable Uri imageUri, + @NonNull IllustrationPreference preference) { + if (imageUri == null) { return; } final int displayHalfHeight = AccessibilityUtil.getDisplayBounds(getPrefContext()).height() / 2; - final IllustrationPreference illustrationPreference = - new IllustrationPreference(getPrefContext()); - illustrationPreference.setImageUri(mImageUri); - illustrationPreference.setSelectable(false); - illustrationPreference.setMaxHeight(displayHalfHeight); - illustrationPreference.setKey(KEY_ANIMATED_IMAGE); - - getPreferenceScreen().addPreference(illustrationPreference); + preference.setImageUri(imageUri); + preference.setSelectable(false); + preference.setMaxHeight(displayHalfHeight); + preference.setKey(KEY_ANIMATED_IMAGE); + preference.setOnBindListener(view -> { + // isAnimatable is decided in + // {@link IllustrationPreference#onBindViewHolder(PreferenceViewHolder)}. Therefore, we + // wait until the view is bond to set the content description for it. + // The content description is added for an animation illustration only. Since the static + // images are decorative. + ThreadUtils.getUiThreadHandler().post(() -> { + if (preference.isAnimatable()) { + preference.setContentDescription( + getContentDescriptionForAnimatedIllustration()); + } + }); + }); + getPreferenceScreen().addPreference(preference); } @VisibleForTesting diff --git a/tests/robotests/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragmentTest.java b/tests/robotests/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragmentTest.java index 571075cba31..f72b591353a 100644 --- a/tests/robotests/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragmentTest.java +++ b/tests/robotests/src/com/android/settings/accessibility/ToggleFeaturePreferenceFragmentTest.java @@ -39,6 +39,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.icu.text.CaseMap; +import android.net.Uri; import android.os.Bundle; import android.platform.test.annotations.DisableFlags; import android.platform.test.annotations.EnableFlags; @@ -56,12 +57,14 @@ import androidx.fragment.app.FragmentActivity; import androidx.preference.Preference; import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; +import androidx.preference.PreferenceViewHolder; import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; import com.android.settings.flags.Flags; import com.android.settings.testutils.shadow.ShadowAccessibilityManager; import com.android.settings.testutils.shadow.ShadowFragment; +import com.android.settingslib.widget.IllustrationPreference; import com.android.settingslib.widget.TopIntroPreference; import com.google.android.setupcompat.util.WizardManagerHelper; @@ -79,6 +82,7 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowApplication; +import org.robolectric.shadows.ShadowLooper; import java.util.List; import java.util.Locale; @@ -315,6 +319,45 @@ public class ToggleFeaturePreferenceFragmentTest { assertThat(mFragment.getPreferenceScreen().getPreferenceCount()).isEqualTo(0); } + @Test + public void initAnimatedImagePreference_isAnimatable_setContentDescription() { + mFragment.mFeatureName = "Test Feature"; + final View view = + LayoutInflater.from(mContext).inflate( + com.android.settingslib.widget.preference.illustration + .R.layout.illustration_preference, + null); + IllustrationPreference preference = spy(new IllustrationPreference(mFragment.getContext())); + when(preference.isAnimatable()).thenReturn(true); + mFragment.initAnimatedImagePreference(mock(Uri.class), preference); + + preference.onBindViewHolder(PreferenceViewHolder.createInstanceForTests(view)); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + String expectedContentDescription = mFragment.getString( + R.string.accessibility_illustration_content_description, mFragment.mFeatureName); + assertThat(preference.getContentDescription().toString()) + .isEqualTo(expectedContentDescription); + } + + @Test + public void initAnimatedImagePreference_isNotAnimatable_notSetContentDescription() { + mFragment.mFeatureName = "Test Feature"; + final View view = + LayoutInflater.from(mContext).inflate( + com.android.settingslib.widget.preference.illustration + .R.layout.illustration_preference, + null); + IllustrationPreference preference = spy(new IllustrationPreference(mFragment.getContext())); + when(preference.isAnimatable()).thenReturn(false); + mFragment.initAnimatedImagePreference(mock(Uri.class), preference); + + preference.onBindViewHolder(PreferenceViewHolder.createInstanceForTests(view)); + ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); + + verify(preference, never()).setContentDescription(any()); + } + @Test @EnableFlags(Flags.FLAG_ACCESSIBILITY_SHOW_APP_INFO_BUTTON) public void createAppInfoPreference_withValidComponentName() { From ada92d5d6ca340927f67fa9760d0a25f99fc635c Mon Sep 17 00:00:00 2001 From: chelseahao Date: Thu, 27 Feb 2025 11:57:02 +0800 Subject: [PATCH 6/9] Small clean up to get ready for receive state based triggering. 1. Maintain a map from device to its source state as they could be different. 2. Ensure thread safety. Test: atest Bug: 398700619 Flag: com.android.settingslib.flags.audio_stream_media_service_by_receive_state Change-Id: I056c80401c12401c78037eb15bed007eb592c12a --- .../audiostreams/AudioStreamMediaService.java | 270 +++++++++--------- .../AudioStreamMediaServiceTest.java | 46 +-- 2 files changed, 148 insertions(+), 168 deletions(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java index a1bb84c1daa..ec8d7bcc206 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaService.java @@ -34,7 +34,10 @@ import android.media.MediaMetadata; import android.media.session.MediaSession; import android.media.session.PlaybackState; import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; +import android.os.Process; import android.util.Log; import android.view.KeyEvent; @@ -51,24 +54,21 @@ import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant.LocalBluetoothLeBroadcastSourceState; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.bluetooth.VolumeControlProfile; import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.utils.ThreadUtils; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.HashMap; +import java.util.Map; public class AudioStreamMediaService extends Service { static final String BROADCAST_ID = "audio_stream_media_service_broadcast_id"; static final String BROADCAST_TITLE = "audio_stream_media_service_broadcast_title"; static final String DEVICES = "audio_stream_media_service_devices"; private static final String TAG = "AudioStreamMediaService"; - private static final int NOTIFICATION_ID = 1; + private static final int NOTIFICATION_ID = R.string.audio_streams_title; private static final int BROADCAST_LISTENING_NOW_TEXT = R.string.audio_streams_listening_now; private static final int BROADCAST_STREAM_PAUSED_TEXT = R.string.audio_streams_present_now; @VisibleForTesting static final String LEAVE_BROADCAST_ACTION = "leave_broadcast_action"; @@ -113,17 +113,16 @@ public class AudioStreamMediaService extends Service { private final MetricsFeatureProvider mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); - private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); - private final AtomicBoolean mIsMuted = new AtomicBoolean(false); - private final AtomicBoolean mIsHysteresis = new AtomicBoolean(false); + private final HandlerThread mHandlerThread = new HandlerThread(TAG, + Process.THREAD_PRIORITY_BACKGROUND); + private boolean mIsMuted = false; // Set 25 as default as the volume range from `VolumeControlProfile` is from 0 to 255. // If the initial volume from `onDeviceVolumeChanged` is larger than zero (not muted), we will // override this value. Otherwise, we raise the volume to 25 when the play button is clicked. - private final AtomicInteger mLatestPositiveVolume = new AtomicInteger(25); - private final Object mLocalSessionLock = new Object(); + private int mLatestPositiveVolume = 25; private boolean mHysteresisModeFixAvailable; private int mBroadcastId; - @Nullable private List mDevices; + @Nullable private Map mStateByDevice; @Nullable private LocalBluetoothManager mLocalBtManager; @Nullable private AudioStreamsHelper mAudioStreamsHelper; @Nullable private LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant; @@ -154,7 +153,6 @@ public class AudioStreamMediaService extends Service { Log.w(TAG, "onCreate() : mLeBroadcastAssistant is null!"); return; } - mHysteresisModeFixAvailable = BluetoothUtils.isAudioSharingHysteresisModeFixAvailable(this); mNotificationManager = getSystemService(NotificationManager.class); if (mNotificationManager == null) { @@ -162,7 +160,8 @@ public class AudioStreamMediaService extends Service { return; } - mExecutor.execute( + mHandlerThread.start(); + getHandler().post( () -> { if (mLocalBtManager == null || mLeBroadcastAssistant == null @@ -184,45 +183,49 @@ public class AudioStreamMediaService extends Service { mVolumeControl = mLocalBtManager.getProfileManager().getVolumeControlProfile(); if (mVolumeControl != null) { mVolumeControlCallback = new VolumeControlCallback(); - mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback); + mVolumeControl.registerCallback(getHandler()::post, mVolumeControlCallback); } mBroadcastAssistantCallback = new AssistantCallback(); mLeBroadcastAssistant.registerServiceCallBack( - mExecutor, mBroadcastAssistantCallback); + getHandler()::post, mBroadcastAssistantCallback); + + mHysteresisModeFixAvailable = + BluetoothUtils.isAudioSharingHysteresisModeFixAvailable(this); }); } + @VisibleForTesting + Handler getHandler() { + return mHandlerThread.getThreadHandler(); + } + @Override public void onDestroy() { Log.d(TAG, "onDestroy()"); - super.onDestroy(); - if (BluetoothUtils.isAudioSharingUIAvailable(this)) { - if (mDevices != null) { - mDevices.clear(); - mDevices = null; - } - synchronized (mLocalSessionLock) { - if (mLocalSession != null) { - mLocalSession.release(); - mLocalSession = null; - } - } - mExecutor.execute( - () -> { - if (mLocalBtManager != null) { - mLocalBtManager.getEventManager().unregisterCallback( - mBluetoothCallback); - } - if (mLeBroadcastAssistant != null && mBroadcastAssistantCallback != null) { - mLeBroadcastAssistant.unregisterServiceCallBack( - mBroadcastAssistantCallback); - } - if (mVolumeControl != null && mVolumeControlCallback != null) { - mVolumeControl.unregisterCallback(mVolumeControlCallback); - } - }); - } + getHandler().post( + () -> { + if (mStateByDevice != null) { + mStateByDevice.clear(); + mStateByDevice = null; + } + if (mLocalSession != null) { + mLocalSession.release(); + mLocalSession = null; + } + if (mLocalBtManager != null) { + mLocalBtManager.getEventManager().unregisterCallback( + mBluetoothCallback); + } + if (mLeBroadcastAssistant != null && mBroadcastAssistantCallback != null) { + mLeBroadcastAssistant.unregisterServiceCallBack( + mBroadcastAssistantCallback); + } + if (mVolumeControl != null && mVolumeControlCallback != null) { + mVolumeControl.unregisterCallback(mVolumeControlCallback); + } + }); + mHandlerThread.quitSafely(); } @Override @@ -233,53 +236,59 @@ public class AudioStreamMediaService extends Service { stopSelf(); return START_NOT_STICKY; } - mBroadcastId = intent.getIntExtra(BROADCAST_ID, -1); - if (mBroadcastId == -1) { - Log.w(TAG, "Invalid broadcast ID. Service will not start."); - stopSelf(); - return START_NOT_STICKY; - } - var extra = intent.getParcelableArrayListExtra(DEVICES, BluetoothDevice.class); - if (extra == null || extra.isEmpty()) { - Log.w(TAG, "No device. Service will not start."); - stopSelf(); - return START_NOT_STICKY; - } - mDevices = Collections.synchronizedList(extra); - MediaSession.Token token = - getOrCreateLocalMediaSession(intent.getStringExtra(BROADCAST_TITLE)); - startForeground(NOTIFICATION_ID, buildNotification(token)); + getHandler().post(() -> { + mBroadcastId = intent.getIntExtra(BROADCAST_ID, -1); + if (mBroadcastId == -1) { + Log.w(TAG, "Invalid broadcast ID. Service will not start."); + stopSelf(); + return; + } + var devices = intent.getParcelableArrayListExtra(DEVICES, BluetoothDevice.class); + if (devices == null || devices.isEmpty()) { + Log.w(TAG, "No device. Service will not start."); + stopSelf(); + } else { + mStateByDevice = new HashMap<>(); + devices.forEach(d -> mStateByDevice.put(d, STREAMING)); + MediaSession.Token token = + getOrCreateLocalMediaSession(intent.getStringExtra(BROADCAST_TITLE)); + startForeground(NOTIFICATION_ID, buildNotification(token)); + } + }); return START_NOT_STICKY; } private MediaSession.Token getOrCreateLocalMediaSession(String title) { - synchronized (mLocalSessionLock) { - if (mLocalSession != null) { - return mLocalSession.getSessionToken(); - } - mLocalSession = new MediaSession(this, TAG); - mLocalSession.setMetadata( - new MediaMetadata.Builder() - .putString(MediaMetadata.METADATA_KEY_TITLE, title) - .putLong(MediaMetadata.METADATA_KEY_DURATION, STATIC_PLAYBACK_DURATION) - .build()); - mLocalSession.setActive(true); - mLocalSession.setPlaybackState(getPlaybackState()); - mMediaSessionCallback = new MediaSessionCallback(); - mLocalSession.setCallback(mMediaSessionCallback); + if (mLocalSession != null) { return mLocalSession.getSessionToken(); } + mLocalSession = new MediaSession(this, TAG); + mLocalSession.setMetadata( + new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putLong(MediaMetadata.METADATA_KEY_DURATION, STATIC_PLAYBACK_DURATION) + .build()); + mLocalSession.setActive(true); + mLocalSession.setPlaybackState(getPlaybackState()); + mMediaSessionCallback = new MediaSessionCallback(); + mLocalSession.setCallback(mMediaSessionCallback, getHandler()); + return mLocalSession.getSessionToken(); } private PlaybackState getPlaybackState() { - if (mIsHysteresis.get()) { + if (isAllDeviceHysteresis()) { return mPlayStateHysteresisBuilder.build(); } - return mIsMuted.get() ? mPlayStatePausingBuilder.build() : mPlayStatePlayingBuilder.build(); + return mIsMuted ? mPlayStatePausingBuilder.build() : mPlayStatePlayingBuilder.build(); + } + + private boolean isAllDeviceHysteresis() { + return mHysteresisModeFixAvailable && mStateByDevice != null + && mStateByDevice.values().stream().allMatch(v -> v == PAUSED); } private String getDeviceName() { - if (mDevices == null || mDevices.isEmpty() || mLocalBtManager == null) { + if (mStateByDevice == null || mStateByDevice.isEmpty() || mLocalBtManager == null) { return DEFAULT_DEVICE_NAME; } @@ -288,7 +297,8 @@ public class AudioStreamMediaService extends Service { return DEFAULT_DEVICE_NAME; } - CachedBluetoothDevice device = manager.findDevice(mDevices.get(0)); + CachedBluetoothDevice device = manager.findDevice( + mStateByDevice.keySet().iterator().next()); return device != null ? device.getName() : DEFAULT_DEVICE_NAME; } @@ -304,7 +314,7 @@ public class AudioStreamMediaService extends Service { .setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing) .setStyle(mediaStyle) .setContentText(getString( - mIsHysteresis.get() ? BROADCAST_STREAM_PAUSED_TEXT : + isAllDeviceHysteresis() ? BROADCAST_STREAM_PAUSED_TEXT : BROADCAST_LISTENING_NOW_TEXT)) .setSilent(true); return notificationBuilder.build(); @@ -333,7 +343,8 @@ public class AudioStreamMediaService extends Service { public void onReceiveStateChanged( BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) { super.onReceiveStateChanged(sink, sourceId, state); - if (!mHysteresisModeFixAvailable || mDevices == null || !mDevices.contains(sink)) { + if (!mHysteresisModeFixAvailable || mStateByDevice == null + || !mStateByDevice.containsKey(sink)) { return; } var sourceState = LocalBluetoothLeBroadcastAssistant.getLocalSourceState(state); @@ -343,12 +354,10 @@ public class AudioStreamMediaService extends Service { if (!streaming && !paused) { return; } - // Atomically update mIsHysteresis if its current value is not the current paused state - if (mIsHysteresis.compareAndSet(!paused, paused)) { - synchronized (mLocalSessionLock) { - if (mLocalSession == null) { - return; - } + boolean shouldUpdate = mStateByDevice.get(sink) != sourceState; + if (shouldUpdate) { + mStateByDevice.put(sink, sourceState); + if (mLocalSession != null) { mLocalSession.setPlaybackState(getPlaybackState()); if (mNotificationManager != null) { mNotificationManager.notify( @@ -356,7 +365,7 @@ public class AudioStreamMediaService extends Service { buildNotification(mLocalSession.getSessionToken()) ); } - Log.d(TAG, "updating hysteresis mode to : " + paused); + Log.d(TAG, "updating source state to : " + sourceState); } } } @@ -374,24 +383,22 @@ public class AudioStreamMediaService extends Service { @Override public void onDeviceVolumeChanged( @NonNull BluetoothDevice device, @IntRange(from = -255, to = 255) int volume) { - if (mDevices == null || mDevices.isEmpty()) { + if (mStateByDevice == null || mStateByDevice.isEmpty()) { Log.w(TAG, "active device or device has source is null!"); return; } Log.d( TAG, "onDeviceVolumeChanged() bluetoothDevice : " + device + " volume: " + volume); - if (mDevices.contains(device)) { + if (mStateByDevice.containsKey(device)) { if (volume == 0) { - mIsMuted.set(true); + mIsMuted = true; } else { - mIsMuted.set(false); - mLatestPositiveVolume.set(volume); + mIsMuted = false; + mLatestPositiveVolume = volume; } - synchronized (mLocalSessionLock) { - if (mLocalSession != null) { - mLocalSession.setPlaybackState(getPlaybackState()); - } + if (mLocalSession != null) { + mLocalSession.setPlaybackState(getPlaybackState()); } } } @@ -400,10 +407,12 @@ public class AudioStreamMediaService extends Service { private class BtCallback implements BluetoothCallback { @Override public void onBluetoothStateChanged(int bluetoothState) { - if (BluetoothAdapter.STATE_OFF == bluetoothState) { - Log.d(TAG, "onBluetoothStateChanged() : stopSelf"); - stopSelf(); - } + getHandler().post(() -> { + if (BluetoothAdapter.STATE_OFF == bluetoothState) { + Log.d(TAG, "onBluetoothStateChanged() : stopSelf"); + stopSelf(); + } + }); } @Override @@ -411,24 +420,17 @@ public class AudioStreamMediaService extends Service { @NonNull CachedBluetoothDevice cachedDevice, @ConnectionState int state, int bluetoothProfile) { - if (state == BluetoothAdapter.STATE_DISCONNECTED - && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT - && mDevices != null) { - mDevices.remove(cachedDevice.getDevice()); - cachedDevice - .getMemberDevice() - .forEach( - m -> { - // Check nullability to pass NullAway check - if (mDevices != null) { - mDevices.remove(m.getDevice()); - } - }); - } - if (mDevices == null || mDevices.isEmpty()) { - Log.d(TAG, "onProfileConnectionStateChanged() : stopSelf"); - stopSelf(); - } + getHandler().post(() -> { + if (state == BluetoothAdapter.STATE_DISCONNECTED + && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT + && mStateByDevice != null) { + mStateByDevice.remove(cachedDevice.getDevice()); + } + if (mStateByDevice == null || mStateByDevice.isEmpty()) { + Log.d(TAG, "onProfileConnectionStateChanged() : stopSelf"); + stopSelf(); + } + }); } } @@ -454,10 +456,8 @@ public class AudioStreamMediaService extends Service { @Override public void onSeekTo(long pos) { Log.d(TAG, "onSeekTo: " + pos); - synchronized (mLocalSessionLock) { - if (mLocalSession != null) { - mLocalSession.setPlaybackState(getPlaybackState()); - } + if (mLocalSession != null) { + mLocalSession.setPlaybackState(getPlaybackState()); } } @@ -484,28 +484,26 @@ public class AudioStreamMediaService extends Service { } private void handleOnPlay() { - if (mDevices == null || mDevices.isEmpty()) { + if (mStateByDevice == null || mStateByDevice.isEmpty()) { Log.w(TAG, "active device or device has source is null!"); return; } - Log.d( - TAG, - "onPlay() setting volume for device : " - + mDevices.getFirst() - + " volume: " - + mLatestPositiveVolume.get()); - setDeviceVolume(mDevices.getFirst(), mLatestPositiveVolume.get()); + mStateByDevice.keySet().forEach(device -> { + Log.d(TAG, "onPlay() setting volume for device : " + device + " volume: " + + mLatestPositiveVolume); + setDeviceVolume(device, mLatestPositiveVolume); + }); } private void handleOnPause() { - if (mDevices == null || mDevices.isEmpty()) { + if (mStateByDevice == null || mStateByDevice.isEmpty()) { Log.w(TAG, "active device or device has source is null!"); return; } - Log.d( - TAG, - "onPause() setting volume for device : " + mDevices.getFirst() + " volume: " + 0); - setDeviceVolume(mDevices.getFirst(), /* volume= */ 0); + mStateByDevice.keySet().forEach(device -> { + Log.d(TAG, "onPause() setting volume for device : " + device + " volume: " + 0); + setDeviceVolume(device, /* volume= */ 0); + }); } private void setDeviceVolume(BluetoothDevice device, int volume) { @@ -514,7 +512,7 @@ public class AudioStreamMediaService extends Service { ThreadUtils.postOnBackgroundThread( () -> { if (mVolumeControl != null) { - mVolumeControl.setDeviceVolume(device, volume, true); + mVolumeControl.setDeviceVolume(device, volume, false); mMetricsFeatureProvider.action( getApplicationContext(), event, volume == 0 ? 1 : 0); } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaServiceTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaServiceTest.java index a0e971b1d45..c82c9787ccd 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaServiceTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamMediaServiceTest.java @@ -28,7 +28,6 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -52,7 +51,9 @@ import android.media.session.ISession; import android.media.session.ISessionController; import android.media.session.MediaSessionManager; import android.os.Bundle; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.os.RemoteException; import android.platform.test.flag.junit.SetFlagsRule; import android.util.DisplayMetrics; @@ -81,14 +82,12 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.util.concurrent.InlineExecutorService; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; import org.robolectric.util.ReflectionHelpers; import java.util.ArrayList; import java.util.List; -import java.util.Set; @RunWith(RobolectricTestRunner.class) @Config( @@ -122,6 +121,7 @@ public class AudioStreamMediaServiceTest { @Mock private PackageManager mPackageManager; @Mock private DisplayMetrics mDisplayMetrics; @Mock private Context mContext; + @Mock private Handler mHandler; private FakeFeatureFactory mFeatureFactory; private AudioStreamMediaService mAudioStreamMediaService; @@ -145,11 +145,18 @@ public class AudioStreamMediaServiceTest { when(mCachedBluetoothDevice.getName()).thenReturn(DEVICE_NAME); when(mLocalBluetoothProfileManager.getVolumeControlProfile()) .thenReturn(mVolumeControlProfile); - - mAudioStreamMediaService = spy(new AudioStreamMediaService()); + when(mHandler.post(any(Runnable.class))).thenAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }); + when(mHandler.getLooper()).thenReturn(Looper.getMainLooper()); + mAudioStreamMediaService = spy(new AudioStreamMediaService() { + @Override + Handler getHandler() { + return mHandler; + } + }); ReflectionHelpers.setField(mAudioStreamMediaService, "mBase", mContext); - ReflectionHelpers.setField( - mAudioStreamMediaService, "mExecutor", new InlineExecutorService()); when(mAudioStreamMediaService.getSystemService(anyString())) .thenReturn(mMediaSessionManager); when(mMediaSessionManager.createSession(any(), anyString(), any())).thenReturn(mISession); @@ -391,31 +398,6 @@ public class AudioStreamMediaServiceTest { verify(mAudioStreamMediaService).stopSelf(); } - @Test - public void bluetoothCallback_onMemberDeviceDisconnect_stopSelf() { - mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - when(mCachedBluetoothDevice.getDevice()).thenReturn(mock(BluetoothDevice.class)); - CachedBluetoothDevice member = mock(CachedBluetoothDevice.class); - when(mCachedBluetoothDevice.getMemberDevice()).thenReturn(Set.of(member)); - when(member.getDevice()).thenReturn(mDevice); - var devices = new ArrayList(); - devices.add(mDevice); - - Intent intent = new Intent(); - intent.putExtra(BROADCAST_ID, 1); - intent.putParcelableArrayListExtra(DEVICES, devices); - - mAudioStreamMediaService.onCreate(); - assertThat(mAudioStreamMediaService.mBluetoothCallback).isNotNull(); - mAudioStreamMediaService.onStartCommand(intent, /* flags= */ 0, /* startId= */ 0); - mAudioStreamMediaService.mBluetoothCallback.onProfileConnectionStateChanged( - mCachedBluetoothDevice, - BluetoothAdapter.STATE_DISCONNECTED, - BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); - - verify(mAudioStreamMediaService).stopSelf(); - } - @Test public void mediaSessionCallback_onPause_setVolume() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); From 3cdc1a58a7f8240e58425c4b5e6812bb3a89f25b Mon Sep 17 00:00:00 2001 From: Weng Su Date: Thu, 6 Mar 2025 15:41:42 +0800 Subject: [PATCH 7/9] Fixed accessibility issues in VPN Settings - Show "(required)" and errors in required fields to alert users - Show "(optional)" below each optional field Bug: 386025633 Flag: EXEMPT bugfix Test: Manual testing atest WifiConfigController2Test Change-Id: Iefbd68e6658af7b073db219b3e04e94805092759 --- res/layout/vpn_dialog.xml | 16 ++- res/values/strings.xml | 6 + .../android/settings/vpn2/ConfigDialog.java | 119 +++++++++++------- .../settings/vpn2/ConfigDialogFragment.java | 1 + .../settings/wifi/WifiConfigController2.java | 7 +- src/com/android/settings/wifi/WifiDialog.java | 5 +- .../settings/wifi/utils/SsidInputGroup.kt | 34 ----- .../settings/wifi/utils/TextInputGroup.kt | 52 +++++--- .../settings/wifi/utils/WifiDialogHelper.kt | 2 +- 9 files changed, 137 insertions(+), 105 deletions(-) delete mode 100644 src/com/android/settings/wifi/utils/SsidInputGroup.kt diff --git a/res/layout/vpn_dialog.xml b/res/layout/vpn_dialog.xml index fadd2025f14..f0e7b836c64 100644 --- a/res/layout/vpn_dialog.xml +++ b/res/layout/vpn_dialog.xml @@ -53,6 +53,8 @@ android:id="@+id/name_layout" android:hint="@string/vpn_name" app:endIconMode="clear_text" + app:helperTextEnabled="true" + app:helperText="@string/vpn_required" app:errorEnabled="true"> generic error. [CHAR LIMIT=120] --> The information entered doesn\'t support always-on VPN + + (optional) + + (required) + + The field is required Cancel diff --git a/src/com/android/settings/vpn2/ConfigDialog.java b/src/com/android/settings/vpn2/ConfigDialog.java index 1c001cb8bab..8dbcf94b023 100644 --- a/src/com/android/settings/vpn2/ConfigDialog.java +++ b/src/com/android/settings/vpn2/ConfigDialog.java @@ -40,6 +40,7 @@ import com.android.internal.net.VpnProfile; import com.android.net.module.util.ProxyUtils; import com.android.settings.R; import com.android.settings.utils.AndroidKeystoreAliasLoader; +import com.android.settings.wifi.utils.TextInputGroup; import java.util.Collection; import java.util.List; @@ -70,16 +71,17 @@ class ConfigDialog extends AlertDialog implements TextWatcher, private View mView; - private TextView mName; + private TextInputGroup mNameInput; private Spinner mType; - private TextView mServer; - private TextView mUsername; + private TextInputGroup mServerInput; + private TextInputGroup mUsernameInput; + private TextInputGroup mPasswordInput; private TextView mPassword; private Spinner mProxySettings; private TextView mProxyHost; private TextView mProxyPort; - private TextView mIpsecIdentifier; - private TextView mIpsecSecret; + private TextInputGroup mIpsecIdentifierInput; + private TextInputGroup mIpsecSecretInput; private Spinner mIpsecUserCert; private Spinner mIpsecCaCert; private Spinner mIpsecServerCert; @@ -106,16 +108,22 @@ class ConfigDialog extends AlertDialog implements TextWatcher, Context context = getContext(); // First, find out all the fields. - mName = (TextView) mView.findViewById(R.id.name); + mNameInput = new TextInputGroup(mView, R.id.name_layout, R.id.name, + R.string.vpn_field_required); mType = (Spinner) mView.findViewById(R.id.type); - mServer = (TextView) mView.findViewById(R.id.server); - mUsername = (TextView) mView.findViewById(R.id.username); - mPassword = (TextView) mView.findViewById(R.id.password); + mServerInput = new TextInputGroup(mView, R.id.server_layout, R.id.server, + R.string.vpn_field_required); + mUsernameInput = new TextInputGroup(mView, R.id.username_layout, R.id.username, + R.string.vpn_field_required); + mPasswordInput = new TextInputGroup(mView, R.id.password_layout, R.id.password, + R.string.vpn_field_required); mProxySettings = (Spinner) mView.findViewById(R.id.vpn_proxy_settings); mProxyHost = (TextView) mView.findViewById(R.id.vpn_proxy_host); mProxyPort = (TextView) mView.findViewById(R.id.vpn_proxy_port); - mIpsecIdentifier = (TextView) mView.findViewById(R.id.ipsec_identifier); - mIpsecSecret = (TextView) mView.findViewById(R.id.ipsec_secret); + mIpsecIdentifierInput = new TextInputGroup(mView, R.id.ipsec_identifier_layout, + R.id.ipsec_identifier, R.string.vpn_field_required); + mIpsecSecretInput = new TextInputGroup(mView, R.id.ipsec_secret_layout, R.id.ipsec_secret, + R.string.vpn_field_required); mIpsecUserCert = (Spinner) mView.findViewById(R.id.ipsec_user_cert); mIpsecCaCert = (Spinner) mView.findViewById(R.id.ipsec_ca_cert); mIpsecServerCert = (Spinner) mView.findViewById(R.id.ipsec_server_cert); @@ -125,21 +133,21 @@ class ConfigDialog extends AlertDialog implements TextWatcher, mAlwaysOnInvalidReason = (TextView) mView.findViewById(R.id.always_on_invalid_reason); // Second, copy values from the profile. - mName.setText(mProfile.name); + mNameInput.setText(mProfile.name); setTypesByFeature(mType); mType.setSelection(convertVpnProfileConstantToTypeIndex(mProfile.type)); - mServer.setText(mProfile.server); + mServerInput.setText(mProfile.server); if (mProfile.saveLogin) { - mUsername.setText(mProfile.username); - mPassword.setText(mProfile.password); + mUsernameInput.setText(mProfile.username); + mPasswordInput.setText(mProfile.password); } if (mProfile.proxy != null) { mProxyHost.setText(mProfile.proxy.getHost()); int port = mProfile.proxy.getPort(); mProxyPort.setText(port == 0 ? "" : Integer.toString(port)); } - mIpsecIdentifier.setText(mProfile.ipsecIdentifier); - mIpsecSecret.setText(mProfile.ipsecSecret); + mIpsecIdentifierInput.setText(mProfile.ipsecIdentifier); + mIpsecSecretInput.setText(mProfile.ipsecSecret); final AndroidKeystoreAliasLoader androidKeystoreAliasLoader = new AndroidKeystoreAliasLoader(null); loadCertificates(mIpsecUserCert, androidKeystoreAliasLoader.getKeyCertAliases(), 0, @@ -150,7 +158,8 @@ class ConfigDialog extends AlertDialog implements TextWatcher, R.string.vpn_no_server_cert, mProfile.ipsecServerCert); mSaveLogin.setChecked(mProfile.saveLogin); mAlwaysOnVpn.setChecked(mProfile.key.equals(VpnUtils.getLockdownVpn())); - mPassword.setTextAppearance(android.R.style.TextAppearance_DeviceDefault_Medium); + mPasswordInput.getEditText() + .setTextAppearance(android.R.style.TextAppearance_DeviceDefault_Medium); // Hide lockdown VPN on devices that require IMS authentication if (SystemProperties.getBoolean("persist.radio.imsregrequired", false)) { @@ -158,16 +167,16 @@ class ConfigDialog extends AlertDialog implements TextWatcher, } // Third, add listeners to required fields. - mName.addTextChangedListener(this); + mNameInput.addTextChangedListener(this); mType.setOnItemSelectedListener(this); - mServer.addTextChangedListener(this); - mUsername.addTextChangedListener(this); - mPassword.addTextChangedListener(this); + mServerInput.addTextChangedListener(this); + mUsernameInput.addTextChangedListener(this); + mPasswordInput.addTextChangedListener(this); mProxySettings.setOnItemSelectedListener(this); mProxyHost.addTextChangedListener(this); mProxyPort.addTextChangedListener(this); - mIpsecIdentifier.addTextChangedListener(this); - mIpsecSecret.addTextChangedListener(this); + mIpsecIdentifierInput.addTextChangedListener(this); + mIpsecSecretInput.addTextChangedListener(this); mIpsecUserCert.setOnItemSelectedListener(this); mShowOptions.setOnClickListener(this); mAlwaysOnVpn.setOnCheckedChangeListener(this); @@ -202,6 +211,8 @@ class ConfigDialog extends AlertDialog implements TextWatcher, setTitle(context.getString(R.string.vpn_connect_to, mProfile.name)); setUsernamePasswordVisibility(mProfile.type); + mUsernameInput.setHelperText(context.getString(R.string.vpn_required)); + mPasswordInput.setHelperText(context.getString(R.string.vpn_required)); // Create a button to connect the network. setButton(DialogInterface.BUTTON_POSITIVE, @@ -260,6 +271,10 @@ class ConfigDialog extends AlertDialog implements TextWatcher, updateProxyFieldsVisibility(position); } updateUiControls(); + mNameInput.setError(""); + mServerInput.setError(""); + mIpsecIdentifierInput.setError(""); + mIpsecSecretInput.setError(""); } @Override @@ -375,30 +390,16 @@ class ConfigDialog extends AlertDialog implements TextWatcher, return false; } - final int position = mType.getSelectedItemPosition(); - final int type = VPN_TYPES.get(position); - if (!editing && requiresUsernamePassword(type)) { - return mUsername.getText().length() != 0 && mPassword.getText().length() != 0; - } - if (mName.getText().length() == 0 || mServer.getText().length() == 0) { - return false; - } - - // All IKEv2 methods require an identifier - if (mIpsecIdentifier.getText().length() == 0) { - return false; - } - if (!validateProxy()) { return false; } - switch (type) { + switch (getVpnType()) { case VpnProfile.TYPE_IKEV2_IPSEC_USER_PASS: return true; case VpnProfile.TYPE_IKEV2_IPSEC_PSK: - return mIpsecSecret.getText().length() != 0; + return true; case VpnProfile.TYPE_IKEV2_IPSEC_RSA: return mIpsecUserCert.getSelectedItemPosition() != 0; @@ -406,6 +407,29 @@ class ConfigDialog extends AlertDialog implements TextWatcher, return false; } + public boolean validate() { + boolean isValidate = true; + int type = getVpnType(); + if (!mEditing && requiresUsernamePassword(type)) { + if (!mUsernameInput.validate()) isValidate = false; + if (!mPasswordInput.validate()) isValidate = false; + return isValidate; + } + + if (!mNameInput.validate()) isValidate = false; + if (!mServerInput.validate()) isValidate = false; + if (!mIpsecIdentifierInput.validate()) isValidate = false; + if (type == VpnProfile.TYPE_IKEV2_IPSEC_PSK && !mIpsecSecretInput.validate()) { + isValidate = false; + } + if (!isValidate) Log.w(TAG, "Failed to validate VPN profile!"); + return isValidate; + } + + private int getVpnType() { + return VPN_TYPES.get(mType.getSelectedItemPosition()); + } + private void setTypesByFeature(Spinner typeSpinner) { String[] types = getContext().getResources().getStringArray(R.array.vpn_types); if (types.length != VPN_TYPES.size()) { @@ -487,15 +511,14 @@ class ConfigDialog extends AlertDialog implements TextWatcher, VpnProfile getProfile() { // First, save common fields. VpnProfile profile = new VpnProfile(mProfile.key); - profile.name = mName.getText().toString(); - final int position = mType.getSelectedItemPosition(); - profile.type = VPN_TYPES.get(position); - profile.server = mServer.getText().toString().trim(); - profile.username = mUsername.getText().toString(); - profile.password = mPassword.getText().toString(); + profile.name = mNameInput.getText(); + profile.type = getVpnType(); + profile.server = mServerInput.getText().trim(); + profile.username = mUsernameInput.getText(); + profile.password = mPasswordInput.getText(); // Save fields based on VPN type. - profile.ipsecIdentifier = mIpsecIdentifier.getText().toString(); + profile.ipsecIdentifier = mIpsecIdentifierInput.getText(); if (hasProxy()) { String proxyHost = mProxyHost.getText().toString().trim(); @@ -517,7 +540,7 @@ class ConfigDialog extends AlertDialog implements TextWatcher, // Then, save type-specific fields. switch (profile.type) { case VpnProfile.TYPE_IKEV2_IPSEC_PSK: - profile.ipsecSecret = mIpsecSecret.getText().toString(); + profile.ipsecSecret = mIpsecSecretInput.getText(); break; case VpnProfile.TYPE_IKEV2_IPSEC_RSA: diff --git a/src/com/android/settings/vpn2/ConfigDialogFragment.java b/src/com/android/settings/vpn2/ConfigDialogFragment.java index 559003aa4c0..6bffef7c6d5 100644 --- a/src/com/android/settings/vpn2/ConfigDialogFragment.java +++ b/src/com/android/settings/vpn2/ConfigDialogFragment.java @@ -124,6 +124,7 @@ public class ConfigDialogFragment extends InstrumentedDialogFragment implements VpnProfile profile = dialog.getProfile(); if (button == DialogInterface.BUTTON_POSITIVE) { + if (!dialog.validate()) return; // Possibly throw up a dialog to explain lockdown VPN. final boolean shouldLockdown = dialog.isVpnAlwaysOn(); final boolean shouldConnect = shouldLockdown || !dialog.isEditing(); diff --git a/src/com/android/settings/wifi/WifiConfigController2.java b/src/com/android/settings/wifi/WifiConfigController2.java index 1bf1102dde1..a080fc8c5bc 100644 --- a/src/com/android/settings/wifi/WifiConfigController2.java +++ b/src/com/android/settings/wifi/WifiConfigController2.java @@ -77,7 +77,7 @@ import com.android.settings.utils.AndroidKeystoreAliasLoader; import com.android.settings.wifi.details2.WifiPrivacyPreferenceController; import com.android.settings.wifi.details2.WifiPrivacyPreferenceController2; import com.android.settings.wifi.dpp.WifiDppUtils; -import com.android.settings.wifi.utils.SsidInputGroup; +import com.android.settings.wifi.utils.TextInputGroup; import com.android.settingslib.Utils; import com.android.settingslib.utils.ThreadUtils; import com.android.wifi.flags.Flags; @@ -229,7 +229,7 @@ public class WifiConfigController2 implements TextWatcher, private final boolean mHideMeteredAndPrivacy; private final WifiManager mWifiManager; private final AndroidKeystoreAliasLoader mAndroidKeystoreAliasLoader; - private SsidInputGroup mSsidInputGroup; + private TextInputGroup mSsidInputGroup; private final Context mContext; @@ -299,7 +299,8 @@ public class WifiConfigController2 implements TextWatcher, wepWarningLayout.setVisibility(View.VISIBLE); } - mSsidInputGroup = new SsidInputGroup(mContext, mView, R.id.ssid_layout, R.id.ssid); + mSsidInputGroup = new TextInputGroup(mView, R.id.ssid_layout, R.id.ssid, + R.string.wifi_ssid_hint); mSsidScanButton = (ImageButton) mView.findViewById(R.id.ssid_scanner_button); mIpSettingsSpinner = (Spinner) mView.findViewById(R.id.ip_settings); mIpSettingsSpinner.setOnItemSelectedListener(this); diff --git a/src/com/android/settings/wifi/WifiDialog.java b/src/com/android/settings/wifi/WifiDialog.java index 40d22e60fa2..38c99b6a759 100644 --- a/src/com/android/settings/wifi/WifiDialog.java +++ b/src/com/android/settings/wifi/WifiDialog.java @@ -28,7 +28,7 @@ import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import com.android.settings.R; -import com.android.settings.wifi.utils.SsidInputGroup; +import com.android.settings.wifi.utils.TextInputGroup; import com.android.settings.wifi.utils.WifiDialogHelper; import com.android.settingslib.RestrictedLockUtils; import com.android.settingslib.RestrictedLockUtilsInternal; @@ -120,7 +120,8 @@ public class WifiDialog extends AlertDialog implements WifiConfigUiBase, } mDialogHelper = new WifiDialogHelper(this, - new SsidInputGroup(getContext(), mView, R.id.ssid_layout, R.id.ssid)); + new TextInputGroup(mView, R.id.ssid_layout, R.id.ssid, + R.string.vpn_field_required)); } @SuppressWarnings("MissingSuperCall") // TODO: Fix me diff --git a/src/com/android/settings/wifi/utils/SsidInputGroup.kt b/src/com/android/settings/wifi/utils/SsidInputGroup.kt deleted file mode 100644 index 5d8f8d418e3..00000000000 --- a/src/com/android/settings/wifi/utils/SsidInputGroup.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2025 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.wifi.utils - -import android.content.Context -import android.view.View -import com.android.settings.R - -/** TextInputGroup for Wi-Fi SSID. */ -class SsidInputGroup(private val context: Context, view: View, layoutId: Int, editTextId: Int) : - TextInputGroup(view, layoutId, editTextId) { - - fun validate(): Boolean { - if (getText().isEmpty()) { - setError(context.getString(R.string.wifi_ssid_hint)) - return false - } - return true - } -} diff --git a/src/com/android/settings/wifi/utils/TextInputGroup.kt b/src/com/android/settings/wifi/utils/TextInputGroup.kt index 8006dad3bc4..53c80ffb241 100644 --- a/src/com/android/settings/wifi/utils/TextInputGroup.kt +++ b/src/com/android/settings/wifi/utils/TextInputGroup.kt @@ -18,6 +18,7 @@ package com.android.settings.wifi.utils import android.text.Editable import android.text.TextWatcher +import android.util.Log import android.view.View import android.widget.EditText import com.google.android.material.textfield.TextInputLayout @@ -27,13 +28,17 @@ open class TextInputGroup( private val view: View, private val layoutId: Int, private val editTextId: Int, + private val errorMessageId: Int, ) { - private val View.layout: TextInputLayout? - get() = findViewById(layoutId) + val layout: TextInputLayout + get() = view.requireViewById(layoutId) - private val View.editText: EditText? - get() = findViewById(editTextId) + val editText: EditText + get() = view.requireViewById(editTextId) + + val errorMessage: String + get() = view.context.getString(errorMessageId) private val textWatcher = object : TextWatcher { @@ -42,7 +47,7 @@ open class TextInputGroup( override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { - view.layout?.isErrorEnabled = false + layout.isErrorEnabled = false } } @@ -51,18 +56,37 @@ open class TextInputGroup( } fun addTextChangedListener(watcher: TextWatcher) { - view.editText?.addTextChangedListener(watcher) + editText.addTextChangedListener(watcher) } - fun getText(): String { - return view.editText?.text?.toString() ?: "" + var text: String + get() = editText.text?.toString() ?: "" + set(value) { + editText.setText(value) + } + + var helperText: String + get() = layout.helperText?.toString() ?: "" + set(value) { + layout.setHelperText(value) + } + + var error: String + get() = layout.error?.toString() ?: "" + set(value) { + layout.setError(value) + } + + open fun validate(): Boolean { + val isValid = text.isNotEmpty() + if (!isValid) { + Log.w(TAG, "validate failed in ${layout.hint ?: "unknown"}") + error = errorMessage.toString() + } + return isValid } - fun setText(text: String) { - view.editText?.setText(text) - } - - fun setError(errorMessage: String?) { - view.layout?.apply { error = errorMessage } + companion object { + const val TAG = "TextInputGroup" } } diff --git a/src/com/android/settings/wifi/utils/WifiDialogHelper.kt b/src/com/android/settings/wifi/utils/WifiDialogHelper.kt index 3b23b1a7e50..aa41b969a6e 100644 --- a/src/com/android/settings/wifi/utils/WifiDialogHelper.kt +++ b/src/com/android/settings/wifi/utils/WifiDialogHelper.kt @@ -21,7 +21,7 @@ import androidx.appcompat.app.AlertDialog class WifiDialogHelper( alertDialog: AlertDialog, - private val ssidInputGroup: SsidInputGroup? = null, + private val ssidInputGroup: TextInputGroup? = null, ) : AlertDialogHelper(alertDialog) { override fun canDismiss(): Boolean { From cf8c81fddf5a04c8e8dfe7a519b8884add6ff135 Mon Sep 17 00:00:00 2001 From: Pragya Bajoria Date: Thu, 6 Mar 2025 01:34:11 -0800 Subject: [PATCH 8/9] Update string name to "desktop windowing" from "Desktop view" for developer options. Bug: 401166274 Flag: EXEMPT (string change) Change-Id: Ia99113d697ee42a1b3723fbc23a4392677468365 --- res/values/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 0f51dddc4e7..c7bdcee7136 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -12938,12 +12938,12 @@ Data usage charges may apply. Enable freeform windows - + Enable desktop experience features - Enable Desktop View on the device and on secondary displays. - - Enable Desktop View on secondary displays. + Enable desktop windowing on the device and on secondary displays. + + Enable desktop windowing on secondary displays. Enable freeform windows on secondary display From 761920b4a1726b782edf270d249e9ffd3910015a Mon Sep 17 00:00:00 2001 From: Pierre Barbier de Reuille Date: Wed, 5 Mar 2025 23:57:29 +0000 Subject: [PATCH 9/9] Update external display settings to use DesktopExperienceFlags Done to keep the injector and allow the same testing as before. Flag: EXEMPT (update flag infra) Test: build, run, use developer option Fix: 401058586 Change-Id: Ie841f5a86d41f573e888cccec46d9c3dc700e561 --- .../display/DesktopExperienceFlags.kt | 41 +++++++++++++++++++ .../ExternalDisplaySettingsConfiguration.java | 3 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/com/android/settings/connecteddevice/display/DesktopExperienceFlags.kt diff --git a/src/com/android/settings/connecteddevice/display/DesktopExperienceFlags.kt b/src/com/android/settings/connecteddevice/display/DesktopExperienceFlags.kt new file mode 100644 index 00000000000..c6ce00d285d --- /dev/null +++ b/src/com/android/settings/connecteddevice/display/DesktopExperienceFlags.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 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.connecteddevice.display + +import android.window.DesktopExperienceFlags.DesktopExperienceFlag +import com.android.settings.flags.FeatureFlags + +/** Class handling Settings flags, but using the Desktop Experience developer option overrides. */ +class DesktopExperienceFlags(private val featureFlagsImpl: FeatureFlags) : FeatureFlags by featureFlagsImpl { + + private val displayTopologyPaneInDisplayListFlag = + DesktopExperienceFlag( + featureFlagsImpl::displayTopologyPaneInDisplayList, + /* shouldOverrideByDevOption= */ true, + ) + + override fun displayTopologyPaneInDisplayList(): Boolean = + displayTopologyPaneInDisplayListFlag.isTrue + + private val displaySizeConnectedDisplaySettingFlag = + DesktopExperienceFlag( + featureFlagsImpl::displaySizeConnectedDisplaySetting, + /* shouldOverrideByDevOption= */ true, + ) + + override fun displaySizeConnectedDisplaySetting(): Boolean = + displaySizeConnectedDisplaySettingFlag.isTrue +} \ No newline at end of file diff --git a/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java b/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java index 1148aa59704..52ec8d27c63 100644 --- a/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java +++ b/src/com/android/settings/connecteddevice/display/ExternalDisplaySettingsConfiguration.java @@ -109,7 +109,8 @@ public class ExternalDisplaySettingsConfiguration { private final Handler mHandler; Injector(@Nullable Context context) { - this(context, new FeatureFlagsImpl(), new Handler(Looper.getMainLooper())); + this(context, new DesktopExperienceFlags(new FeatureFlagsImpl()), + new Handler(Looper.getMainLooper())); } Injector(@Nullable Context context, @NonNull FeatureFlags flags, @NonNull Handler handler) {