1/*
2 * Copyright (C) 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.settingslib.inputmethod;
18
19import static com.android.settingslib.RestrictedLockUtils.EnforcedAdmin;
20
21import android.app.AlertDialog;
22import android.content.ActivityNotFoundException;
23import android.content.Context;
24import android.content.Intent;
25import android.content.res.Configuration;
26import android.os.UserHandle;
27import android.support.v7.preference.Preference;
28import android.support.v7.preference.Preference.OnPreferenceChangeListener;
29import android.support.v7.preference.Preference.OnPreferenceClickListener;
30import android.text.TextUtils;
31import android.util.Log;
32import android.view.inputmethod.InputMethodInfo;
33import android.view.inputmethod.InputMethodManager;
34import android.view.inputmethod.InputMethodSubtype;
35import android.widget.Toast;
36
37import com.android.internal.annotations.VisibleForTesting;
38import com.android.internal.inputmethod.InputMethodUtils;
39import com.android.settingslib.R;
40import com.android.settingslib.RestrictedLockUtils;
41import com.android.settingslib.RestrictedSwitchPreference;
42
43import java.text.Collator;
44import java.util.List;
45
46/**
47 * Input method preference.
48 *
49 * This preference represents an IME. It is used for two purposes. 1) An instance with a switch
50 * is used to enable or disable the IME. 2) An instance without a switch is used to invoke the
51 * setting activity of the IME.
52 */
53public class InputMethodPreference extends RestrictedSwitchPreference implements OnPreferenceClickListener,
54        OnPreferenceChangeListener {
55    private static final String TAG = InputMethodPreference.class.getSimpleName();
56    private static final String EMPTY_TEXT = "";
57    private static final int NO_WIDGET = 0;
58
59    public interface OnSavePreferenceListener {
60        /**
61         * Called when this preference needs to be saved its state.
62         *
63         * Note that this preference is non-persistent and needs explicitly to be saved its state.
64         * Because changing one IME state may change other IMEs' state, this is a place to update
65         * other IMEs' state as well.
66         *
67         * @param pref This preference.
68         */
69        void onSaveInputMethodPreference(InputMethodPreference pref);
70    }
71
72    private final InputMethodInfo mImi;
73    private final boolean mHasPriorityInSorting;
74    private final OnSavePreferenceListener mOnSaveListener;
75    private final InputMethodSettingValuesWrapper mInputMethodSettingValues;
76    private final boolean mIsAllowedByOrganization;
77
78    private AlertDialog mDialog = null;
79
80    /**
81     * A preference entry of an input method.
82     *
83     * @param context The Context this is associated with.
84     * @param imi The {@link InputMethodInfo} of this preference.
85     * @param isImeEnabler true if this preference is the IME enabler that has enable/disable
86     *     switches for all available IMEs, not the list of enabled IMEs.
87     * @param isAllowedByOrganization false if the IME has been disabled by a device or profile
88     *     owner.
89     * @param onSaveListener The listener called when this preference has been changed and needs
90     *     to save the state to shared preference.
91     */
92    public InputMethodPreference(final Context context, final InputMethodInfo imi,
93            final boolean isImeEnabler, final boolean isAllowedByOrganization,
94            final OnSavePreferenceListener onSaveListener) {
95        this(context, imi, imi.loadLabel(context.getPackageManager()), isAllowedByOrganization,
96                onSaveListener);
97        if (!isImeEnabler) {
98            // Remove switch widget.
99            setWidgetLayoutResource(NO_WIDGET);
100        }
101    }
102
103    @VisibleForTesting
104    InputMethodPreference(final Context context, final InputMethodInfo imi,
105            final CharSequence title, final boolean isAllowedByOrganization,
106            final OnSavePreferenceListener onSaveListener) {
107        super(context);
108        setPersistent(false);
109        mImi = imi;
110        mIsAllowedByOrganization = isAllowedByOrganization;
111        mOnSaveListener = onSaveListener;
112        // Disable on/off switch texts.
113        setSwitchTextOn(EMPTY_TEXT);
114        setSwitchTextOff(EMPTY_TEXT);
115        setKey(imi.getId());
116        setTitle(title);
117        final String settingsActivity = imi.getSettingsActivity();
118        if (TextUtils.isEmpty(settingsActivity)) {
119            setIntent(null);
120        } else {
121            // Set an intent to invoke settings activity of an input method.
122            final Intent intent = new Intent(Intent.ACTION_MAIN);
123            intent.setClassName(imi.getPackageName(), settingsActivity);
124            setIntent(intent);
125        }
126        mInputMethodSettingValues = InputMethodSettingValuesWrapper.getInstance(context);
127        mHasPriorityInSorting = InputMethodUtils.isSystemIme(imi)
128                && mInputMethodSettingValues.isValidSystemNonAuxAsciiCapableIme(imi, context);
129        setOnPreferenceClickListener(this);
130        setOnPreferenceChangeListener(this);
131    }
132
133    public InputMethodInfo getInputMethodInfo() {
134        return mImi;
135    }
136
137    private boolean isImeEnabler() {
138        // If this {@link SwitchPreference} doesn't have a widget layout, we explicitly hide the
139        // switch widget at constructor.
140        return getWidgetLayoutResource() != NO_WIDGET;
141    }
142
143    @Override
144    public boolean onPreferenceChange(final Preference preference, final Object newValue) {
145        // Always returns false to prevent default behavior.
146        // See {@link TwoStatePreference#onClick()}.
147        if (!isImeEnabler()) {
148            // Prevent disabling an IME because this preference is for invoking a settings activity.
149            return false;
150        }
151        if (isChecked()) {
152            // Disable this IME.
153            setCheckedInternal(false);
154            return false;
155        }
156        if (InputMethodUtils.isSystemIme(mImi)) {
157            // Enable a system IME. No need to show a security warning dialog,
158            // but we might need to prompt if it's not Direct Boot aware.
159            // TV doesn't doesn't need to worry about this, but other platforms should show
160            // a warning.
161            if (mImi.getServiceInfo().directBootAware || isTv()) {
162                setCheckedInternal(true);
163            } else if (!isTv()){
164                showDirectBootWarnDialog();
165            }
166        } else {
167            // Once security is confirmed, we might prompt if the IME isn't
168            // Direct Boot aware.
169            showSecurityWarnDialog();
170        }
171        return false;
172    }
173
174    @Override
175    public boolean onPreferenceClick(final Preference preference) {
176        // Always returns true to prevent invoking an intent without catching exceptions.
177        // See {@link Preference#performClick(PreferenceScreen)}/
178        if (isImeEnabler()) {
179            // Prevent invoking a settings activity because this preference is for enabling and
180            // disabling an input method.
181            return true;
182        }
183        final Context context = getContext();
184        try {
185            final Intent intent = getIntent();
186            if (intent != null) {
187                // Invoke a settings activity of an input method.
188                context.startActivity(intent);
189            }
190        } catch (final ActivityNotFoundException e) {
191            Log.d(TAG, "IME's Settings Activity Not Found", e);
192            final String message = context.getString(
193                    R.string.failed_to_open_app_settings_toast,
194                    mImi.loadLabel(context.getPackageManager()));
195            Toast.makeText(context, message, Toast.LENGTH_LONG).show();
196        }
197        return true;
198    }
199
200    public void updatePreferenceViews() {
201        final boolean isAlwaysChecked = mInputMethodSettingValues.isAlwaysCheckedIme(
202                mImi, getContext());
203        // When this preference has a switch and an input method should be always enabled,
204        // this preference should be disabled to prevent accidentally disabling an input method.
205        // This preference should also be disabled in case the admin does not allow this input
206        // method.
207        if (isAlwaysChecked && isImeEnabler()) {
208            setDisabledByAdmin(null);
209            setEnabled(false);
210        } else if (!mIsAllowedByOrganization) {
211            EnforcedAdmin admin =
212                    RestrictedLockUtils.checkIfInputMethodDisallowed(getContext(),
213                            mImi.getPackageName(), UserHandle.myUserId());
214            setDisabledByAdmin(admin);
215        } else {
216            setEnabled(true);
217        }
218        setChecked(mInputMethodSettingValues.isEnabledImi(mImi));
219        if (!isDisabledByAdmin()) {
220            setSummary(getSummaryString());
221        }
222    }
223
224    private InputMethodManager getInputMethodManager() {
225        return (InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
226    }
227
228    private String getSummaryString() {
229        final InputMethodManager imm = getInputMethodManager();
230        final List<InputMethodSubtype> subtypes = imm.getEnabledInputMethodSubtypeList(mImi, true);
231        return InputMethodAndSubtypeUtil.getSubtypeLocaleNameListAsSentence(
232                subtypes, getContext(), mImi);
233    }
234
235    private void setCheckedInternal(boolean checked) {
236        super.setChecked(checked);
237        mOnSaveListener.onSaveInputMethodPreference(InputMethodPreference.this);
238        notifyChanged();
239    }
240
241    private void showSecurityWarnDialog() {
242        if (mDialog != null && mDialog.isShowing()) {
243            mDialog.dismiss();
244        }
245        final Context context = getContext();
246        final AlertDialog.Builder builder = new AlertDialog.Builder(context);
247        builder.setCancelable(true /* cancelable */);
248        builder.setTitle(android.R.string.dialog_alert_title);
249        final CharSequence label = mImi.getServiceInfo().applicationInfo.loadLabel(
250                context.getPackageManager());
251        builder.setMessage(context.getString(R.string.ime_security_warning, label));
252        builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
253            // The user confirmed to enable a 3rd party IME, but we might
254            // need to prompt if it's not Direct Boot aware.
255            // TV doesn't doesn't need to worry about this, but other platforms should show
256            // a warning.
257            if (mImi.getServiceInfo().directBootAware || isTv()) {
258                setCheckedInternal(true);
259            } else {
260                showDirectBootWarnDialog();
261            }
262        });
263        builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
264            // The user canceled to enable a 3rd party IME.
265            setCheckedInternal(false);
266        });
267        mDialog = builder.create();
268        mDialog.show();
269    }
270
271    private boolean isTv() {
272        return (getContext().getResources().getConfiguration().uiMode
273                & Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION;
274    }
275
276    private void showDirectBootWarnDialog() {
277        if (mDialog != null && mDialog.isShowing()) {
278            mDialog.dismiss();
279        }
280        final Context context = getContext();
281        final AlertDialog.Builder builder = new AlertDialog.Builder(context);
282        builder.setCancelable(true /* cancelable */);
283        builder.setMessage(context.getText(R.string.direct_boot_unaware_dialog_message));
284        builder.setPositiveButton(android.R.string.ok, (dialog, which) -> setCheckedInternal(true));
285        builder.setNegativeButton(android.R.string.cancel,
286                (dialog, which) -> setCheckedInternal(false));
287        mDialog = builder.create();
288        mDialog.show();
289    }
290
291    public int compareTo(final InputMethodPreference rhs, final Collator collator) {
292        if (this == rhs) {
293            return 0;
294        }
295        if (mHasPriorityInSorting != rhs.mHasPriorityInSorting) {
296            // Prefer always checked system IMEs
297            return mHasPriorityInSorting ? -1 : 1;
298        }
299        final CharSequence title = getTitle();
300        final CharSequence rhsTitle = rhs.getTitle();
301        final boolean emptyTitle = TextUtils.isEmpty(title);
302        final boolean rhsEmptyTitle = TextUtils.isEmpty(rhsTitle);
303        if (!emptyTitle && !rhsEmptyTitle) {
304            return collator.compare(title.toString(), rhsTitle.toString());
305        }
306        // For historical reasons, an empty text needs to be put at the first.
307        return (emptyTitle ? -1 : 0) - (rhsEmptyTitle ? -1 : 0);
308    }
309}
310