Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package com.maxistar.textpad.test;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import android.content.Context;
import android.graphics.Insets;
import android.graphics.Rect;
import android.os.Build;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;

import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;

import com.maxistar.textpad.R;
import com.maxistar.textpad.ServiceLocator;
import com.maxistar.textpad.activities.EditorActivity;
import com.maxistar.textpad.service.SettingsService;

import org.junit.After;
import org.junit.Assume;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicBoolean;

@RunWith(AndroidJUnit4.class)
public class EditorImeInsetsTest {
private static final long IME_TIMEOUT_MILLIS = 5000;

@After
public void restoreStandardScrolling() {
setSimpleScrolling(false);
}

@Test
public void bothLayoutsKeepCaretAboveImeAndRestoreViewport() {
Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM);
setSimpleScrolling(false);

try (ActivityScenario<EditorActivity> scenario = ActivityScenario.launch(EditorActivity.class)) {
verifyCurrentLayout(scenario, R.id.vscroll);

setSimpleScrolling(true);
scenario.onActivity(this::clearChangedStateForLayoutRecreation);
scenario.recreate();
verifyCurrentLayout(scenario, R.id.linear_layout);

hideImeAndAssertViewportRestored(scenario);
}
}

private void verifyCurrentLayout(
ActivityScenario<EditorActivity> scenario,
int expectedLayoutViewId
) {
scenario.onActivity(activity -> {
assertTrue("Expected editor layout must be active",
activity.findViewById(expectedLayoutViewId) != null);
EditText editor = activity.findViewById(R.id.editText1);
editor.setText(longDocument());
editor.setSelection(editor.length());
editor.requestFocus();
editor.performClick();
showIme(activity, editor);
});

waitForImeVisibility(scenario, true);
onView(withId(R.id.editText1)).perform(typeText("text typed with the keyboard visible"));
scenario.onActivity(activity -> {
View editorRoot = activity.findViewById(R.id.editor_root);
EditText editor = activity.findViewById(R.id.editText1);
WindowInsets windowInsets = editorRoot.getRootWindowInsets();
Insets bars = windowInsets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()
);
Insets ime = windowInsets.getInsets(WindowInsets.Type.ime());

assertEquals(Math.max(bars.bottom, ime.bottom), editorRoot.getPaddingBottom());
assertCaretAboveIme(activity, editor, ime.bottom);
});
}

