Better support for emoji in game-text-input

Refactored game-text-input tests and test app

Test: build.sh tests

Change-Id: Ia2184e4d736fda8855e1a28b7f12f19f460fc6b9
diff --git a/build.sh b/build.sh
index bc161c8..4344754 100755
--- a/build.sh
+++ b/build.sh
@@ -38,8 +38,6 @@
 fi
 echo yes | $sdkmanager_path "platform-tools" "platforms;android-35" "platforms;android-31" "build-tools;35.0.0"
 
-# cp -Rf samples/sdk_licenses ../prebuilts/sdk/licenses
-
 # Use the distribution path given to the script by the build bot in DIST_DIR. Otherwise,
 # build in the default location.
 if [[ -z $DIST_DIR ]]
@@ -96,6 +94,7 @@
     ./gradlew packageMavenZip -Plibraries=memory_advice   -PdistPath="$dist_dir" -PpackageName=$package_name
     ./gradlew jetpadJson -Plibraries=swappy,tuningfork,game_activity,game_text_input,paddleboat,memory_advice -PdistPath="$dist_dir" -PpackageName=$package_name
 fi
+
 if [[ $1 != "maven-only" ]]
 then
     mkdir -p "$dist_dir/$package_name/apks/samples"
@@ -106,7 +105,6 @@
     pushd ./samples/tuningfork/insightsdemo/
     ./gradlew ":app:assembleDebug"
     popd
-
     pushd ./samples/tuningfork/experimentsdemo/
     ./gradlew ":app:assembleDebug"
     popd
@@ -120,7 +118,6 @@
     pushd samples/bouncyball
     ./gradlew ":app:assembleDebug"
     popd
-
     pushd third_party/cube
     ./gradlew ":app:assembleDebug"
     popd
diff --git a/game-text-input/src/androidTest/java/com/google/android/gametextinput/ExampleInstrumentedTest.java b/game-text-input/src/androidTest/java/com/google/android/gametextinput/ExampleInstrumentedTest.java
deleted file mode 100644
index 107853b..0000000
--- a/game-text-input/src/androidTest/java/com/google/android/gametextinput/ExampleInstrumentedTest.java
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright (C) 2024 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.google.androidgamesdk.gametextinput.test;
-
-import static androidx.test.espresso.Espresso.onView;
-import static androidx.test.espresso.action.ViewActions.click;
-import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
-import static androidx.test.espresso.action.ViewActions.typeText;
-import static androidx.test.espresso.assertion.ViewAssertions.matches;
-import static androidx.test.espresso.matcher.ViewMatchers.withId;
-import static androidx.test.espresso.matcher.ViewMatchers.withText;
-import static org.junit.Assert.*;
-
-import androidx.test.ext.junit.rules.ActivityScenarioRule;
-import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
-import androidx.test.platform.app.InstrumentationRegistry;
-import com.google.androidgamesdk.gametextinput.test.R;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
- */
-@RunWith(AndroidJUnit4ClassRunner.class)
-public class ExampleInstrumentedTest {
-  @Test
-  public void testAppContext() {
-    // Context of the app under test.
-    android.content.Context appContext =
-        InstrumentationRegistry.getInstrumentation().getTargetContext();
-    assertEquals("com.google.androidgamesdk.gametextinput.test", appContext.getPackageName());
-  }
-}
diff --git a/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputEnabledTextView.java b/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputEnabledTextView.java
index c78a6d1..da67253 100644
--- a/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputEnabledTextView.java
+++ b/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputEnabledTextView.java
@@ -36,6 +36,8 @@
   public InputConnection mInputConnection;
   private MainActivity mMainActivity;
 
+  private State state = new State("", 0, 0, -1, -1);
+
   public InputEnabledTextView(Context context, AttributeSet attrs) {
     super(context, attrs);
   }
@@ -68,6 +70,9 @@
     }
     if (outAttrs != null) {
       GameTextInput.copyEditorInfo(mInputConnection.getEditorInfo(), outAttrs);
+      Log.d(LOG_TAG,
+          "onCreateInputConnection outAttrs = s:" + outAttrs.initialSelStart + "-"
+              + outAttrs.initialSelEnd);
     }
     return mInputConnection;
   }
@@ -75,10 +80,18 @@
   // Called when the IME has changed the input
   @Override
   public void stateChanged(State newState, boolean dismissed) {
-    Log.d(LOG_TAG, "stateChanged: " + newState + " dismissed: " + dismissed);
+    state = newState;
+    Log.d(LOG_TAG,
+        "stateChanged: " + newState.text + " s: " + newState.selectionStart + "-"
+            + newState.selectionEnd + " cr: " + newState.composingRegionStart + "-"
+            + newState.composingRegionEnd);
     onTextInputEvent(newState);
   }
 
