diff --git a/Settings/AndroidManifest.xml b/Settings/AndroidManifest.xml index 5c9c5d2..fc08383 100644 --- a/Settings/AndroidManifest.xml +++ b/Settings/AndroidManifest.xml @@ -17,8 +17,8 @@ diff --git a/Settings/res/layout/setup_password_item.xml b/Settings/res/layout/setup_password_item.xml index cbc5bcc..9317a95 100644 --- a/Settings/res/layout/setup_password_item.xml +++ b/Settings/res/layout/setup_password_item.xml @@ -30,14 +30,15 @@ android:paddingEnd="@dimen/setup_list_item_padding" android:paddingStart="@dimen/setup_list_item_padding"> - - + \ No newline at end of file diff --git a/Settings/src/com/android/tv/settings/connectivity/setup/EnterPasswordState.java b/Settings/src/com/android/tv/settings/connectivity/setup/EnterPasswordState.java index 8447067..eb25a1e 100644 --- a/Settings/src/com/android/tv/settings/connectivity/setup/EnterPasswordState.java +++ b/Settings/src/com/android/tv/settings/connectivity/setup/EnterPasswordState.java @@ -21,15 +21,22 @@ import android.content.Context; import android.net.wifi.WifiInfo; import android.os.Bundle; import android.text.InputType; +import android.text.Editable; +import android.text.TextWatcher; import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ImageSpan; +import android.text.method.HideReturnsTransformationMethod; +import android.text.method.PasswordTransformationMethod; +import android.text.method.TransformationMethod; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.view.inputmethod.EditorInfo; -import android.widget.Checkable; +import android.view.inputmethod.InputMethodManager; +import android.widget.CompoundButton; import android.widget.EditText; import android.widget.TextView; import android.widget.Toast; @@ -104,9 +111,21 @@ public class EnterPasswordState implements State { private UserChoiceInfo mUserChoiceInfo; private StateMachine mStateMachine; private EditText mTextInput; - private Checkable mCheckable; + private CompoundButton mCheckable; private GuidedAction mPasswordAction; private boolean mEditFocused = false; + private boolean mEditorWanted; + private View mFragmentView; + private EditText mRestoreInput; + private Runnable mRestoreRunnable; + private int mRestoreGeneration; + private TextWatcher mPasswordWatcher; + private final ViewTreeObserver.OnWindowFocusChangeListener mWindowFocusListener = + hasFocus -> { + if (hasFocus && isResumed() && mEditorWanted) { + restorePasswordEditor(); + } + }; @NonNull @Override @@ -145,19 +164,24 @@ public class EnterPasswordState implements State { if (action.getId() == ACTION_ID_CHECKABLE) { PasswordViewHolder checkableVH = (PasswordViewHolder) vh; mCheckable = checkableVH.mCheckable; - checkableVH.itemView.setOnClickListener(view -> { - mCheckable.setChecked(!mCheckable.isChecked()); - if (mPasswordAction != null) { - setSelectedActionPosition(0); + mCheckable.setOnCheckedChangeListener(null); + mCheckable.setChecked(mUserChoiceInfo.isPasswordHidden()); + final CompoundButton checkable = mCheckable; + checkable.setOnCheckedChangeListener((button, hidden) -> { + if (checkable != mCheckable) { + return; } + mUserChoiceInfo.setPasswordHidden(hidden); + updatePasswordInputObfuscation(); + restorePasswordEditor(); }); - mCheckable.setChecked(mUserChoiceInfo.isPasswordHidden()); + // The child switch and the row share one state-change listener. + checkableVH.itemView.setOnClickListener(view -> checkable.toggle()); + updatePasswordInputObfuscation(); } else if (action.getId() == GuidedAction.ACTION_ID_CONTINUE) { - mTextInput = (EditText) vh.itemView.findViewById( - R.id.guidedactions_item_title); - mTextInput.setImeOptions( - EditorInfo.IME_ACTION_DONE | EditorInfo.IME_FLAG_NO_EXTRACT_UI); - openInEditMode(action); + bindPasswordEditor((EditText) vh.itemView.findViewById( + R.id.guidedactions_item_title)); + restorePasswordEditor(); } } @@ -165,7 +189,18 @@ public class EnterPasswordState implements State { protected void onEditingModeChange( ViewHolder vh, boolean editing, boolean withTransition) { super.onEditingModeChange(vh, editing, withTransition); - updatePasswordInputObfuscation(); + if (vh.getAction().getId() == GuidedAction.ACTION_ID_CONTINUE) { + mEditorWanted = editing; + if (editing && mTextInput != null) { + // Leanback clears touch focusability when binding/leaving edit mode. + // Restore it before its adapter requests focus and opens the IME. + mTextInput.setFocusableInTouchMode(true); + mTextInput.setFocusable(true); + } else if (!editing) { + cancelPasswordEditorRestore(); + } + updatePasswordInputObfuscation(); + } } @Override @@ -198,6 +233,8 @@ public class EnterPasswordState implements State { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = super.onCreateView(inflater, container, savedInstanceState); + mFragmentView = view; + view.getViewTreeObserver().addOnWindowFocusChangeListener(mWindowFocusListener); // Update guidance to contain subtitle with action done icon TextView guidanceDescription = (TextView) view.findViewById(R.id.guidance_description); @@ -232,13 +269,11 @@ public class EnterPasswordState implements State { Bundle savedInstanceState) { Context context = getActivity(); CharSequence prevPassword = mUserChoiceInfo.getPageSummary(UserChoiceInfo.PASSWORD); - boolean isPasswordHidden = mUserChoiceInfo.isPasswordHidden(); + mUserChoiceInfo.setPasswordHidden(true); mPasswordAction = new GuidedAction.Builder(context) .title(prevPassword == null ? "" : prevPassword) - .editInputType(InputType.TYPE_CLASS_TEXT - | (isPasswordHidden - ? InputType.TYPE_TEXT_VARIATION_PASSWORD - : InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) + .inputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + .editInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) .id(GuidedAction.ACTION_ID_CONTINUE) .editable(true) .build(); @@ -252,7 +287,36 @@ public class EnterPasswordState implements State { @Override public void onResume() { super.onResume(); - openInEditMode(mPasswordAction); + restorePasswordEditor(); + } + + @Override + public void onPause() { + mEditorWanted = false; + cancelPasswordEditorRestore(); + super.onPause(); + } + + @Override + public void onDestroyView() { + mEditorWanted = false; + cancelPasswordEditorRestore(); + if (mFragmentView != null && mFragmentView.getViewTreeObserver().isAlive()) { + mFragmentView.getViewTreeObserver().removeOnWindowFocusChangeListener( + mWindowFocusListener); + } + if (mTextInput != null && mPasswordWatcher != null) { + mTextInput.removeTextChangedListener(mPasswordWatcher); + } + if (mCheckable != null) { + mCheckable.setOnCheckedChangeListener(null); + } + mFragmentView = null; + mTextInput = null; + mPasswordWatcher = null; + mCheckable = null; + mEditFocused = false; + super.onDestroyView(); } @Override @@ -264,7 +328,8 @@ public class EnterPasswordState implements State { PSK_MIN_LENGTH : WEP_MIN_LENGTH; if (password.length() >= minPasswordLength) { mUserChoiceInfo.put(UserChoiceInfo.PASSWORD, action.getTitle().toString()); - mUserChoiceInfo.setPasswordHidden(mCheckable.isChecked()); + mEditorWanted = false; + cancelPasswordEditorRestore(); mStateMachine.getListener().onComplete(this, StateMachine.OPTIONS_OR_CONNECT); } else { final Activity activity = getActivity(); @@ -281,23 +346,175 @@ public class EnterPasswordState implements State { public void onGuidedActionFocused(GuidedAction action) { boolean newEditFocused = action == mPasswordAction; if (!mEditFocused && newEditFocused) { - openInEditMode(action); + restorePasswordEditor(); } mEditFocused = newEditFocused; } + private void bindPasswordEditor(EditText input) { + cancelPasswordEditorRestore(); + if (mTextInput != null && mPasswordWatcher != null) { + mTextInput.removeTextChangedListener(mPasswordWatcher); + } + mTextInput = input; + input.setImeOptions(EditorInfo.IME_ACTION_DONE | EditorInfo.IME_FLAG_NO_EXTRACT_UI + | EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING); + input.setFocusableInTouchMode(true); + input.setFocusable(true); + mPasswordWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence text, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence text, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable text) { + if (input == mTextInput && mPasswordAction != null) { + // Keep the action in sync so a Leanback rebind cannot restore stale text. + mPasswordAction.setTitle(text.toString()); + } + } + }; + input.addTextChangedListener(mPasswordWatcher); + updatePasswordInputObfuscation(); + } + + private void cancelPasswordEditorRestore() { + mRestoreGeneration++; + if (mRestoreInput != null && mRestoreRunnable != null) { + mRestoreInput.removeCallbacks(mRestoreRunnable); + } + mRestoreInput = null; + mRestoreRunnable = null; + } + + private void restorePasswordEditor() { + final EditText input = mTextInput; + if (input == null || mPasswordAction == null) { + return; + } + cancelPasswordEditorRestore(); + mEditorWanted = true; + final int generation = mRestoreGeneration; + mRestoreInput = input; + mRestoreRunnable = () -> { + if (generation != mRestoreGeneration || !isAdded() || !isResumed() + || !mEditorWanted || input != mTextInput || !input.isAttachedToWindow()) { + return; + } + mRestoreInput = null; + mRestoreRunnable = null; + if (!input.hasWindowFocus()) { + // The first layout can precede window focus. Retry on the focus callback, + // not a timer that could reopen a keyboard after the user leaves this page. + return; + } + CharSequence text = new SpannableStringBuilder(input.getText()); + int start = input.getSelectionStart(); + int end = input.getSelectionEnd(); + mPasswordAction.setTitle(text); + input.setFocusableInTouchMode(true); + input.setFocusable(true); + openInEditMode(mPasswordAction); + if (!mEditorWanted || !isResumed() || input != mTextInput) { + return; + } + if (!TextUtils.equals(input.getText(), text)) { + input.setText(text); + } + updatePasswordInputObfuscation(); + input.setFocusableInTouchMode(true); + input.setFocusable(true); + input.requestFocusFromTouch(); + restoreSelection(input, start, end); + InputMethodManager manager = (InputMethodManager) input.getContext() + .getSystemService(Context.INPUT_METHOD_SERVICE); + if (manager != null && input.hasFocus() && input.hasWindowFocus()) { + manager.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT); + } + }; + input.post(mRestoreRunnable); + } + + private static final PasswordTransformationMethod ALWAYS_HIDDEN = + new PasswordTransformationMethod() { + @Override + public CharSequence getTransformation(CharSequence source, View view) { + super.getTransformation(source, view); + return new MaskedPassword(source); + } + + @Override + public void onTextChanged(CharSequence text, int start, int before, int count) { + // Hidden means fully hidden, including the last typed character. + } + }; + private void updatePasswordInputObfuscation() { - if (mTextInput != null && mCheckable != null) { - mTextInput.setInputType(InputType.TYPE_CLASS_TEXT - | (mCheckable.isChecked() - ? InputType.TYPE_TEXT_VARIATION_PASSWORD - : InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)); + if (mTextInput == null) { + return; + } + int start = mTextInput.getSelectionStart(); + int end = mTextInput.getSelectionEnd(); + TransformationMethod method = mUserChoiceInfo.isPasswordHidden() + ? ALWAYS_HIDDEN : HideReturnsTransformationMethod.getInstance(); + if (mTextInput.getTransformationMethod() != method) { + // Keep inputType stable: changing it restarts the input connection and can + // race the first touch keyboard. Only the displayed characters change here. + mTextInput.setTransformationMethod(method); } + restoreSelection(mTextInput, start, end); + } + + private static void restoreSelection(EditText input, int start, int end) { + int length = input.length(); + input.setSelection(start < 0 ? length : Math.min(start, length), + end < 0 ? length : Math.min(end, length)); } + private static final class MaskedPassword implements CharSequence { + private final CharSequence mSource; + + MaskedPassword(CharSequence source) { + mSource = source; + } + + @Override + public int length() { + return mSource.length(); + } + + @Override + public char charAt(int index) { + if (index < 0 || index >= length()) { + throw new IndexOutOfBoundsException(); + } + return '\u2022'; + } + + @Override + public CharSequence subSequence(int start, int end) { + if (start < 0 || end > length() || start > end) { + throw new IndexOutOfBoundsException(); + } + StringBuilder masked = new StringBuilder(end - start); + for (int index = start; index < end; index++) { + masked.append('\u2022'); + } + return masked.toString(); + } + + @Override + public String toString() { + return subSequence(0, length()).toString(); + } + } private static class PasswordViewHolder extends GuidedActionsAlignUtil.SetupViewHolder { - Checkable mCheckable; + CompoundButton mCheckable; PasswordViewHolder(View v) { super(v); diff --git a/Settings/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragment.java b/Settings/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragment.java index fcc0cdf..2f036e2 100644 --- a/Settings/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragment.java +++ b/Settings/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragment.java @@ -149,6 +149,9 @@ public class ResolutionSelectionFragment extends PreferenceControllerFragment { Display display = mDisplayManager.getDisplay(Display.DEFAULT_DISPLAY); mAutoMode = display.getSystemPreferredDisplayMode(); + if (mAutoMode == null) { + mAutoMode = display.getMode(); + } final String summary = getResources().getString(R.string.resolution_display_mode, ResolutionSelectionUtils.getResolutionString( mAutoMode.getPhysicalWidth(), mAutoMode.getPhysicalHeight()), diff --git a/Settings/tests/robotests/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragmentTest.java b/Settings/tests/robotests/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragmentTest.java index 5b2042b..ed85644 100644 --- a/Settings/tests/robotests/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragmentTest.java +++ b/Settings/tests/robotests/src/com/android/tv/settings/device/displaysound/ResolutionSelectionFragmentTest.java @@ -181,6 +181,35 @@ public class ResolutionSelectionFragmentTest { assertThat(preference.isChecked()).isFalse(); } + @Test + public void testMissingSystemPreferredMode_usesActiveModeForAutoSummary() { + Display.Mode preferred = new Display.Mode(1, 1920, 1080, 60); + Display.Mode active = new Display.Mode(2, 1280, 720, 60); + ResolutionSelectionFragment fragment = createResolutionSelectionFragmentWith( + new Display.Mode[] {preferred, active}, null, null, active); + RadioPreference autoPreference = fragment.findPreference(KEY_RESOLUTION_SELECTION_AUTO); + + assertThat(autoPreference.isChecked()).isTrue(); + assertThat(autoPreference.getSummary()).isEqualTo(fragment.getContext().getString( + R.string.resolution_display_mode, + ResolutionSelectionUtils.getResolutionString(1280, 720), + ResolutionSelectionUtils.getRefreshRateString(60))); + } + + @Test + public void testAvailableSystemPreferredMode_keepsPreferredAutoSummary() { + Display.Mode preferred = new Display.Mode(1, 1920, 1080, 60); + Display.Mode active = new Display.Mode(2, 1280, 720, 60); + ResolutionSelectionFragment fragment = createResolutionSelectionFragmentWith( + new Display.Mode[] {preferred, active}, null, preferred, active); + RadioPreference autoPreference = fragment.findPreference(KEY_RESOLUTION_SELECTION_AUTO); + + assertThat(autoPreference.getSummary()).isEqualTo(fragment.getContext().getString( + R.string.resolution_display_mode, + ResolutionSelectionUtils.getResolutionString(1920, 1080), + ResolutionSelectionUtils.getRefreshRateString(60))); + } + private ResolutionSelectionFragment createResolutionSelectionFragmentWith( Display.Mode[] supportedModes) { return createResolutionSelectionFragmentWith(supportedModes, null); @@ -188,10 +217,17 @@ public class ResolutionSelectionFragmentTest { private ResolutionSelectionFragment createResolutionSelectionFragmentWith( Display.Mode[] supportedModes, Display.Mode userPreferredMode) { + return createResolutionSelectionFragmentWith(supportedModes, userPreferredMode, + supportedModes[0], supportedModes[0]); + } + + private ResolutionSelectionFragment createResolutionSelectionFragmentWith( + Display.Mode[] supportedModes, Display.Mode userPreferredMode, + Display.Mode systemPreferredMode, Display.Mode activeMode) { Display display = spy(Display.class); doReturn(supportedModes).when(display).getSupportedModes(); - Display.Mode systemPreferredMode = supportedModes[0]; doReturn(systemPreferredMode).when(display).getSystemPreferredDisplayMode(); + doReturn(activeMode).when(display).getMode(); doReturn(display).when(mDisplayManager).getDisplay(Display.DEFAULT_DISPLAY); ResolutionSelectionFragment fragment = spy(ResolutionSelectionFragment.class); diff --git a/Settings/src/com/android/tv/settings/connectivity/util/WifiPasswordEditText.java b/Settings/src/com/android/tv/settings/connectivity/util/WifiPasswordEditText.java new file mode 100644 index 0000000..dadd12a --- /dev/null +++ b/Settings/src/com/android/tv/settings/connectivity/util/WifiPasswordEditText.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 MECE + * + * 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.tv.settings.connectivity.util; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; + +/** Input connection compatibility for the Wi-Fi password editor only. */ +public class WifiPasswordEditText extends SettingsGuidedActionEditText { + public WifiPasswordEditText(Context context) { + super(context); + } + + public WifiPasswordEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public WifiPasswordEditText(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + boolean handled = super.onKeyUp(keyCode, event); + // RecyclerView also treats Home/End key UP as list navigation. The editor + // already moved its cursor on DOWN; keep UP from moving focus to another row. + return keyCode == KeyEvent.KEYCODE_MOVE_HOME || keyCode == KeyEvent.KEYCODE_MOVE_END + || handled; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + InputConnection connection = super.onCreateInputConnection(outAttrs); + if (connection == null) { + return null; + } + return new InputConnectionWrapper(connection, false) { + @Override + public boolean sendKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + boolean softDigit = (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0 + && event.hasNoModifiers() + && keyCode >= KeyEvent.KEYCODE_0 && keyCode <= KeyEvent.KEYCODE_9; + if (softDigit) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + // LatinIME sends digits as asynchronous window key events, unlike + // letters. Commit them to this editor's input connection instead. + char digit = (char) ('0' + keyCode - KeyEvent.KEYCODE_0); + return super.commitText(String.valueOf(digit), 1); + } + if (event.getAction() == KeyEvent.ACTION_UP) { + return true; + } + } + // Preserve physical keys, shortcuts/modifiers, navigation and IME actions. + return super.sendKeyEvent(event); + } + }; + } +}