diff --git a/core/api/current.txt b/core/api/current.txt index 34a9026e618c5..c094de34dc472 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -53486,6 +53486,7 @@ package android.view.inputmethod { method public void sendAppPrivateCommand(android.view.View, String, android.os.Bundle); method @Deprecated public void setAdditionalInputMethodSubtypes(@NonNull String, @NonNull android.view.inputmethod.InputMethodSubtype[]); method @Deprecated @RequiresPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) public boolean setCurrentInputMethodSubtype(android.view.inputmethod.InputMethodSubtype); + method public void setExplicitlyEnabledInputMethodSubtypes(@NonNull String, @NonNull int[]); method @Deprecated public void setInputMethod(android.os.IBinder, String); method @Deprecated public void setInputMethodAndSubtype(@NonNull android.os.IBinder, String, android.view.inputmethod.InputMethodSubtype); method @Deprecated public boolean shouldOfferSwitchingToNextInputMethod(android.os.IBinder); diff --git a/core/java/android/view/inputmethod/IInputMethodManagerInvoker.java b/core/java/android/view/inputmethod/IInputMethodManagerInvoker.java index ef45bcfe137fe..a8e1d758cb61d 100644 --- a/core/java/android/view/inputmethod/IInputMethodManagerInvoker.java +++ b/core/java/android/view/inputmethod/IInputMethodManagerInvoker.java @@ -201,6 +201,16 @@ final class IInputMethodManagerInvoker { } } + @AnyThread + void setExplicitlyEnabledInputMethodSubtypes(@NonNull String imeId, + @NonNull int[] subtypeHashCodes, @UserIdInt int userId) { + try { + mTarget.setExplicitlyEnabledInputMethodSubtypes(imeId, subtypeHashCodes, userId); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + } + @AnyThread int getInputMethodWindowVisibleHeight(@NonNull IInputMethodClient client) { try { diff --git a/core/java/android/view/inputmethod/InputMethodManager.java b/core/java/android/view/inputmethod/InputMethodManager.java index 78471c54b7de0..a78f10a27b394 100644 --- a/core/java/android/view/inputmethod/InputMethodManager.java +++ b/core/java/android/view/inputmethod/InputMethodManager.java @@ -3632,6 +3632,56 @@ public final class InputMethodManager { mServiceInvoker.setAdditionalInputMethodSubtypes(imiId, subtypes, UserHandle.myUserId()); } + /** + * Updates the list of explicitly enabled {@link InputMethodSubtype} for a given IME owned by + * the calling process. + * + *
By default each IME has no explicitly enabled {@link InputMethodSubtype}. In this state + * the system will decide what {@link InputMethodSubtype} should be enabled by using information + * available at runtime as per-user language settings. Users can, however, manually pick up one + * or more {@link InputMethodSubtype} to be enabled on an Activity shown by + * {@link #showInputMethodAndSubtypeEnabler(String)}. Such a manual change is stored in + * {@link Settings.Secure#ENABLED_INPUT_METHODS} so that the change can persist across reboots. + * {@link Settings.Secure#ENABLED_INPUT_METHODS} stores {@link InputMethodSubtype#hashCode()} as + * the identifier of {@link InputMethodSubtype} for historical reasons.
+ * + *This API provides a safe and managed way for IME developers to modify what + * {@link InputMethodSubtype} are referenced in {@link Settings.Secure#ENABLED_INPUT_METHODS} + * for their own IME. One use case is when IME developers want to use their own Activity for + * users to pick up {@link InputMethodSubtype}. Another use case is for IME developers to fix up + * any stale and/or invalid value stored in {@link Settings.Secure#ENABLED_INPUT_METHODS} + * without bothering users. Passing an empty {@code subtypeHashCodes} is guaranteed to reset + * the state to default.
+ * + *{@link android.R.attr#subtypeId} and {@link + * android.view.inputmethod.InputMethodSubtype.InputMethodSubtypeBuilder#setSubtypeId(int)} are + * available for IME developers to control the return value of + * {@link InputMethodSubtype#hashCode()}. Beware that {@code -1} is not a valid value of + * {@link InputMethodSubtype#hashCode()} for historical reasons.
+ * + *While IME developers can call this API even before + * {@link android.os.UserManager#isUserUnlocked()} becomes {@code true}, such a change is + * volatile thus remains effective only until {@link android.os.UserManager#isUserUnlocked()} + * becomes {@code true} or the device is rebooted. To make the change persistent IME developers + * need to call this API again after receiving {@link Intent#ACTION_USER_UNLOCKED}.
+ * + * @param imiId IME ID. The specified IME and the calling process need to belong to the same + * package. Otherwise {@link SecurityException} will be thrown. + * @param subtypeHashCodes An arrays of {@link InputMethodSubtype#hashCode()} to be explicitly + * enabled. Entries that are found in the specified IME will be silently + * ignored. Pass an empty array to reset the state to default. + * @throws NullPointerException if {@code subtypeHashCodes} is {@code null}. + * @throws SecurityException if the specified IME and the calling process do not belong to the + * same package. + */ + public void setExplicitlyEnabledInputMethodSubtypes(@NonNull String imiId, + @NonNull int[] subtypeHashCodes) { + mServiceInvoker.setExplicitlyEnabledInputMethodSubtypes(imiId, subtypeHashCodes, + UserHandle.myUserId()); + } + /** * Returns the last used {@link InputMethodSubtype} in system history. * diff --git a/core/java/com/android/internal/view/IInputMethodManager.aidl b/core/java/com/android/internal/view/IInputMethodManager.aidl index bd132c7b36ebf..9f15469f4046d 100644 --- a/core/java/com/android/internal/view/IInputMethodManager.aidl +++ b/core/java/com/android/internal/view/IInputMethodManager.aidl @@ -97,6 +97,11 @@ interface IInputMethodManager { void setAdditionalInputMethodSubtypes(String id, in InputMethodSubtype[] subtypes, int userId); + @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = " + + "android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, conditional = true)") + void setExplicitlyEnabledInputMethodSubtypes(String imeId, in int[] subtypeHashCodes, + int userId); + // This is kept due to @UnsupportedAppUsage. // TODO(Bug 113914148): Consider removing this. int getInputMethodWindowVisibleHeight(in IInputMethodClient client); diff --git a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java index 8f253ee33afd2..08ccccd49e541 100644 --- a/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java +++ b/services/core/java/com/android/server/inputmethod/InputMethodManagerService.java @@ -75,6 +75,7 @@ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.ContentProvider; import android.content.ContentResolver; import android.content.Context; @@ -4214,6 +4215,46 @@ public final class InputMethodManagerService extends IInputMethodManager.Stub } } + @Override + public void setExplicitlyEnabledInputMethodSubtypes(String imeId, + @NonNull int[] subtypeHashCodes, @UserIdInt int userId) { + if (UserHandle.getCallingUserId() != userId) { + mContext.enforceCallingPermission(Manifest.permission.INTERACT_ACROSS_USERS_FULL, null); + } + final int callingUid = Binder.getCallingUid(); + final ComponentName imeComponentName = + imeId != null ? ComponentName.unflattenFromString(imeId) : null; + if (imeComponentName == null || !InputMethodUtils.checkIfPackageBelongsToUid(mAppOpsManager, + callingUid, imeComponentName.getPackageName())) { + throw new SecurityException("Calling UID=" + callingUid + " does not belong to imeId=" + + imeId); + } + Objects.requireNonNull(subtypeHashCodes, "subtypeHashCodes must not be null"); + + final long ident = Binder.clearCallingIdentity(); + try { + synchronized (ImfLock.class) { + final boolean currentUser = (mSettings.getCurrentUserId() == userId); + final InputMethodSettings settings = currentUser + ? mSettings + : new InputMethodSettings(mContext, queryMethodMapForUser(userId), userId, + !mUserManagerInternal.isUserUnlocked(userId)); + if (!settings.setEnabledInputMethodSubtypes(imeId, subtypeHashCodes)) { + return; + } + if (currentUser) { + // To avoid unnecessary "updateInputMethodsFromSettingsLocked" from happening. + if (mSettingsObserver != null) { + mSettingsObserver.mLastEnabled = settings.getEnabledInputMethodsStr(); + } + updateInputMethodsFromSettingsLocked(false /* enabledChanged */); + } + } + } finally { + Binder.restoreCallingIdentity(ident); + } + } + /** * This is kept due to {@code @UnsupportedAppUsage} in * {@link InputMethodManager#getInputMethodWindowVisibleHeight()} and a dependency in diff --git a/services/core/java/com/android/server/inputmethod/InputMethodUtils.java b/services/core/java/com/android/server/inputmethod/InputMethodUtils.java index f9d4735d94dae..beeaa90a3832c 100644 --- a/services/core/java/com/android/server/inputmethod/InputMethodUtils.java +++ b/services/core/java/com/android/server/inputmethod/InputMethodUtils.java @@ -35,6 +35,7 @@ import android.provider.Settings; import android.text.TextUtils; import android.util.ArrayMap; import android.util.ArraySet; +import android.util.IntArray; import android.util.Pair; import android.util.Printer; import android.util.Slog; @@ -42,6 +43,7 @@ import android.view.inputmethod.InputMethodInfo; import android.view.inputmethod.InputMethodSubtype; import android.view.textservice.SpellCheckerInfo; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.inputmethod.StartInputFlags; import com.android.internal.util.ArrayUtils; import com.android.server.LocalServices; @@ -901,6 +903,72 @@ final class InputMethodUtils { return true; } + boolean setEnabledInputMethodSubtypes(@NonNull String imeId, + @NonNull int[] subtypeHashCodes) { + final InputMethodInfo imi = mMethodMap.get(imeId); + if (imi == null) { + return false; + } + + final IntArray validSubtypeHashCodes = new IntArray(subtypeHashCodes.length); + for (int subtypeHashCode : subtypeHashCodes) { + if (subtypeHashCode == NOT_A_SUBTYPE_ID) { + continue; // NOT_A_SUBTYPE_ID must not be saved + } + if (!SubtypeUtils.isValidSubtypeId(imi, subtypeHashCode)) { + continue; // this subtype does not exist in InputMethodInfo. + } + if (validSubtypeHashCodes.indexOf(subtypeHashCode) >= 0) { + continue; // The entry is already added. No need to add anymore. + } + validSubtypeHashCodes.add(subtypeHashCode); + } + + final String originalEnabledImesString = getEnabledInputMethodsStr(); + final String updatedEnabledImesString = updateEnabledImeString( + originalEnabledImesString, imi.getId(), validSubtypeHashCodes); + if (TextUtils.equals(originalEnabledImesString, updatedEnabledImesString)) { + return false; + } + + putEnabledInputMethodsStr(updatedEnabledImesString); + return true; + } + + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) + static String updateEnabledImeString(@NonNull String enabledImesString, + @NonNull String imeId, @NonNull IntArray enabledSubtypeHashCodes) { + final TextUtils.SimpleStringSplitter imeSplitter = + new TextUtils.SimpleStringSplitter(INPUT_METHOD_SEPARATOR); + final TextUtils.SimpleStringSplitter imeSubtypeSplitter = + new TextUtils.SimpleStringSplitter(INPUT_METHOD_SUBTYPE_SEPARATOR); + + final StringBuilder sb = new StringBuilder(); + + imeSplitter.setString(enabledImesString); + boolean needsImeSeparator = false; + while (imeSplitter.hasNext()) { + final String nextImsStr = imeSplitter.next(); + imeSubtypeSplitter.setString(nextImsStr); + if (imeSubtypeSplitter.hasNext()) { + if (needsImeSeparator) { + sb.append(INPUT_METHOD_SEPARATOR); + } + if (TextUtils.equals(imeId, imeSubtypeSplitter.next())) { + sb.append(imeId); + for (int i = 0; i < enabledSubtypeHashCodes.size(); ++i) { + sb.append(INPUT_METHOD_SUBTYPE_SEPARATOR); + sb.append(enabledSubtypeHashCodes.get(i)); + } + } else { + sb.append(nextImsStr); + } + needsImeSeparator = true; + } + } + return sb.toString(); + } + public void dumpLocked(final Printer pw, final String prefix) { pw.println(prefix + "mCurrentUserId=" + mCurrentUserId); pw.println(prefix + "mCurrentProfileIds=" + Arrays.toString(mCurrentProfileIds)); diff --git a/services/tests/servicestests/src/com/android/server/inputmethod/InputMethodUtilsTest.java b/services/tests/servicestests/src/com/android/server/inputmethod/InputMethodUtilsTest.java index 0a4da8da943e2..426b9433492d4 100644 --- a/services/tests/servicestests/src/com/android/server/inputmethod/InputMethodUtilsTest.java +++ b/services/tests/servicestests/src/com/android/server/inputmethod/InputMethodUtilsTest.java @@ -35,11 +35,14 @@ import android.content.res.Resources; import android.os.Build; import android.os.LocaleList; import android.os.Parcel; +import android.text.TextUtils; import android.util.ArrayMap; +import android.util.IntArray; import android.view.inputmethod.InputMethodInfo; import android.view.inputmethod.InputMethodSubtype; import android.view.inputmethod.InputMethodSubtype.InputMethodSubtypeBuilder; +import androidx.annotation.NonNull; import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; import androidx.test.runner.AndroidJUnit4; @@ -1210,4 +1213,79 @@ public class InputMethodUtilsTest { Build.VERSION_CODES.P, StartInputFlags.VIEW_HAS_FOCUS | StartInputFlags.IS_TEXT_EDITOR)); } + + private static IntArray createSubtypeHashCodeArrayFromStr(String subtypeHashCodesStr) { + final IntArray subtypes = new IntArray(); + final TextUtils.SimpleStringSplitter imeSubtypeSplitter = + new TextUtils.SimpleStringSplitter(';'); + if (TextUtils.isEmpty(subtypeHashCodesStr)) { + return subtypes; + } + imeSubtypeSplitter.setString(subtypeHashCodesStr); + while (imeSubtypeSplitter.hasNext()) { + subtypes.add(Integer.parseInt(imeSubtypeSplitter.next())); + } + return subtypes; + } + + private static void verifyUpdateEnabledImeString(@NonNull String expectedEnabledImeStr, + @NonNull String initialEnabledImeStr, @NonNull String imeId, + @NonNull String enabledSubtypeHashCodesStr) { + assertEquals(expectedEnabledImeStr, + InputMethodUtils.InputMethodSettings.updateEnabledImeString(initialEnabledImeStr, + imeId, createSubtypeHashCodeArrayFromStr(enabledSubtypeHashCodesStr))); + } + + @Test + public void updateEnabledImeStringTest() { + // No change cases + verifyUpdateEnabledImeString( + "com.android/.ime1", + "com.android/.ime1", "com.android/.ime1", ""); + verifyUpdateEnabledImeString( + "com.android/.ime1", + "com.android/.ime1", "com.android/.ime2", ""); + + // To enable subtypes + verifyUpdateEnabledImeString( + "com.android/.ime1", + "com.android/.ime1", "com.android/.ime2", ""); + verifyUpdateEnabledImeString( + "com.android/.ime1;1", + "com.android/.ime1", "com.android/.ime1", "1"); + + verifyUpdateEnabledImeString( + "com.android/.ime1;1;2;3", + "com.android/.ime1", "com.android/.ime1", "1;2;3"); + + verifyUpdateEnabledImeString( + "com.android/.ime1;1;2;3:com.android/.ime2", + "com.android/.ime1:com.android/.ime2", "com.android/.ime1", "1;2;3"); + verifyUpdateEnabledImeString( + "com.android/.ime0:com.android/.ime1;1;2;3", + "com.android/.ime0:com.android/.ime1", "com.android/.ime1", "1;2;3"); + verifyUpdateEnabledImeString( + "com.android/.ime0:com.android/.ime1;1;2;3:com.android/.ime2", + "com.android/.ime0:com.android/.ime1:com.android/.ime2", "com.android/.ime1", + "1;2;3"); + + // To reset enabled subtypes + verifyUpdateEnabledImeString( + "com.android/.ime1", + "com.android/.ime1;1", "com.android/.ime1", ""); + verifyUpdateEnabledImeString( + "com.android/.ime1", + "com.android/.ime1;1;2;3", "com.android/.ime1", ""); + verifyUpdateEnabledImeString( + "com.android/.ime1:com.android/.ime2", + "com.android/.ime1;1;2;3:com.android/.ime2", "com.android/.ime1", ""); + + verifyUpdateEnabledImeString( + "com.android/.ime0:com.android/.ime1", + "com.android/.ime0:com.android/.ime1;1;2;3", "com.android/.ime1", ""); + verifyUpdateEnabledImeString( + "com.android/.ime0:com.android/.ime1:com.android/.ime2", + "com.android/.ime0:com.android/.ime1;1;2;3:com.android/.ime2", "com.android/.ime1", + ""); + } }