+  public State getState() {
+    return state;
+  }
+
   @Override
   public void onEditorAction(int action) {
     Log.d(LOG_TAG, "onEditorAction: " + action);
@@ -95,11 +108,7 @@
   }
 
   private void onTextInputEvent(State state) {
-    mMainActivity.setDisplayedText(state.text);
-  }
-
-  public void enableSoftKeyboard() {
-    mInputConnection.setSoftKeyboardActive(true, 0);
+    mMainActivity.setDisplayedText(state.text, state.selectionStart, state.selectionEnd);
   }
 
   public InputConnection getInputConnection() {
diff --git a/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputTest.java b/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputTest.java
index d625bd1..e661b46 100644
--- a/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputTest.java
+++ b/game-text-input/src/androidTest/java/com/google/android/gametextinput/InputTest.java
@@ -30,38 +30,336 @@
 import static androidx.test.espresso.matcher.ViewMatchers.*;
 import static androidx.test.espresso.matcher.ViewMatchers.withId;
 import static androidx.test.espresso.matcher.ViewMatchers.withText;
-import static org.junit.Assert.*;
 
+import android.os.SystemClock;
+import android.util.Log;
 import android.view.KeyEvent;
 import android.view.View;
 import androidx.test.espresso.UiController;
 import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.ViewInteraction;
 import androidx.test.ext.junit.rules.ActivityScenarioRule;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import com.google.androidgamesdk.gametextinput.InputConnection;
-import com.google.androidgamesdk.gametextinput.test.MainActivity;
-import com.google.androidgamesdk.gametextinput.test.R;
-import java.util.concurrent.TimeUnit;
+import com.google.androidgamesdk.gametextinput.State;
+import com.google.common.collect.ImmutableList;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
 import org.hamcrest.Matcher;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
 
 /**
- * Instrumented test, which will execute on an Android device.
+ * GameTextInput Instrumented tests, which execute on an Android device.
  *
- * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ * To speedup test time tests are combined to groups.
  */
-@RunWith(AndroidJUnit4.class)
+@RunWith(Parameterized.class)
 @LargeTest
 public class InputTest {
-  static private final String INITIAL_VALUE = "this is test text";
+  static private final String TAG = "InputTest";
+  enum TestGroup {
+    UNKNOWN,
+    TYPE_TEXT,
+    KEY_CHARACTER,
+    KEY_DEL,
+    KEY_FORWARD_DEL,
+    KEY_DPAD_LEFT,
+    KEY_DPAD_RIGHT,
+    KEYCODE_MOVE_HOME,
+    KEYCODE_MOVE_END
+  }
+
+  @Target({ElementType.METHOD})
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface TestToCombine {
+    TestGroup group() default TestGroup.UNKNOWN;
+  }
+
+  public enum PressKeyImplementation { PRESS_KEY, ON_KEY_LISTENER }
+  ;
+
+  @Parameter(0) public PressKeyImplementation pressKeyImplementation;
+
+  @Parameters(name = "{0}")
+  public static ImmutableList<Object[]> parameters() {
+    ImmutableList.Builder<Object[]> listBuilder = new ImmutableList.Builder<>();
+    listBuilder.add(new Object[] {PressKeyImplementation.PRESS_KEY});
+    listBuilder.add(new Object[] {PressKeyImplementation.ON_KEY_LISTENER});
+    return listBuilder.build();
+  }
 
   @Rule
   public ActivityScenarioRule<MainActivity> activityScenarioRule =
       new ActivityScenarioRule<>(MainActivity.class);
 
+  @Before
+  public void setUp() {
+    onInputView().perform(activateSoftKeyboard());
+  }
+
+  @After
+  public void tearDown() {
+    onInputView().perform(deactivateSoftKeyboard());
+  }
+
+  @TestToCombine(group = TestGroup.TYPE_TEXT)
+  public void typeText_singleCaracter() {
+    onInputView().perform(typeText("c"));
+    checkResultText("c");
+  }
+
+  @TestToCombine(group = TestGroup.TYPE_TEXT)
+  public void typeText_phrase() {
+    onInputView().perform(typeText("123 456"));
+    checkResultText("123 456");
+  }
+
+  @TestToCombine(group = TestGroup.TYPE_TEXT)
+  public void typeText_selectAll_overwrites() {
+    onInputView().perform(typeText("abcdef"), selectText(0, 6), typeText("xyz"));
+    checkResultText("xyz");
+  }
+
+  @TestToCombine(group = TestGroup.TYPE_TEXT)
+  public void typeText_twice() {
+    onInputView().perform(typeText("abc"), typeText("def"));
+    checkResultText("abcdef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_CHARACTER)
+  public void keyCharacter_single() {
+    onInputView().perform(key(KeyEvent.KEYCODE_1));
+    checkResultText("1");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_CHARACTER)
+  public void keyCharacter_sequence() {
+    onInputView().perform(key(KeyEvent.KEYCODE_1), key(KeyEvent.KEYCODE_A), key(KeyEvent.KEYCODE_2),
+        key(KeyEvent.KEYCODE_Z), key(KeyEvent.KEYCODE_9), key(KeyEvent.KEYCODE_R));
+    checkResultText("1a2z9r");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_CHARACTER)
+  @Test(timeout = 100000)
+  public void keyCharacter_replaceSelection() {
+    onInputView().perform(
+        setState("BeginSelectionEnd", 5, 14), key(KeyEvent.KEYCODE_1), key(KeyEvent.KEYCODE_2));
+    checkResultText("Begin12End");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_CHARACTER)
+  public void keyCharacter_replaceSelectionAll() {
+    onInputView().perform(key(KeyEvent.KEYCODE_A), key(KeyEvent.KEYCODE_B), selectText(0, 2),
+        key(KeyEvent.KEYCODE_1), key(KeyEvent.KEYCODE_2));
+    checkResultText("12");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_atTheEnd() {
+    onInputView().perform(setState("abcdef", 6, 6), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("abcde");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_emoji() {
+    onInputView().perform(setState("abc\uD83D\uDE0Dd", 5, 5), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("abcd");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_firstCharacter() {
+    onInputView().perform(setState("abcdef", 1, 1), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("bcdef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_middleTwoCharacters() {
+    onInputView().perform(
+        setState("123ab456", 5, 5), key(KeyEvent.KEYCODE_DEL), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("123456");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_selection() {
+    onInputView().perform(setState("123sel456", 3, 6), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("123456");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_selectAll() {
+    onInputView().perform(setState("sel", 0, 3), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_atTheStart_noDeletion() {
+    onInputView().perform(setState("abc", 0, 0), key(KeyEvent.KEYCODE_DEL));
+
+    checkResultText("abc");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_selectionTwoCharacters() {
+    onInputView().perform(setState("abcdefgh", 6, 8), key(KeyEvent.KEYCODE_DEL));
+    checkResultText("abcdef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_selectionFirstTwoCharacters() {
+    onInputView().perform(setState("mncdef", 0, 2), key(KeyEvent.KEYCODE_DEL), typeText("ab"));
+    checkResultText("abcdef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DEL)
+  public void keyDel_selectionMiddle() {
+    onInputView().perform(setState("mncdef", 1, 4), key(KeyEvent.KEYCODE_DEL),
+        key(KeyEvent.KEYCODE_X), key(KeyEvent.KEYCODE_Y));
+    checkResultText("mxyef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_atTheEnd_noDeletion() {
+    onInputView().perform(setState("abc", 3, 3), key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("abc");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_twice() {
+    onInputView().perform(setState("abcdef", 3, 3), key(KeyEvent.KEYCODE_FORWARD_DEL),
+        key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("abcf");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_emoji() {
+    onInputView().perform(setState("abc\uD83D\uDE0Dd", 3, 3), key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("abcd");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_lastCharacter() {
+    onInputView().perform(setState("abcdef", 5, 5), key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("abcde");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_selectAll() {
+    onInputView().perform(setState("sel", 0, 3), key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_FORWARD_DEL)
+  public void keyForwardDel_selectionCases() {
+    onInputView().perform(setState("abcdefgh", 6, 8), key(KeyEvent.KEYCODE_FORWARD_DEL));
+    checkResultText("abcdef");
+
+    onInputView().perform(selectText(0, 2), key(KeyEvent.KEYCODE_FORWARD_DEL), typeText("mn"));
+    checkResultText("mncdef");
+
+    onInputView().perform(selectText(1, 4), key(KeyEvent.KEYCODE_FORWARD_DEL), typeText("xyz"));
+    checkResultText("mxyzef");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DPAD_LEFT)
+  public void keyDpadLeft_middle() {
+    onInputView().perform(
+        setState("abcdefgh", 6, 6), key(KeyEvent.KEYCODE_DPAD_LEFT), key(KeyEvent.KEYCODE_X));
+
+    checkResultText("abcdexfgh");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DPAD_RIGHT)
+  public void keyDpadLeft_emoji() {
+    onInputView().perform(setState("abc\uD83D\uDE0Dd", 5, 5), key(KeyEvent.KEYCODE_DPAD_LEFT),
+        key(KeyEvent.KEYCODE_1));
+    checkResultText("abc1\uD83D\uDE0Dd");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DPAD_RIGHT)
+  public void keyDpadRight_middle() {
+    onInputView().perform(
+        setState("abcdefgh", 6, 6), key(KeyEvent.KEYCODE_DPAD_RIGHT), key(KeyEvent.KEYCODE_X));
+
+    checkResultText("abcdefgxh");
+  }
+
+  @TestToCombine(group = TestGroup.KEY_DPAD_RIGHT)
+  public void keyDpadRight_emoji() {
+    onInputView().perform(setState("abc\uD83D\uDE0Dd", 3, 3), key(KeyEvent.KEYCODE_DPAD_RIGHT),
+        key(KeyEvent.KEYCODE_1));
+    checkResultText("abc\uD83D\uDE0D1d");
+  }
+
+  @TestToCombine(group = TestGroup.KEYCODE_MOVE_HOME)
+  public void keyMoveHome_fromMiddle() {
+    onInputView().perform(setState("abcdefgh", 3, 3), key(KeyEvent.KEYCODE_1),
+        key(KeyEvent.KEYCODE_MOVE_HOME), key(KeyEvent.KEYCODE_2));
+    checkResultText("2abc1defgh");
+  }
+
+  @TestToCombine(group = TestGroup.KEYCODE_MOVE_END)
+  public void keyMoveEnd_fromFirstChar() {
+    onInputView().perform(setState("abcdefgh", 0, 0), key(KeyEvent.KEYCODE_1),
+        key(KeyEvent.KEYCODE_MOVE_END), key(KeyEvent.KEYCODE_2));
+    checkResultText("1abcdefgh2");
+  }
+
+  @Test
+  public void runCombinedTests_type() {
+    runGroupTests(TestGroup.TYPE_TEXT, this.getClass());
+    runGroupTests(TestGroup.KEY_CHARACTER, this.getClass());
+  }
+
+  @Test
+  public void runCombinedTests_DeletionKeys() {
+    runGroupTests(TestGroup.KEY_DEL, this.getClass());
+    runGroupTests(TestGroup.KEY_FORWARD_DEL, this.getClass());
+  }
+
+  @Test
+  public void runCombinedTests_DpadKeys() {
+    runGroupTests(TestGroup.KEY_DPAD_LEFT, this.getClass());
+    runGroupTests(TestGroup.KEY_DPAD_RIGHT, this.getClass());
+    runGroupTests(TestGroup.KEYCODE_MOVE_HOME, this.getClass());
+    runGroupTests(TestGroup.KEYCODE_MOVE_END, this.getClass());
+  }
+
+  private ViewInteraction onInputView() {
+    return onView(withId(R.id.input_enabled_text_view));
+  }
+
+  private void resetBeforeTest() {
+    onInputView().perform(setState("", 0, 0));
+  }
+
+  private void checkResultText(String text) {
+    onInputView().perform(waitForIdle());
+    onView(withId(R.id.displayed_text)).check(matches(withText(text)));
+  }
+
+  private ViewAction key(int keyCode) {
+    if (pressKeyImplementation == PressKeyImplementation.PRESS_KEY) {
+      return pressKey(keyCode);
+    }
+    if (pressKeyImplementation == PressKeyImplementation.ON_KEY_LISTENER) {
+      return passKeyToOnKeyListener(keyCode);
+    }
+    return null;
+  }
+
   // Espresso doesn't support selecting text, so this action implements this ability.
   // Note that start = end means in Android this is just a text cursor.
   // For example, start = end = 3 means that there is no selection and the cursor
@@ -81,272 +379,166 @@
       @Override
       public void perform(UiController uiController, View view) {
         InputEnabledTextView inputView = (InputEnabledTextView) view;
-        InputConnection ic = inputView.getInputConnection();
+        State state = inputView.getState();
+        state.selectionStart = start;
+        state.selectionEnd = end;
 
-        ic.setSelection(start, end);
+        InputConnection ic = inputView.getInputConnection();
+        ic.setState(state);
+        uiController.loopMainThreadForAtLeast(500);
       }
     };
   }
 
-  //////////////////////////////////////////////////////////////////////////
-  // Below are tests. First group of tests is for simple text typing.
-  //////////////////////////////////////////////////////////////////////////
+  private ViewAction sleep(int time) {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-  @Test
-  public void canTypeCharOnSoftwareKeyboard() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+      @Override
+      public String getDescription() {
+        return "sleep";
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view)).perform(typeText("s"), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("s")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        uiController.loopMainThreadForAtLeast(time);
+      }
+    };
   }
 
-  @Test
-  public void canTypeTextOnSoftwareKeyboard() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+  private ViewAction activateSoftKeyboard() {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
+      @Override
+      public String getDescription() {
+        return "activateSoftKeyboard";
+      }
 
-    onView(withId(R.id.input_enabled_text_view)).perform(typeText("abcdef"), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcdef")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        InputEnabledTextView inputView = (InputEnabledTextView) view;
+        InputConnection ic = inputView.getInputConnection();
+        ic.setSoftKeyboardActive(true, 0);
+        uiController.loopMainThreadForAtLeast(1000);
+      }
+    };
   }
 
-  @Test
-  public void canTypeCharOnHardwareKeyboard() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+  private ViewAction deactivateSoftKeyboard() {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
+      @Override
+      public String getDescription() {
+        return "deactivateSoftKeyboard";
+      }
 
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(pressKey(KeyEvent.KEYCODE_1), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("1")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        InputEnabledTextView inputView = (InputEnabledTextView) view;
+        InputConnection ic = inputView.getInputConnection();
+        ic.setSoftKeyboardActive(false, 0);
+      }
+    };
   }
 
-  @Test
-  public void canTypeTextOnHardwareKeyboard() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+  private ViewAction waitForIdle() {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
+      @Override
+      public String getDescription() {
+        return "waitForIdle";
+      }
 
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(pressKey(KeyEvent.KEYCODE_1), pressKey(KeyEvent.KEYCODE_A),
-            pressKey(KeyEvent.KEYCODE_2), pressKey(KeyEvent.KEYCODE_Z),
-            pressKey(KeyEvent.KEYCODE_9), pressKey(KeyEvent.KEYCODE_R), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("1a2z9r")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        uiController.loopMainThreadUntilIdle();
+      }
+    };
   }
 
-  //////////////////////////////////////////////////////////////////////////
-  // Tests for Backspace and Delete buttons - single character.
-  //////////////////////////////////////////////////////////////////////////
+  private ViewAction setState(String text, int selectionStart, int selectionEnd) {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-  @Test
-  public void hardwareBackspaceAtTheEndWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+      @Override
+      public String getDescription() {
+        return "setState to: " + text + ", s: " + selectionStart + "-" + selectionEnd;
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), pressKey(KeyEvent.KEYCODE_DEL), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcde")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        InputEnabledTextView inputView = (InputEnabledTextView) view;
+        InputConnection ic = inputView.getInputConnection();
+        State state = new State(text, selectionStart, selectionEnd, -1, -1);
+        ic.setState(state);
+        uiController.loopMainThreadForAtLeast(500);
+      }
+    };
   }
 
-  @Test
-  public void hardwareBackspaceAtTheStartDoesNothing() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+  private ViewAction passKeyToOnKeyListener(int keyCode) {
+    return new ViewAction() {
+      @Override
+      public Matcher<View> getConstraints() {
+        return isDisplayed();
+      }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
+      @Override
+      public String getDescription() {
+        return "Select text";
+      }
 
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(
-            typeText("abc"), selectText(0, 0), pressKey(KeyEvent.KEYCODE_DEL), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abc")));
+      @Override
+      public void perform(UiController uiController, View view) {
+        InputEnabledTextView inputView = (InputEnabledTextView) view;
+        InputConnection ic = inputView.getInputConnection();
+        long eventTime = SystemClock.uptimeMillis();
+        KeyEvent downEvent =
+            new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, keyCode, 0, 0);
+        ic.onKey(view, keyCode, downEvent);
+        KeyEvent upEvent = new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, keyCode, 0, 0);
+        ic.onKey(view, keyCode, upEvent);
+      }
+    };
   }
 
-  @Test
-  public void hardwareDeleteAtTheEndDoesNothing() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
+  public void runGroupTests(TestGroup testGroup, Class<?> testClass) {
+    List<Method> testsToRun = new ArrayList<>();
+    for (Method method : testClass.getDeclaredMethods()) {
+      if (method.isAnnotationPresent(TestToCombine.class)) {
+        TestToCombine annotation = method.getAnnotation(TestToCombine.class);
+        if (annotation.group().equals(testGroup)) {
+          testsToRun.add(method);
+        }
+      }
+    }
 
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abc"), selectText(3, 3), pressKey(KeyEvent.KEYCODE_FORWARD_DEL),
-            closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abc")));
-  }
-
-  @Test
-  public void hardwareBackspaceInTheMiddleWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), selectText(1, 1), pressKey(KeyEvent.KEYCODE_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("bcdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(3, 3), pressKey(KeyEvent.KEYCODE_DEL), pressKey(KeyEvent.KEYCODE_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("bef")));
-
-    onView(withId(R.id.input_enabled_text_view)).perform(typeText("xyz"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("bxyzef")));
-  }
-
-  @Test
-  public void multipleHardwareDeletesWork() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcde"), selectText(3, 3), pressKey(KeyEvent.KEYCODE_FORWARD_DEL),
-            pressKey(KeyEvent.KEYCODE_FORWARD_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abc")));
-  }
-
-  @Test
-  public void hardwareDeleteInTheMiddleWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), selectText(1, 1), pressKey(KeyEvent.KEYCODE_FORWARD_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("acdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(3, 3), pressKey(KeyEvent.KEYCODE_FORWARD_DEL),
-            pressKey(KeyEvent.KEYCODE_FORWARD_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("acd")));
-
-    onView(withId(R.id.input_enabled_text_view)).perform(selectText(1, 1), typeText("xyz"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("axyzcd")));
-  }
-
-  //////////////////////////////////////////////////////////////////////////
-  // Tests for deletion of selected text.
-  //////////////////////////////////////////////////////////////////////////
-
-  @Test
-  public void selectAllWithOverwriteWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), selectText(0, 6), typeText("xyz"), closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("xyz")));
-  }
-
-  @Test
-  public void selectAllWithHardwareBackspaceWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), selectText(0, 6), pressKey(KeyEvent.KEYCODE_DEL),
-            closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("")));
-  }
-
-  @Test
-  public void selectAllWithHardwareDeleteWorks() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdef"), selectText(0, 6), pressKey(KeyEvent.KEYCODE_FORWARD_DEL),
-            closeSoftKeyboard());
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("")));
-  }
-
-  @Test
-  public void hardwareBackspaceWorksWithAnySelection() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdefgh"), selectText(6, 8), pressKey(KeyEvent.KEYCODE_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(0, 2), pressKey(KeyEvent.KEYCODE_DEL), typeText("mn"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("mncdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(1, 4), pressKey(KeyEvent.KEYCODE_DEL), typeText("xyz"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("mxyzef")));
-  }
-
-  @Test
-  public void hardwareDeleteWorksWithAnySelection() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdefgh"), selectText(6, 8), pressKey(KeyEvent.KEYCODE_FORWARD_DEL));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(0, 2), pressKey(KeyEvent.KEYCODE_FORWARD_DEL), typeText("mn"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("mncdef")));
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(selectText(1, 4), pressKey(KeyEvent.KEYCODE_FORWARD_DEL), typeText("xyz"));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("mxyzef")));
-  }
-
-  @Test
-  public void pressingLeftMovesCursorLeft() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdefgh"), selectText(6, 6), pressKey(KeyEvent.KEYCODE_DPAD_LEFT),
-            pressKey(KeyEvent.KEYCODE_X));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcdexfgh")));
-  }
-
-  @Test
-  public void pressingRightMovesCursorRight() {
-    onView(withId(R.id.displayed_text)).check(matches(withText(INITIAL_VALUE)));
-
-    activityScenarioRule.getScenario().onActivity(activity -> activity.enableSoftKeyboard());
-
-    onView(withId(R.id.input_enabled_text_view))
-        .perform(typeText("abcdefgh"), selectText(6, 6), pressKey(KeyEvent.KEYCODE_DPAD_RIGHT),
-            pressKey(KeyEvent.KEYCODE_X));
-
-    onView(withId(R.id.displayed_text)).check(matches(withText("abcdefgxh")));
+    for (Method test : testsToRun) {
+      Log.d(TAG, "Executing test: " + test.getName());
+      try {
+        resetBeforeTest();
+        test.invoke(this);
+      } catch (InvocationTargetException | IllegalAccessException e) {
+        throw new RuntimeException(e);
+      }
+    }
   }
 }
