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

Commit a9fc062f authored by Raff Tsai's avatar Raff Tsai
Browse files

Fix cannot switch between multiple TTS engines

- We didn't reset reset radio button state when user click back
while dialog was popped. Change the UI, when user really accepts the
dialog content, then changes radio button state.
- Migrate TtsEnginePreferenceFragment to RadioButtonPickerFragment

Fixes: 135588938
Test: manual
Change-Id: Ia824e08d59c77a23e6590ff0f5b7d897a73a1ff8
parent 5f5ac0b3
Loading
Loading
Loading
Loading
+0 −2
Original line number Diff line number Diff line
@@ -18,6 +18,4 @@
        android:key="tts_engine_picker_screen"
        android:title="@string/tts_engine_preference_title">

    <PreferenceCategory android:key="tts_engine_preference_category"/>

</PreferenceScreen>
+0 −186
Original line number Diff line number Diff line
/*
 * Copyright (C) 2011 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.tts;

import android.content.Context;
import android.content.DialogInterface;
import android.speech.tts.TextToSpeech.EngineInfo;
import android.util.Log;
import android.widget.Checkable;
import android.widget.CompoundButton;
import android.widget.RadioButton;

import androidx.appcompat.app.AlertDialog;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;

import com.android.settings.R;

import androidx.annotation.VisibleForTesting;


public class TtsEnginePreference extends Preference {

    private static final String TAG = "TtsEnginePreference";

    /**
     * The engine information for the engine this preference represents.
     * Contains it's name, label etc. which are used for display.
     */
    private final EngineInfo mEngineInfo;

    /**
     * The shared radio button state, which button is checked etc.
     */
    private final RadioButtonGroupState mSharedState;
    private RadioButton mRadioButton;

    /**
     * When true, the change callbacks on the radio button will not
     * fire.
     */
    private volatile boolean mPreventRadioButtonCallbacks;

    private final CompoundButton.OnCheckedChangeListener mRadioChangeListener =
            new CompoundButton.OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                    onRadioButtonClicked(buttonView, isChecked);
                }
            };

    public TtsEnginePreference(Context context, EngineInfo info, RadioButtonGroupState state) {
        super(context);

        setWidgetLayoutResource(R.layout.preference_widget_radiobutton);
        setLayoutResource(R.layout.preference_radio);
        setIconSpaceReserved(false);

        mSharedState = state;
        mEngineInfo = info;
        mPreventRadioButtonCallbacks = false;

        setKey(mEngineInfo.name);
        setTitle(mEngineInfo.label);
    }

    @Override
    public void onBindViewHolder(PreferenceViewHolder view) {
        super.onBindViewHolder(view);

        if (mSharedState == null) {
            throw new IllegalStateException("Call to getView() before a call to" +
                    "setSharedState()");
        }

        final RadioButton rb = view.itemView.findViewById(android.R.id.checkbox);
        rb.setOnCheckedChangeListener(mRadioChangeListener);

        boolean isChecked = getKey().equals(mSharedState.getCurrentKey());
        if (isChecked) {
            mSharedState.setCurrentChecked(rb);
        }

        mPreventRadioButtonCallbacks = true;
        rb.setChecked(isChecked);
        mPreventRadioButtonCallbacks = false;
        mRadioButton = rb;
    }

    @Override
    public void onClick() {
        mRadioButton.setChecked(true);
    }

    private boolean shouldDisplayDataAlert() {
        return !mEngineInfo.system;
    }


    private void displayDataAlert(
            DialogInterface.OnClickListener positiveOnClickListener,
            DialogInterface.OnClickListener negativeOnClickListener) {
        Log.i(TAG, "Displaying data alert for :" + mEngineInfo.name);

        AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
        builder.setTitle(android.R.string.dialog_alert_title)
                .setMessage(getContext().getString(
                        R.string.tts_engine_security_warning, mEngineInfo.label))
                .setCancelable(true)
                .setPositiveButton(android.R.string.ok, positiveOnClickListener)
                .setNegativeButton(android.R.string.cancel, negativeOnClickListener);

        AlertDialog dialog = builder.create();
        dialog.show();
    }


    private void onRadioButtonClicked(final CompoundButton buttonView,
            boolean isChecked) {
        if (mPreventRadioButtonCallbacks ||
                (mSharedState.getCurrentChecked() == buttonView)) {
            return;
        }

        if (isChecked) {
            // Should we alert user? if that's true, delay making engine current one.
            if (shouldDisplayDataAlert()) {
                displayDataAlert(new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        makeCurrentEngine(buttonView);
                    }
                }, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        // Undo the click.
                        buttonView.setChecked(false);
                    }
                });
            } else {
                // Privileged engine, set it current
                makeCurrentEngine(buttonView);
            }
        }
    }

    private void makeCurrentEngine(Checkable current) {
        if (mSharedState.getCurrentChecked() != null) {
            mSharedState.getCurrentChecked().setChecked(false);
        }
        mSharedState.setCurrentChecked(current);
        mSharedState.setCurrentKey(getKey());
        callChangeListener(mSharedState.getCurrentKey());
    }


    /**
     * Holds all state that is common to this group of radio buttons, such
     * as the currently selected key and the currently checked compound button.
     * (which corresponds to this key).
     */
    public interface RadioButtonGroupState {
        String getCurrentKey();

        Checkable getCurrentChecked();

        void setCurrentKey(String key);

        void setCurrentChecked(Checkable current);
    }

}
+125 −87
Original line number Diff line number Diff line
@@ -4,70 +4,64 @@ import static android.provider.Settings.Secure.TTS_DEFAULT_SYNTH;

