diff options
3 files changed, 431 insertions, 0 deletions
diff --git a/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.aidl b/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.aidl new file mode 100644 index 000000000000..18bd6e598756 --- /dev/null +++ b/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.aidl @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2022 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.internal.inputmethod; + +parcelable InputMethodSubtypeHandle; diff --git a/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.java b/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.java new file mode 100644 index 000000000000..780c637b3c9b --- /dev/null +++ b/core/java/com/android/internal/inputmethod/InputMethodSubtypeHandle.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2022 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.internal.inputmethod; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.annotation.AnyThread; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.content.ComponentName; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils.SimpleStringSplitter; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodSubtype; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.security.InvalidParameterException; +import java.util.Objects; + +/** + * A stable and serializable identifier for the pair of {@link InputMethodInfo#getId()} and + * {@link android.view.inputmethod.InputMethodSubtype}. + * + * <p>To save {@link InputMethodSubtypeHandle} to storage, call {@link #toStringHandle()} to get a + * {@link String} handle and just save it. Once you load a {@link String} handle, you can obtain a + * {@link InputMethodSubtypeHandle} instance from {@link #of(String)}.</p> + * + * <p>For better readability, consider specifying {@link RawHandle} annotation to {@link String} + * object when it is a raw {@link String} handle.</p> + */ +public final class InputMethodSubtypeHandle implements Parcelable { + private static final String SUBTYPE_TAG = "subtype"; + private static final char DATA_SEPARATOR = ':'; + + /** + * Can be used to annotate {@link String} object if it is raw handle format. + */ + @Retention(SOURCE) + @Target({ElementType.METHOD, ElementType.FIELD, ElementType.LOCAL_VARIABLE, + ElementType.PARAMETER}) + public @interface RawHandle { + } + + /** + * The main content of this {@link InputMethodSubtypeHandle}. Is designed to be safe to be + * saved into storage. + */ + @RawHandle + private final String mHandle; + + /** + * Encode {@link InputMethodInfo} and {@link InputMethodSubtype#hashCode()} into + * {@link RawHandle}. + * + * @param imeId {@link InputMethodInfo#getId()} to be used. + * @param subtypeHashCode {@link InputMethodSubtype#hashCode()} to be used. + * @return The encoded {@link RawHandle} string. + */ + @AnyThread + @RawHandle + @NonNull + private static String encodeHandle(@NonNull String imeId, int subtypeHashCode) { + return imeId + DATA_SEPARATOR + SUBTYPE_TAG + DATA_SEPARATOR + subtypeHashCode; + } + + private InputMethodSubtypeHandle(@NonNull String handle) { + mHandle = handle; + } + + /** + * Creates {@link InputMethodSubtypeHandle} from {@link InputMethodInfo} and + * {@link InputMethodSubtype}. + * + * @param imi {@link InputMethodInfo} to be used. + * @param subtype {@link InputMethodSubtype} to be used. + * @return A {@link InputMethodSubtypeHandle} object. + */ + @AnyThread + @NonNull + public static InputMethodSubtypeHandle of( + @NonNull InputMethodInfo imi, @Nullable InputMethodSubtype subtype) { + final int subtypeHashCode = + subtype != null ? subtype.hashCode() : InputMethodSubtype.SUBTYPE_ID_NONE; + return new InputMethodSubtypeHandle(encodeHandle(imi.getId(), subtypeHashCode)); + } + + /** + * Creates {@link InputMethodSubtypeHandle} from a {@link RawHandle} {@link String}, which can + * be obtained by {@link #toStringHandle()}. + * + * @param stringHandle {@link RawHandle} {@link String} to be parsed. + * @return A {@link InputMethodSubtypeHandle} object. + * @throws NullPointerException when {@code stringHandle} is {@code null} + * @throws InvalidParameterException when {@code stringHandle} is not a valid {@link RawHandle}. + */ + @AnyThread + @NonNull + public static InputMethodSubtypeHandle of(@RawHandle @NonNull String stringHandle) { + final SimpleStringSplitter splitter = new SimpleStringSplitter(DATA_SEPARATOR); + splitter.setString(Objects.requireNonNull(stringHandle)); + if (!splitter.hasNext()) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + final String imeId = splitter.next(); + final ComponentName componentName = ComponentName.unflattenFromString(imeId); + if (componentName == null) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + // TODO: Consolidate IME ID validation logic into one place. + if (!Objects.equals(componentName.flattenToShortString(), imeId)) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + if (!splitter.hasNext()) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + final String source = splitter.next(); + if (!Objects.equals(source, SUBTYPE_TAG)) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + if (!splitter.hasNext()) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + final String hashCodeStr = splitter.next(); + if (splitter.hasNext()) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + final int subtypeHashCode; + try { + subtypeHashCode = Integer.parseInt(hashCodeStr); + } catch (NumberFormatException ignore) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + + // Redundant expressions (e.g. "0001" instead of "1") are not allowed. + if (!Objects.equals(encodeHandle(imeId, subtypeHashCode), stringHandle)) { + throw new InvalidParameterException("Invalid handle=" + stringHandle); + } + + return new InputMethodSubtypeHandle(stringHandle); + } + + /** + * @return {@link ComponentName} of the input method. + * @see InputMethodInfo#getComponent() + */ + @AnyThread + @NonNull + public ComponentName getComponentName() { + return ComponentName.unflattenFromString(getImeId()); + } + + /** + * @return IME ID. + * @see InputMethodInfo#getId() + */ + @AnyThread + @NonNull + public String getImeId() { + return mHandle.substring(0, mHandle.indexOf(DATA_SEPARATOR)); + } + + /** + * @return {@link RawHandle} {@link String} data that should be stable and persistable. + * @see #of(String) + */ + @RawHandle + @AnyThread + @NonNull + public String toStringHandle() { + return mHandle; + } + + /** + * {@inheritDoc} + */ + @AnyThread + @Override + public boolean equals(Object obj) { + if (!(obj instanceof InputMethodSubtypeHandle)) { + return false; + } + final InputMethodSubtypeHandle that = (InputMethodSubtypeHandle) obj; + return Objects.equals(mHandle, that.mHandle); + } + + /** + * {@inheritDoc} + */ + @AnyThread + @Override + public int hashCode() { + return Objects.hashCode(mHandle); + } + + /** + * {@inheritDoc} + */ + @AnyThread + @NonNull + @Override + public String toString() { + return "InputMethodSubtypeHandle{mHandle=" + mHandle + "}"; + } + + /** + * {@link Creator} for parcelable. + */ + public static final Creator<InputMethodSubtypeHandle> CREATOR = new Creator<>() { + @Override + public InputMethodSubtypeHandle createFromParcel(Parcel in) { + return of(in.readString8()); + } + + @Override + public InputMethodSubtypeHandle[] newArray(int size) { + return new InputMethodSubtypeHandle[size]; + } + }; + + /** + * {@inheritDoc} + */ + @AnyThread + @Override + public int describeContents() { + return 0; + } + + /** + * {@inheritDoc} + */ + @AnyThread + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString8(toStringHandle()); + } +} diff --git a/core/tests/coretests/src/com/android/internal/inputmethod/InputMethodSubtypeHandleTest.java b/core/tests/coretests/src/com/android/internal/inputmethod/InputMethodSubtypeHandleTest.java new file mode 100644 index 000000000000..f111bf6fcd64 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/inputmethod/InputMethodSubtypeHandleTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2022 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.internal.inputmethod; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import android.annotation.NonNull; +import android.content.ComponentName; +import android.os.Parcel; +import android.platform.test.annotations.Presubmit; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodSubtype; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.security.InvalidParameterException; + +@SmallTest +@Presubmit +@RunWith(AndroidJUnit4.class) +public class InputMethodSubtypeHandleTest { + + @Test + public void testCreateFromRawHandle() { + { + final InputMethodSubtypeHandle handle = + InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1"); + assertNotNull(handle); + assertEquals("com.android.test/.Ime1:subtype:1", handle.toStringHandle()); + assertEquals("com.android.test/.Ime1", handle.getImeId()); + assertEquals(ComponentName.unflattenFromString("com.android.test/.Ime1"), + handle.getComponentName()); + } + + assertThrows(NullPointerException.class, () -> InputMethodSubtypeHandle.of(null)); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of("")); + + // The IME ID must use ComponentName#flattenToShortString(), not #flattenToString(). + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/com.android.test.Ime1:subtype:1")); + + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:0001")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:1!")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:1:")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:1:2")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:a")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:subtype:0x01")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "com.android.test/.Ime1:Subtype:a")); + assertThrows(InvalidParameterException.class, () -> InputMethodSubtypeHandle.of( + "ime1:subtype:1")); + } + + @Test + public void testCreateFromInputMethodInfo() { + final InputMethodInfo imi = new InputMethodInfo( + "com.android.test", "com.android.test.Ime1", "TestIME", null); + { + final InputMethodSubtypeHandle handle = InputMethodSubtypeHandle.of(imi, null); + assertNotNull(handle); + assertEquals("com.android.test/.Ime1:subtype:0", handle.toStringHandle()); + assertEquals("com.android.test/.Ime1", handle.getImeId()); + assertEquals(ComponentName.unflattenFromString("com.android.test/.Ime1"), + handle.getComponentName()); + } + + final InputMethodSubtype subtype = + new InputMethodSubtype.InputMethodSubtypeBuilder().setSubtypeId(1).build(); + { + final InputMethodSubtypeHandle handle = InputMethodSubtypeHandle.of(imi, subtype); + assertNotNull(handle); + assertEquals("com.android.test/.Ime1:subtype:1", handle.toStringHandle()); + assertEquals("com.android.test/.Ime1", handle.getImeId()); + assertEquals(ComponentName.unflattenFromString("com.android.test/.Ime1"), + handle.getComponentName()); + } + + assertThrows(NullPointerException.class, () -> InputMethodSubtypeHandle.of(null, null)); + assertThrows(NullPointerException.class, () -> InputMethodSubtypeHandle.of(null, subtype)); + } + + @Test + public void testEquality() { + assertEquals(InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1"), + InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1")); + assertEquals(InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1").hashCode(), + InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1").hashCode()); + + assertNotEquals(InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1"), + InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:2")); + assertNotEquals(InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1"), + InputMethodSubtypeHandle.of("com.android.test/.Ime2:subtype:1")); + } + + @Test + public void testParcelablility() { + final InputMethodSubtypeHandle original = + InputMethodSubtypeHandle.of("com.android.test/.Ime1:subtype:1"); + final InputMethodSubtypeHandle cloned = cloneHandle(original); + assertEquals(original, cloned); + assertEquals(original.hashCode(), cloned.hashCode()); + assertEquals(original.getComponentName(), cloned.getComponentName()); + assertEquals(original.getImeId(), cloned.getImeId()); + assertEquals(original.toStringHandle(), cloned.toStringHandle()); + } + + @Test + public void testNoUnnecessaryStringInstantiationInToStringHandle() { + final String validHandleStr = "com.android.test/.Ime1:subtype:1"; + // Verify that toStringHandle() returns the same String object if the input is valid for + // an efficient memory usage. + assertSame(validHandleStr, InputMethodSubtypeHandle.of(validHandleStr).toStringHandle()); + } + + @NonNull + private static InputMethodSubtypeHandle cloneHandle( + @NonNull InputMethodSubtypeHandle original) { + Parcel parcel = null; + try { + parcel = Parcel.obtain(); + original.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + return InputMethodSubtypeHandle.CREATOR.createFromParcel(parcel); + } finally { + if (parcel != null) { + parcel.recycle(); + } + } + } +} |