diff --git a/game-text-input/src/androidTest/java/com/google/android/gametextinput/MainActivity.java b/game-text-input/src/androidTest/java/com/google/android/gametextinput/MainActivity.java
index d48109e..3aaafaf 100644
--- a/game-text-input/src/androidTest/java/com/google/android/gametextinput/MainActivity.java
+++ b/game-text-input/src/androidTest/java/com/google/android/gametextinput/MainActivity.java
@@ -15,15 +15,18 @@
  */
 package com.google.androidgamesdk.gametextinput.test;
 
+import android.graphics.Color;
 import android.os.Bundle;
 import android.text.InputType;
-import android.view.inputmethod.EditorInfo;
-import android.widget.Button;
+import android.text.SpannableString;
+import android.text.style.BackgroundColorSpan;
+import android.util.Log;
 import android.widget.TextView;
+import androidx.activity.EdgeToEdge;
 import androidx.appcompat.app.AppCompatActivity;
-import com.google.androidgamesdk.gametextinput.test.R;
-
-// import com.google.androidgamesdk.gametextinput.InputConnection;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
 
 public class MainActivity extends AppCompatActivity {
   InputEnabledTextView inputEnabledTextView;
@@ -32,8 +35,9 @@
   @Override
   protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
+    EdgeToEdge.enable(this);
     setContentView(R.layout.activity_main);
-
+    applyInsets();
     inputEnabledTextView = (InputEnabledTextView) findViewById(R.id.input_enabled_text_view);
     assert (inputEnabledTextView != null);
 
@@ -43,15 +47,20 @@
     inputEnabledTextView.createInputConnection(InputType.TYPE_CLASS_TEXT, this);
   }
 