import android.app.settings.SettingsEnums;
import android.content.Context;
import android.content.DialogInterface;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.provider.SearchIndexableResource;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.EngineInfo;
import android.speech.tts.TtsEngines;
import android.util.Log;
import android.widget.Checkable;

import androidx.preference.PreferenceCategory;
import androidx.appcompat.app.AlertDialog;

import com.android.settings.R;
import com.android.settings.SettingsPreferenceFragment;
import com.android.settings.search.BaseSearchIndexProvider;
import com.android.settings.search.Indexable;
import com.android.settings.tts.TtsEnginePreference.RadioButtonGroupState;
import com.android.settings.widget.RadioButtonPickerFragment;
import com.android.settingslib.search.SearchIndexable;
import com.android.settingslib.widget.CandidateInfo;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@SearchIndexable
public class TtsEnginePreferenceFragment extends SettingsPreferenceFragment
        implements RadioButtonGroupState {
public class TtsEnginePreferenceFragment extends RadioButtonPickerFragment {
    private static final String TAG = "TtsEnginePrefFragment";

    private static final int VOICE_DATA_INTEGRITY_CHECK = 1977;

    /** The currently selected engine. */
    private String mCurrentEngine;

    /**
     * The engine checkbox that is currently checked. Saves us a bit of effort in deducing the right
     * one from the currently selected engine.
     */
    private Checkable mCurrentChecked;

    /**
     * The previously selected TTS engine. Useful for rollbacks if the users choice is not loaded or
     * fails a voice integrity check.
     */
    private String mPreviousEngine;

    private PreferenceCategory mEnginePreferenceCategory;

    private TextToSpeech mTts = null;
    private TtsEngines mEnginesHelper = null;

    private Context mContext;
    private Map<String, EngineCandidateInfo> mEngineMap;
    /**
     * The initialization listener used when the user changes his choice of engine (as opposed to
     * when then screen is being initialized for the first time).
     */
    private final TextToSpeech.OnInitListener mUpdateListener =
            new TextToSpeech.OnInitListener() {
                @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.tts_engine_picker);

        mEnginePreferenceCategory =
                (PreferenceCategory) findPreference("tts_engine_preference_category");
        mEnginesHelper = new TtsEngines(getActivity().getApplicationContext());

        mTts = new TextToSpeech(getActivity().getApplicationContext(), null);

        initSettings();
                public void onInit(int status) {
                    onUpdateEngine(status);
                }
            };

    @Override
    public int getMetricsCategory() {
        return SettingsEnums.TTS_ENGINE_SETTINGS;
    public void onCreate(Bundle savedInstanceState) {
        mContext = getContext().getApplicationContext();
        mEnginesHelper = new TtsEngines(mContext);
        mEngineMap = new HashMap<>();
        mTts = new TextToSpeech(mContext, null);

        super.onCreate(savedInstanceState);
    }

    @Override
@@ -79,47 +73,96 @@ public class TtsEnginePreferenceFragment extends SettingsPreferenceFragment
        }
    }

    private void initSettings() {
        if (mTts != null) {
            mCurrentEngine = mTts.getCurrentEngine();
    @Override
    public int getMetricsCategory() {
        return SettingsEnums.TTS_ENGINE_SETTINGS;
    }

    /**
     * Step 3: We have now bound to the TTS engine the user requested. We will attempt to check
     * voice data for the engine if we successfully bound to it, or revert to the previous engine if
     * we didn't.
     */
    public void onUpdateEngine(int status) {
        if (status == TextToSpeech.SUCCESS) {
            Log.d(TAG, "Updating engine: Successfully bound to the engine: "
                    + mTts.getCurrentEngine());
            android.provider.Settings.Secure.putString(
                    mContext.getContentResolver(), TTS_DEFAULT_SYNTH, mTts.getCurrentEngine());
        } else {
            Log.d(TAG, "Updating engine: Failed to bind to engine, reverting.");
            if (mPreviousEngine != null) {
                // This is guaranteed to at least bind, since mPreviousEngine would be
                // null if the previous bind to this engine failed.
                mTts = new TextToSpeech(mContext, null, mPreviousEngine);
                updateCheckedState(mPreviousEngine);
            }
            mPreviousEngine = null;
        }
    }

        mEnginePreferenceCategory.removeAll();
    @Override
    protected void onRadioButtonConfirmed(String selectedKey) {
        final EngineCandidateInfo info = mEngineMap.get(selectedKey);
        // Should we alert user? if that's true, delay making engine current one.
        if (shouldDisplayDataAlert(info)) {
            displayDataAlert(info, (dialog, which) -> {
                setDefaultKey(selectedKey);
            });
        } else {
            // Privileged engine, set it current
            setDefaultKey(selectedKey);
        }
    }

        List<EngineInfo> engines = mEnginesHelper.getEngines();
    @Override
    protected List<? extends CandidateInfo> getCandidates() {
        final List<EngineCandidateInfo> infos = new ArrayList<>();
        final List<EngineInfo> engines = mEnginesHelper.getEngines();
        for (EngineInfo engine : engines) {
            TtsEnginePreference enginePref =
                    new TtsEnginePreference(getPrefContext(), engine, this);
            mEnginePreferenceCategory.addPreference(enginePref);
            final EngineCandidateInfo info = new EngineCandidateInfo(engine);
            infos.add(info);
            mEngineMap.put(engine.name, info);
        }
        return infos;
    }

    @Override
    public Checkable getCurrentChecked() {
        return mCurrentChecked;
    protected String getDefaultKey() {
        return mEnginesHelper.getDefaultEngine();
    }

    @Override
    public String getCurrentKey() {
        return mCurrentEngine;
    protected boolean setDefaultKey(String key) {
        updateDefaultEngine(key);
        updateCheckedState(key);
        return true;
    }

    @Override
    public void setCurrentChecked(Checkable current) {
        mCurrentChecked = current;
    protected int getPreferenceScreenResId() {
        return R.xml.tts_engine_picker;
    }

    /**
     * The initialization listener used when the user changes his choice of engine (as opposed to
     * when then screen is being initialized for the first time).
     */
    private final TextToSpeech.OnInitListener mUpdateListener =
            new TextToSpeech.OnInitListener() {
                @Override
                public void onInit(int status) {
                    onUpdateEngine(status);
    private boolean shouldDisplayDataAlert(EngineCandidateInfo info) {
        return !info.isSystem();
    }

    private void displayDataAlert(EngineCandidateInfo info,
            DialogInterface.OnClickListener positiveOnClickListener) {
        Log.i(TAG, "Displaying data alert for :" + info.getKey());

        final AlertDialog dialog = new AlertDialog.Builder(getPrefContext())
                .setTitle(android.R.string.dialog_alert_title)
                .setMessage(mContext.getString(
                        R.string.tts_engine_security_warning, info.loadLabel()))
                .setCancelable(true)
                .setPositiveButton(android.R.string.ok, positiveOnClickListener)
                .setNegativeButton(android.R.string.cancel, null)
                .create();

        dialog.show();
    }
            };

    private void updateDefaultEngine(String engine) {
        Log.d(TAG, "Updating default synth to : " + engine);
@@ -146,41 +189,36 @@ public class TtsEnginePreferenceFragment extends SettingsPreferenceFragment
        // Step 3 is continued on #onUpdateEngine (below) which is called when
        // the app binds successfully to the engine.
        Log.i(TAG, "Updating engine : Attempting to connect to engine: " + engine);
        mTts = new TextToSpeech(getActivity().getApplicationContext(), mUpdateListener, engine);
        mTts = new TextToSpeech(mContext, mUpdateListener, engine);
        Log.i(TAG, "Success");
    }

    /**
     * Step 3: We have now bound to the TTS engine the user requested. We will attempt to check
     * voice data for the engine if we successfully bound to it, or revert to the previous engine if
     * we didn't.
     */
    public void onUpdateEngine(int status) {
        if (status == TextToSpeech.SUCCESS) {
            Log.d(
    public static class EngineCandidateInfo extends CandidateInfo {
        private final EngineInfo mEngineInfo;

                    TAG,
                    "Updating engine: Successfully bound to the engine: "
                            + mTts.getCurrentEngine());
            android.provider.Settings.Secure.putString(
                    getContentResolver(), TTS_DEFAULT_SYNTH, mTts.getCurrentEngine());
        } else {
            Log.d(TAG, "Updating engine: Failed to bind to engine, reverting.");
            if (mPreviousEngine != null) {
                // This is guaranteed to at least bind, since mPreviousEngine would be
                // null if the previous bind to this engine failed.
                mTts =
                        new TextToSpeech(
                                getActivity().getApplicationContext(), null, mPreviousEngine);
        EngineCandidateInfo(EngineInfo engineInfo) {
            super(true /* enabled */);
            mEngineInfo = engineInfo;
        }
            mPreviousEngine = null;

        @Override
        public CharSequence loadLabel() {
            return mEngineInfo.label;
        }

        @Override
        public Drawable loadIcon() {
            return null;
        }

        @Override
    public void setCurrentKey(String key) {
        mCurrentEngine = key;
        updateDefaultEngine(mCurrentEngine);
        public String getKey() {
            return mEngineInfo.name;
        }

        public boolean isSystem() {
            return mEngineInfo.system;
        }
    }

    public static final Indexable.SearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
+87 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2019 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.testutils.shadow;

import android.content.Intent;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TtsEngines;

import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.Resetter;

import java.util.List;
import java.util.Locale;

@Implements(TtsEngines.class)
public class ShadowTtsEngines {
    private static TtsEngines sInstance;

    public static void setInstance(TtsEngines ttsEngines) {
        sInstance = ttsEngines;
    }

    @Resetter
    public static void reset() {
        sInstance = null;
    }

    @Implementation
    protected List<TextToSpeech.EngineInfo> getEngines() {
        return sInstance.getEngines();
    }

    @Implementation
    protected TextToSpeech.EngineInfo getEngineInfo(String packageName) {
        return sInstance.getEngineInfo(packageName);
    }

    @Implementation
    protected String getDefaultEngine() {
        return sInstance.getDefaultEngine();
    }

    @Implementation
    protected Intent getSettingsIntent(String engine) {
        return sInstance.getSettingsIntent(engine);
    }

    @Implementation
    protected boolean isEngineInstalled(String engine) {
        return sInstance.isEngineInstalled(engine);
    }

    @Implementation
    protected boolean isLocaleSetToDefaultForEngine(String engineName) {
        return sInstance.isLocaleSetToDefaultForEngine(engineName);
    }

    @Implementation
    protected Locale getLocalePrefForEngine(String engineName) {
        return sInstance.getLocalePrefForEngine(engineName);
    }

    @Implementation
    protected synchronized void updateLocalePrefForEngine(String engineName, Locale newLocale) {
        sInstance.updateLocalePrefForEngine(engineName, newLocale);
    }

    @Implementation
    protected Locale parseLocaleString(String localeString) {
        return sInstance.parseLocaleString(localeString);
    }
}
 No newline at end of file
+129 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2019 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.tts;

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

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
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.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import android.os.Bundle;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TtsEngines;

import com.android.settings.testutils.shadow.ShadowTtsEngines;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowPackageManager;
import org.robolectric.shadows.androidx.fragment.FragmentController;

@RunWith(RobolectricTestRunner.class)
public class TtsEnginePreferenceFragmentTest {

    private Context mContext;
    private TtsEnginePreferenceFragment mTtsEnginePreferenceFragment;

    @Before
    public void setUp() {
        mContext = RuntimeEnvironment.application;

        final ResolveInfo info = new ResolveInfo();
        final ServiceInfo serviceInfo = spy(new ServiceInfo());
        serviceInfo.packageName = mContext.getPackageName();
        serviceInfo.name = mContext.getClass().getName();
        info.serviceInfo = serviceInfo;
        doReturn("title").when(serviceInfo).loadLabel(any(PackageManager.class));
        doReturn(1).when(serviceInfo).getIconResource();

        final ShadowPackageManager spm = Shadow.extract(mContext.getPackageManager());
        spm.addResolveInfoForIntent(
                new Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE), info);
    }

    @After
    public void tearDown() {
        ShadowTtsEngines.reset();
    }

    @Test
    public void getCandidates_AddEngines_returnCorrectEngines() {
        mTtsEnginePreferenceFragment = FragmentController.of(new TtsEnginePreferenceFragment(),
                new Bundle())
                .create()
                .get();

        assertThat(mTtsEnginePreferenceFragment.getCandidates().size()).isEqualTo(1);
    }

    @Test
    @Config(shadows = {ShadowTtsEngines.class})
    public void getDefaultKey_validKey_returnCorrectKey() {
        final String TEST_ENGINE = "test_engine";
        final TtsEngines engine = mock(TtsEngines.class);
        ShadowTtsEngines.setInstance(engine);
        mTtsEnginePreferenceFragment = FragmentController.of(new TtsEnginePreferenceFragment(),
                new Bundle())
                .create()
                .get();
        when(engine.getDefaultEngine()).thenReturn(TEST_ENGINE);

        assertThat(mTtsEnginePreferenceFragment.getDefaultKey()).isEqualTo(TEST_ENGINE);
    }

    @Test
    @Config(shadows = {ShadowTtsEngines.class})
    public void setDefaultKey_validKey_callingTtsEngineFunction() {
        final TtsEngines engine = mock(TtsEngines.class);
        ShadowTtsEngines.setInstance(engine);
        mTtsEnginePreferenceFragment = FragmentController.of(new TtsEnginePreferenceFragment(),
                new Bundle())
                .create()
                .get();

        mTtsEnginePreferenceFragment.setDefaultKey(mContext.getPackageName());

        verify(engine).isEngineInstalled(mContext.getPackageName());
    }

    @Test
    public void setDefaultKey_validKey_updateCheckedState() {
        mTtsEnginePreferenceFragment = spy(FragmentController.of(new TtsEnginePreferenceFragment(),
                new Bundle())
                .create()
                .get());

        mTtsEnginePreferenceFragment.setDefaultKey(mContext.getPackageName());

        verify(mTtsEnginePreferenceFragment).updateCheckedState(mContext.getPackageName());
    }
}
Loading