private void hideImeAndAssertViewportRestored(ActivityScenario<EditorActivity> scenario) {
scenario.onActivity(activity -> {
EditText editor = activity.findViewById(R.id.editText1);
InputMethodManager inputMethodManager =
(InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(editor.getWindowToken(), 0);
});

waitForImeVisibility(scenario, false);
scenario.onActivity(activity -> {
View editorRoot = activity.findViewById(R.id.editor_root);
WindowInsets windowInsets = editorRoot.getRootWindowInsets();
Insets bars = windowInsets.getInsets(
WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout()
);
assertEquals(bars.bottom, editorRoot.getPaddingBottom());
});
}

private void assertCaretAboveIme(EditorActivity activity, EditText editor, int imeBottom) {
int line = editor.getLayout().getLineForOffset(editor.getSelectionStart());
int[] editorLocation = new int[2];
int[] decorLocation = new int[2];
editor.getLocationOnScreen(editorLocation);
activity.getWindow().getDecorView().getLocationOnScreen(decorLocation);

int caretBottom = editorLocation[1]
+ editor.getTotalPaddingTop()
+ editor.getLayout().getLineBottom(line)
- editor.getScrollY();
int imeTop = decorLocation[1]
+ activity.getWindow().getDecorView().getHeight()
- imeBottom;
Rect visibleEditor = new Rect();

assertTrue("The focused editor must have a visible region", editor.getGlobalVisibleRect(visibleEditor));
assertTrue("The editor viewport must end above the IME: visibleBottom="
+ visibleEditor.bottom + ", imeTop=" + imeTop, visibleEditor.bottom <= imeTop);
assertTrue("The caret must remain inside the visible editor viewport: caretBottom="
+ caretBottom + ", visibleBottom=" + visibleEditor.bottom
+ ", editorTop=" + editorLocation[1] + ", scrollY=" + editor.getScrollY(),
caretBottom <= visibleEditor.bottom);
}

private void waitForImeVisibility(
ActivityScenario<EditorActivity> scenario,
boolean expectedVisible
) {
long deadline = SystemClock.uptimeMillis() + IME_TIMEOUT_MILLIS;
while (SystemClock.uptimeMillis() < deadline) {
AtomicBoolean matches = new AtomicBoolean(false);
scenario.onActivity(activity -> {
View editorRoot = activity.findViewById(R.id.editor_root);
WindowInsets windowInsets = editorRoot.getRootWindowInsets();
matches.set(windowInsets != null
&& windowInsets.isVisible(WindowInsets.Type.ime()) == expectedVisible);
if (expectedVisible && !matches.get() && activity.hasWindowFocus()) {
EditText editor = activity.findViewById(R.id.editText1);
editor.requestFocus();
showIme(activity, editor);
}
});
if (matches.get()) {
return;
}
SystemClock.sleep(100);
}
assertTrue("IME visibility did not become " + expectedVisible, false);
}

private void showIme(EditorActivity activity, EditText editor) {
WindowInsetsController controller = editor.getWindowInsetsController();
if (controller != null) {
controller.show(WindowInsets.Type.ime());
}
InputMethodManager inputMethodManager =
(InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
inputMethodManager.restartInput(editor);
inputMethodManager.showSoftInput(editor, InputMethodManager.SHOW_FORCED);
}

private void clearChangedStateForLayoutRecreation(EditorActivity activity) {
try {
Field changed = EditorActivity.class.getDeclaredField("changed");
changed.setAccessible(true);
changed.setBoolean(activity, false);
} catch (ReflectiveOperationException exception) {
throw new AssertionError("Unable to prepare editor layout recreation", exception);
}
}

private String longDocument() {
StringBuilder text = new StringBuilder();
for (int line = 0; line < 120; line++) {
text.append("Document line ").append(line).append('\n');
}
return text.toString();
}

private void setSimpleScrolling(boolean enabled) {
Context context = ApplicationProvider.getApplicationContext();
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putBoolean(SettingsService.SETTING_USE_SIMPLE_SCROLLING, enabled)
.commit();
ServiceLocator.getInstance().getSettingsService(context).reloadSettings(context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static androidx.test.espresso.Espresso.pressBack;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.junit.Assert.assertEquals;
Expand Down Expand Up @@ -106,6 +107,7 @@ public void changedUntitledDocumentCanBeRestoredAfterRecreation() {
editor.setText(content);
editor.setSelection(1234);
});
android.os.SystemClock.sleep(800);

scenario.recreate();
onView(withText(R.string.Restore)).perform(click());
Expand Down Expand Up @@ -140,6 +142,24 @@ public void dismissingRecoveryKeepsDraft() throws Exception {
}
}

@Test
public void pendingRecoveryDecisionCannotOverwriteDraftDuringRecreation() throws Exception {
String content = "recoverable after recreation";
try (ActivityScenario<EditorActivity> scenario = ActivityScenario.launch(EditorActivity.class)) {
scenario.onActivity(activity -> ((EditText) activity.findViewById(R.id.editText1)).setText(content));
Thread.sleep(800);
scenario.recreate();
onView(withText(R.string.Restore)).inRoot(isDialog())
.check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed()));
pressBack();
scenario.moveToState(Lifecycle.State.CREATED);
scenario.moveToState(Lifecycle.State.RESUMED);
assertEquals(content, new RecoveryRepository(context).loadActive().text);
onView(withText(R.string.Restore)).inRoot(isDialog()).perform(click());
onView(withId(R.id.editText1)).check(matches(withText(content)));
}
}