-  public void setDisplayedText(String text) {
-    displayedText.setText(text);
+  public void setDisplayedText(String text, int selectionStart, int selectionEnd) {
+    SpannableString str = new SpannableString(text);
+
+    if (selectionStart != selectionEnd) {
+      str.setSpan(new BackgroundColorSpan(Color.YELLOW), selectionStart, selectionEnd, 0);
+    }
+    displayedText.setText(str);
   }
 
-  public String getTestString() {
-    return "abc";
-  }
-
-  public void enableSoftKeyboard() {
-    inputEnabledTextView.enableSoftKeyboard();
+  private void applyInsets() {
+    ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
+      Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+      v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+      return insets;
+    });
   }
 }
diff --git a/game-text-input/src/androidTest/res/layout/activity_main.xml b/game-text-input/src/androidTest/res/layout/activity_main.xml
index 0b77257..07e042b 100644
--- a/game-text-input/src/androidTest/res/layout/activity_main.xml
+++ b/game-text-input/src/androidTest/res/layout/activity_main.xml
@@ -1,24 +1,24 @@
 <?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/main"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
     tools:context=".MainActivity">
 
-    <com.google.androidgamesdk.gametextinput.test.InputEnabledTextView
-        android:id="@+id/input_enabled_text_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="this is test input"
-        />
-
     <TextView
         android:id="@+id/displayed_text"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_marginTop="16dp"
-        android:text="this is test text"
-        android:textAppearance="@style/TextAppearance.AppCompat.Large" />
+        android:textAppearance="@style/TextAppearance.AppCompat.Large"
+        android:text="this is test text"/>
+    <com.google.androidgamesdk.gametextinput.test.InputEnabledTextView
+        android:id="@+id/input_enabled_text_view"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:layout_constraintTop_toBottomOf="@+id/displayed_text"
+        />
 
-</LinearLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/game-text-input/src/androidTest/res/values-night/styles.xml b/game-text-input/src/androidTest/res/values-night/styles.xml
deleted file mode 100644
index f9e288b..0000000
--- a/game-text-input/src/androidTest/res/values-night/styles.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<resources>
-
-    <style name="Widget.Theme.GameTextInputTest.MyView" parent="">
-        <item name="android:background">@color/gray_600</item>
-        <item name="exampleColor">@color/light_blue_600</item>
-    </style>
-</resources>
diff --git a/game-text-input/src/androidTest/res/values-night/themes.xml b/game-text-input/src/androidTest/res/values-night/themes.xml
index 694e50d..6cc788f 100644
--- a/game-text-input/src/androidTest/res/values-night/themes.xml
+++ b/game-text-input/src/androidTest/res/values-night/themes.xml
@@ -1,16 +1,4 @@
 <resources xmlns:tools="http://schemas.android.com/tools">
     <!-- Base application theme. -->