@Test
public void namedDraftIsDetectedByExactDocumentUri() throws Exception {
String documentUri = "content://recovery-test/document/notes.txt";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,16 @@ public void writesSeparateDraftAndMetadataAndLoadsThem() throws Exception {
assertNotNull(loaded);
assertEquals("recover me", loaded.text);
assertEquals(3, loaded.metadata.generation);
assertTrue(new File(repository.getDirectoryForTests(), key + ".draft").isFile());
assertTrue(new File(repository.getDirectoryForTests(), key + ".json").isFile());
File generation = publishedGeneration(key);
assertTrue(new File(generation, "draft.draft").isFile());
assertTrue(new File(generation, "metadata.json").isFile());
}

@Test
public void invalidLengthAndMissingPairAreRejected() throws Exception {
String key = RecoveryKeys.forUntitledDocument();
repository.write(metadata(key, null, 1), "valid");
File draft = new File(repository.getDirectoryForTests(), key + ".draft");
File draft = new File(publishedGeneration(key), "draft.draft");
try (FileOutputStream output = new FileOutputStream(draft, true)) {
output.write('x');
}
Expand All @@ -98,7 +99,7 @@ public void invalidLengthAndMissingPairAreRejected() throws Exception {
public void sameLengthContentMismatchIsRejected() throws Exception {
String key = RecoveryKeys.forUntitledDocument();
repository.write(metadata(key, null, 1), "first");
File draft = new File(repository.getDirectoryForTests(), key + ".draft");
File draft = new File(publishedGeneration(key), "draft.draft");
try (FileOutputStream output = new FileOutputStream(draft, false)) {
output.write("other".getBytes(StandardCharsets.UTF_8));
}
Expand All @@ -110,7 +111,7 @@ public void sameLengthContentMismatchIsRejected() throws Exception {
public void staleActivePointerIsCleared() throws Exception {
String key = RecoveryKeys.forUntitledDocument();
repository.write(metadata(key, null, 1), "draft");
new File(repository.getDirectoryForTests(), key + ".draft").delete();
deleteRecursively(publishedGeneration(key));

assertNull(repository.loadActive());
assertNull(ApplicationProvider.<Context>getApplicationContext()
Expand Down Expand Up @@ -140,8 +141,49 @@ public void deleteRemovesPairAndActivePointer() throws Exception {
repository.delete(key);

assertNull(repository.load(key, null));
assertFalse(new File(repository.getDirectoryForTests(), key + ".draft").exists());
assertFalse(new File(repository.getDirectoryForTests(), key + ".json").exists());
assertFalse(new File(repository.getDirectoryForTests(), key + ".current").exists());
assertFalse(new File(repository.getDirectoryForTests(), key + ".generations").exists());
}

@Test
public void incompleteUnpublishedGenerationDoesNotReplaceCurrentDraft() throws Exception {
String key = RecoveryKeys.forUntitledDocument();
repository.write(metadata(key, null, 1), "previous");
File generations = new File(repository.getDirectoryForTests(), key + ".generations");
File incomplete = new File(generations, "generation-interrupted.tmp");
assertTrue(incomplete.mkdir());
try (FileOutputStream output = new FileOutputStream(new File(incomplete, "draft.draft"))) {
output.write("newer".getBytes(StandardCharsets.UTF_8));
}

repository.cleanupIncompleteArtifacts();

RecoveryDraft draft = repository.loadActive();
assertNotNull(draft);
assertEquals("previous", draft.text);
assertFalse(incomplete.exists());
}

@Test
public void cleanupRestoresLegacyBackupBeforeLoading() throws Exception {
String key = RecoveryKeys.forUntitledDocument();
RecoveryMetadata published = metadata(key, null, 1).published(
"valid".getBytes(StandardCharsets.UTF_8).length,
sha256("valid".getBytes(StandardCharsets.UTF_8)),
123L
);
File directory = repository.getDirectoryForTests();
assertTrue(directory.mkdirs());
write(new File(directory, key + ".draft"), "broken");
write(new File(directory, key + ".draft.bak"), "valid");
write(new File(directory, key + ".json"), published.toJson().toString());

repository.cleanupIncompleteArtifacts();

RecoveryDraft draft = repository.load(key, null);
assertNotNull(draft);
assertEquals("valid", draft.text);
assertFalse(new File(directory, key + ".draft.bak").exists());
}

private RecoveryMetadata metadata(String key, String uri, long generation) {
Expand All @@ -151,6 +193,29 @@ private RecoveryMetadata metadata(String key, String uri, long generation) {
);
}

private File publishedGeneration(String key) {
File generations = new File(repository.getDirectoryForTests(), key + ".generations");
File[] children = generations.listFiles(file -> file.isDirectory() && !file.getName().endsWith(".tmp"));
assertNotNull(children);
assertEquals(1, children.length);
return children[0];
}

private static void write(File file, String value) throws Exception {
try (FileOutputStream output = new FileOutputStream(file)) {
output.write(value.getBytes(StandardCharsets.UTF_8));
}
}

private static String sha256(byte[] value) throws Exception {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
StringBuilder result = new StringBuilder();
for (byte item : digest.digest(value)) {
result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff));
}
return result.toString();
}

private static void deleteRecursively(File file) {
if (!file.exists()) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public synchronized RecoveryMetadata write(RecoveryMetadata metadata, String tex
public void cancellationBarrierPreventsQueuedDraftFromReappearingAfterDelete() {
String key = RecoveryKeys.forUntitledDocument();
writer.schedule(snapshot(key, 3, "queued"));
writer.cancelAndWait(3, 2000);
writer.cancelAndWait(key, 3, 2000);
repository.delete(key);

try {
Expand All @@ -104,6 +104,19 @@ public void cancellationBarrierPreventsQueuedDraftFromReappearingAfterDelete() {
assertEquals(null, repository.load(key, null));
}

@Test
public void lowerGenerationForAnotherKeyIsNotRejected() {
String firstKey = RecoveryKeys.forUntitledDocument();
String secondKey = RecoveryKeys.forUntitledDocument();
writer.flushAndWait(snapshot(firstKey, 9, "first"), 2000);
writer.flushAndWait(snapshot(secondKey, 1, "second"), 2000);

RecoveryDraft draft = repository.load(secondKey, null);
assertNotNull(draft);
assertEquals("second", draft.text);
assertEquals(1, draft.metadata.generation);
}

private RecoveryWriter.Snapshot snapshot(String key, long generation, String text) {
RecoveryMetadata metadata = new RecoveryMetadata(
key, null, "newfile.txt", true, "UTF-8", false,
Expand Down
Loading