-    <style name="Theme.GameTextInputTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
-        <!-- Primary brand color. -->
-        <item name="colorPrimary">@color/purple_200</item>
-        <item name="colorPrimaryVariant">@color/purple_700</item>
-        <item name="colorOnPrimary">@color/black</item>
-        <!-- Secondary brand color. -->
-        <item name="colorSecondary">@color/teal_200</item>
-        <item name="colorSecondaryVariant">@color/teal_200</item>
-        <item name="colorOnSecondary">@color/black</item>
-        <!-- Status bar color. -->
-        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
-        <!-- Customize your theme here. -->
-    </style>
+    <style name="Theme.GameTextInputTest" parent="Theme.Material3.DayNight"/>
 </resources>
diff --git a/game-text-input/src/androidTest/res/values/attrs_input_enabled_text_view.xml b/game-text-input/src/androidTest/res/values/attrs_input_enabled_text_view.xml
deleted file mode 100644
index d5523d5..0000000
--- a/game-text-input/src/androidTest/res/values/attrs_input_enabled_text_view.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<resources>
-    <declare-styleable name="InputEnabledTextView">
-        <attr name="exampleString" format="string" />
-        <attr name="exampleDimension" format="dimension" />
-        <attr name="exampleColor" format="color" />
-        <attr name="exampleDrawable" format="color|reference" />
-    </declare-styleable>
-</resources>
\ No newline at end of file
diff --git a/game-text-input/src/androidTest/res/values/colors.xml b/game-text-input/src/androidTest/res/values/colors.xml
deleted file mode 100644
index 66e3f6c..0000000
--- a/game-text-input/src/androidTest/res/values/colors.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<resources>
-    <color name="purple_200">#FFBB86FC</color>
-    <color name="purple_500">#FF6200EE</color>
-    <color name="purple_700">#FF3700B3</color>
-    <color name="teal_200">#FF03DAC5</color>
-    <color name="teal_700">#FF018786</color>
-    <color name="black">#FF000000</color>
-    <color name="white">#FFFFFFFF</color>
-    <color name="light_blue_400">#FF29B6F6</color>
-    <color name="light_blue_600">#FF039BE5</color>
-    <color name="gray_400">#FFBDBDBD</color>
-    <color name="gray_600">#FF757575</color>
-</resources>
\ No newline at end of file
diff --git a/game-text-input/src/androidTest/res/values/styles.xml b/game-text-input/src/androidTest/res/values/styles.xml
deleted file mode 100644
index b89766f..0000000
--- a/game-text-input/src/androidTest/res/values/styles.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<resources>
-
-    <style name="Widget.Theme.GameTextInputTest.MyView" parent="">
-        <item name="android:background">@color/gray_400</item>
-        <item name="exampleColor">@color/light_blue_400</item>
-    </style>
-</resources>
diff --git a/game-text-input/src/androidTest/res/values/themes.xml b/game-text-input/src/androidTest/res/values/themes.xml
index e82ce08..6cc788f 100644
--- a/game-text-input/src/androidTest/res/values/themes.xml
+++ b/game-text-input/src/androidTest/res/values/themes.xml
@@ -1,16 +1,4 @@
 <resources xmlns:tools="http://schemas.android.com/tools">
     <!-- Base application theme. -->
-    <style name="Theme.GameTextInputTest" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
-        <!-- Primary brand color. -->
-        <item name="colorPrimary">@color/purple_500</item>
-        <item name="colorPrimaryVariant">@color/purple_700</item>
-        <item name="colorOnPrimary">@color/white</item>
-        <!-- Secondary brand color. -->
-        <item name="colorSecondary">@color/teal_200</item>
-        <item name="colorSecondaryVariant">@color/teal_700</item>
-        <item name="colorOnSecondary">@color/black</item>
-        <!-- Status bar color. -->
-        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
-        <!-- Customize your theme here. -->
-    </style>
+    <style name="Theme.GameTextInputTest" parent="Theme.Material3.DayNight"/>
 </resources>
diff --git a/game-text-input/src/main/java/com/google/androidgamesdk/gametextinput/InputConnection.java b/game-text-input/src/main/java/com/google/androidgamesdk/gametextinput/InputConnection.java
index c70a297..c95f468 100644
--- a/game-text-input/src/main/java/com/google/androidgamesdk/gametextinput/InputConnection.java
+++ b/game-text-input/src/main/java/com/google/androidgamesdk/gametextinput/InputConnection.java
@@ -124,7 +124,7 @@
    * after calling setEditorInfo.
    */
   public void restartInput() {
-    this.imm.restartInput(targetView);
+    imm.restartInput(targetView);
   }
 
   /**
@@ -153,8 +153,8 @@
       this.imm.showSoftInput(this.targetView, flags);
     } else {
       this.imm.hideSoftInputFromWindow(this.targetView.getWindowToken(), flags);
-      imm.restartInput(targetView);
     }
+    restartInput();
   }
 
   /**
@@ -173,7 +173,7 @@
    */
   public final void setEditorInfo(EditorInfo editorInfo) {
     Log.d(TAG, "setEditorInfo");
-    this.settings.mEditorInfo = editorInfo;
+    settings.mEditorInfo = editorInfo;
 
     // Depending on the multiline state, we might need a different set of filters.
     // Filters are being used to filter specific characters for hardware keyboards
@@ -201,10 +201,13 @@
             + state.selectionEnd + "), composing region=(" + state.composingRegionStart + ","
             + state.composingRegionEnd + ")");
     mEditable.clear();
+    mEditable.clearSpans();
     mEditable.insert(0, (CharSequence) state.text);
     setSelection(state.selectionStart, state.selectionEnd);
-    setComposingRegion(state.composingRegionStart, state.composingRegionEnd);
-    informIMM();
+    if (state.composingRegionStart != state.composingRegionEnd) {
+      setComposingRegion(state.composingRegionStart, state.composingRegionEnd);
+    }
+    restartInput();
   }
 
   /**
@@ -235,7 +238,15 @@
       return false;
     }
     // Don't call sendKeyEvent as it might produce an infinite loop.
-    return processKeyEvent(keyEvent);
+    if (processKeyEvent(keyEvent)) {
+      // IMM seems to cache the content of Editable, so we update it with restartInput
+      // Also it caches selection and composing region, so let's notify it about updates.
+      stateUpdated();
+      immUpdateSelection();
+      restartInput();
+      return true;
+    }
+    return false;
   }
 
   // From BaseInputConnection
@@ -263,7 +274,6 @@
     return super.setComposingText(text, newCursorPosition);
   }
 
-  // From BaseInputConnection
   @Override
   public boolean setComposingRegion(int start, int end) {
     Log.d(TAG, "setComposingRegion: " + start + ":" + end);
@@ -395,14 +405,15 @@
     return super.performPrivateCommand(action, data);
   }
 
-  private void informIMM() {
+  private void immUpdateSelection() {
     Pair selection = this.getSelection();
     Pair cr = this.getComposingRegion();
     Log.d(TAG,
-        "informIMM: " + selection.first + "," + selection.second + ". " + cr.first + ","
+        "immUpdateSelection: " + selection.first + "," + selection.second + ". " + cr.first + ","
             + cr.second);
-    this.imm.updateSelection(
-        this.targetView, selection.first, selection.second, cr.first, cr.second);
+    settings.mEditorInfo.initialSelStart = selection.first;
+    settings.mEditorInfo.initialSelEnd = selection.second;
+    imm.updateSelection(targetView, selection.first, selection.second, cr.first, cr.second);
   }
 
   private Pair getSelection() {
@@ -417,19 +428,18 @@
     if (event == null) {
       return false;
     }
-    Log.d(TAG,
-        String.format(
-            "processKeyEvent(key=%d) text=%s", event.getKeyCode(), this.mEditable.toString()));
+    int keyCode = event.getKeyCode();
+    Log.d(
+        TAG, String.format("processKeyEvent(key=%d) text=%s", keyCode, this.mEditable.toString()));
     // Filter out Enter keys if multi-line mode is disabled.
     if ((settings.mEditorInfo.inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) == 0
-        && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
-            || event.getKeyCode() == KeyEvent.KEYCODE_NUMPAD_ENTER)
+        && (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)
         && event.hasNoModifiers()) {
       sendEditorAction(settings.mEditorInfo.actionId);
       return true;
     }
-    if (event.getAction() != 0) {
-      return true;
+    if (event.getAction() != KeyEvent.ACTION_DOWN) {
+      return false;
     }
     // If no selection is set, move the selection to the end.
     // This is the case when first typing on keys when the selection is not set.
@@ -441,75 +451,83 @@
       selection.second = this.mEditable.length();
     }
 
-    boolean modified = false;
-
-    if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) {
+    if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
       if (selection.first == selection.second) {
-        setSelection(selection.first - 1, selection.second - 1);
+        int newIndex = findIndexBackward(mEditable, selection.first, 1);
+        setSelection(newIndex, newIndex);
       } else {
         setSelection(selection.first, selection.first);
       }
       return true;
-    } else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) {
+    }
+    if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
       if (selection.first == selection.second) {
-        setSelection(selection.first + 1, selection.second + 1);
+        int newIndex = findIndexForward(mEditable, selection.second, 1);
+        setSelection(newIndex, newIndex);
       } else {
         setSelection(selection.second, selection.second);
       }
       return true;
-    } else if (selection.first != selection.second) {
-      Log.d(TAG, String.format("processKeyEvent: deleting selection"));
-      this.mEditable.delete(selection.first, selection.second);
-      modified = true;
-    } else if (event.getKeyCode() == KeyEvent.KEYCODE_DEL && selection.first > 0) {
-      this.mEditable.delete(selection.first - 1, selection.first);
-      this.stateUpdated();
-      Log.d(TAG,
-          String.format("processKeyEvent: exit after DEL, text=%s", this.mEditable.toString()));
-      return true;
-    } else if (event.getKeyCode() == KeyEvent.KEYCODE_FORWARD_DEL
-        && selection.first < this.mEditable.length()) {
-      this.mEditable.delete(selection.first, selection.first + 1);
-      this.stateUpdated();
-      Log.d(TAG,
-          String.format(
-              "processKeyEvent: exit after FORWARD_DEL, text=%s", this.mEditable.toString()));
+    }
+    if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
+      setSelection(0, 0);
       return true;
     }
-
-    int code = event.getKeyCode();
-    if (event.getUnicodeChar() != 0) {
-      String charsToInsert = Character.toString((char) event.getUnicodeChar());
-      this.mEditable.insert(selection.first, (CharSequence) charsToInsert);
-      int length = this.mEditable.length();
-
-      // Same logic as in setComposingText(): we must update composing region,
-      // so make sure it points to a valid range.
-      Pair composingRegion = this.getComposingRegion();
-      if (composingRegion.first == -1) {
-        composingRegion = this.getSelection();
-        if (composingRegion.first == -1) {
-          composingRegion = new Pair(0, 0);
+    if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
+      setSelection(this.mEditable.length(), this.mEditable.length());
+      return true;
+    }
+    if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+      if (selection.first != selection.second) {
+        this.mEditable.delete(selection.first, selection.second);
+        return true;
+      }
+      if (keyCode == KeyEvent.KEYCODE_DEL) {
+        if (selection.first > 0) {
+          finishComposingText();
+          deleteSurroundingTextInCodePoints(1, 0);
+          return true;
         }
       }
-
-      // IMM seems to cache the content of Editable, so we update it with restartInput
-      // Also it caches selection and composing region, so let's notify it about updates.
-      composingRegion.second = composingRegion.first + length;
-      this.setComposingRegion(composingRegion.first, composingRegion.second);
-      int new_cursor = selection.first + charsToInsert.length();
-      setSelection(new_cursor, new_cursor);
-      this.informIMM();
-      this.restartInput();
-      modified = true;
+      if (keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+        if (selection.first < this.mEditable.length()) {
+          finishComposingText();
+          deleteSurroundingTextInCodePoints(0, 1);
+          return true;
+        }
+      }
+      return false;
     }
 
-    if (modified) {
-      Log.d(TAG, String.format("processKeyEvent: exit, text=%s", this.mEditable.toString()));
-      this.stateUpdated();
+    if (event.getUnicodeChar() == 0) {
+      return false;
     }
 
-    return modified;
+    if (selection.first != selection.second) {
+      Log.d(TAG, String.format("processKeyEvent: deleting selection"));
+      this.mEditable.delete(selection.first, selection.second);
+    }
+
+    String charsToInsert = Character.toString((char) event.getUnicodeChar());
+    this.mEditable.insert(selection.first, (CharSequence) charsToInsert);
+    int length = this.mEditable.length();
+
+    // Same logic as in setComposingText(): we must update composing region,
+    // so make sure it points to a valid range.
+    Pair composingRegion = this.getComposingRegion();
+    if (composingRegion.first == -1) {
+      composingRegion = this.getSelection();
+      if (composingRegion.first == -1) {
+        composingRegion = new Pair(0, 0);
+      }
+    }
+
+    composingRegion.second = composingRegion.first + length;
+    this.setComposingRegion(composingRegion.first, composingRegion.second);
+    int new_cursor = selection.first + charsToInsert.length();
+    setSelection(new_cursor, new_cursor);
+    Log.d(TAG, String.format("processKeyEvent: exit, text=%s", this.mEditable.toString()));
+    return true;
   }
 
   private final void stateUpdated() {
@@ -517,6 +535,8 @@
     Pair cr = this.getComposingRegion();
     State state = new State(
         this.mEditable.toString(), selection.first, selection.second, cr.first, cr.second);
+    settings.mEditorInfo.initialSelStart = selection.first;
+    settings.mEditorInfo.initialSelEnd = selection.second;
 
     // Keep a reference to the listener to avoid a race condition when setting the listener.
     Listener listener = this.listener;
@@ -597,4 +617,98 @@
     }
     return false;
   }
+
+  private static int INVALID_INDEX = -1;
+  // Implementation copy from BaseInputConnection
+  private static int findIndexBackward(
+      final CharSequence cs, final int from, final int numCodePoints) {
+    int currentIndex = from;
+    boolean waitingHighSurrogate = false;
+    final int N = cs.length();
+    if (currentIndex < 0 || N < currentIndex) {
+      return INVALID_INDEX; // The starting point is out of range.
+    }
+    if (numCodePoints < 0) {
+      return INVALID_INDEX; // Basically this should not happen.
+    }
+    int remainingCodePoints = numCodePoints;
+    while (true) {
+      if (remainingCodePoints == 0) {
+        return currentIndex; // Reached to the requested length in code points.
+      }
+
+      --currentIndex;
+      if (currentIndex < 0) {
+        if (waitingHighSurrogate) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        return 0; // Reached to the beginning of the text w/o any invalid surrogate pair.
+      }
+      final char c = cs.charAt(currentIndex);
+      if (waitingHighSurrogate) {
+        if (!java.lang.Character.isHighSurrogate(c)) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        waitingHighSurrogate = false;
+        --remainingCodePoints;
+        continue;
+      }
+      if (!java.lang.Character.isSurrogate(c)) {
+        --remainingCodePoints;
+        continue;
+      }
+      if (java.lang.Character.isHighSurrogate(c)) {
+        return INVALID_INDEX; // A invalid surrogate pair is found.
+      }
+      waitingHighSurrogate = true;
+    }
+  }
+
+  // Implementation copy from BaseInputConnection
+  private static int findIndexForward(
+      final CharSequence cs, final int from, final int numCodePoints) {
+    int currentIndex = from;
+    boolean waitingLowSurrogate = false;
+    final int N = cs.length();
+    if (currentIndex < 0 || N < currentIndex) {
+      return INVALID_INDEX; // The starting point is out of range.
+    }
+    if (numCodePoints < 0) {
+      return INVALID_INDEX; // Basically this should not happen.
+    }
+    int remainingCodePoints = numCodePoints;
+
+    while (true) {
+      if (remainingCodePoints == 0) {
+        return currentIndex; // Reached to the requested length in code points.
+      }
+
+      if (currentIndex >= N) {
+        if (waitingLowSurrogate) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        return N; // Reached to the end of the text w/o any invalid surrogate pair.
+      }
+      final char c = cs.charAt(currentIndex);
+      if (waitingLowSurrogate) {
+        if (!java.lang.Character.isLowSurrogate(c)) {
+          return INVALID_INDEX; // An invalid surrogate pair is found.
+        }
+        --remainingCodePoints;
+        waitingLowSurrogate = false;
+        ++currentIndex;
+        continue;
+      }
+      if (!java.lang.Character.isSurrogate(c)) {
+        --remainingCodePoints;
+        ++currentIndex;
+        continue;
+      }
+      if (java.lang.Character.isLowSurrogate(c)) {
+        return INVALID_INDEX; // A invalid surrogate pair is found.
+      }
+      waitingLowSurrogate = true;
+      ++currentIndex;
+    }
+  }
 }
diff --git a/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/InputEnabledTextView.java b/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/InputEnabledTextView.java
index 9ec701d..561ea02 100644
--- a/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/InputEnabledTextView.java
+++ b/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/InputEnabledTextView.java
@@ -73,7 +73,10 @@
   // Called when the IME has changed the input
   @Override
   public void stateChanged(State newState, boolean dismissed) {
-    Log.d(LOG_TAG, "stateChanged: " + newState + " dismissed: " + dismissed);
+    Log.d(LOG_TAG, "stateChanged: " + newState.text
+        + ", s: " + newState.selectionStart + "-" + newState.selectionEnd
+        + ", cr: " + newState.composingRegionStart + "-" + newState.composingRegionEnd
+    );
     onTextInputEventNative(newState);
   }
 
diff --git a/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/MainActivity.java b/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/MainActivity.java
index 4d037bf..825d9eb 100644
--- a/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/MainActivity.java
+++ b/samples/game_text_input/game_text_input_testbed/app/src/main/java/com/gameinput/testbed/MainActivity.java
@@ -95,7 +95,6 @@
     SpannableString str = new SpannableString(text);
 
     if (selectionStart != selectionEnd) {
-      Log.e("main", String.format("selection: %d to %d", selectionStart, selectionEnd));
       str.setSpan(new BackgroundColorSpan(Color.YELLOW), selectionStart, selectionEnd, 0);
     }