From 7e23fec8c7876e92ac80f3063403d1d014714aec Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Fri, 14 Aug 2026 17:20:40 +0200 Subject: [PATCH 1/3] address code review comments --- .../test/EditorExternalChangeTest.java | 508 ++++++++++++++++++ .../textpad/test/EditorRecoveryTest.java | 16 + .../test/recovery/RecoveryRepositoryTest.java | 79 ++- .../test/recovery/RecoveryWriterTest.java | 15 +- .../textpad/activities/EditorActivity.java | 421 +++++++++++---- .../textpad/recovery/RecoveryRepository.java | 234 ++++++-- .../textpad/recovery/RecoveryWriter.java | 32 +- .../textpad/utils/DocumentSaveValidator.java | 48 ++ app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../utils/DocumentSaveValidatorTest.java | 76 +++ 11 files changed, 1268 insertions(+), 175 deletions(-) create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/EditorExternalChangeTest.java create mode 100644 app/src/main/java/com/maxistar/textpad/utils/DocumentSaveValidator.java create mode 100644 app/src/test/java/com/maxistar/textpad/utils/DocumentSaveValidatorTest.java diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorExternalChangeTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorExternalChangeTest.java new file mode 100644 index 0000000..410e939 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorExternalChangeTest.java @@ -0,0 +1,508 @@ +package com.maxistar.textpad.test; + +import static androidx.test.espresso.Espresso.onView; +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.intent.Intents.intended; +import static androidx.test.espresso.intent.Intents.intending; +import static androidx.test.espresso.intent.matcher.IntentMatchers.hasAction; +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; +import static org.junit.Assert.assertNotNull; + +import android.content.ContentValues; +import android.app.Activity; +import android.app.Instrumentation.ActivityResult; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.preference.PreferenceManager; +import android.provider.MediaStore; +import android.widget.EditText; + +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.espresso.intent.Intents; +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.recovery.RecoveryKeys; +import com.maxistar.textpad.recovery.RecoveryMetadata; +import com.maxistar.textpad.recovery.RecoveryRepository; +import com.maxistar.textpad.service.SettingsService; +import com.maxistar.textpad.utils.DocumentSaveValidator; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; + +@RunWith(AndroidJUnit4.class) +public class EditorExternalChangeTest { + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + clearRecovery(); + setPreference(SettingsService.SETTING_LEGASY_FILE_PICKER, false); + setPreference(SettingsService.SETTING_AUTO_SAVE_CURRENT_FILE, false); + setPreference(SettingsService.SETTING_USE_SIMPLE_SCROLLING, false); + } + + @After + public void tearDown() { + clearRecovery(); + setPreference(SettingsService.SETTING_LEGASY_FILE_PICKER, false); + setPreference(SettingsService.SETTING_AUTO_SAVE_CURRENT_FILE, false); + setPreference(SettingsService.SETTING_USE_SIMPLE_SCROLLING, false); + } + + @Test + public void explicitSaveConflictCanBeCancelledWithoutLosingEitherVersion() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + + invokeSave(scenario); + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + pressBack(); + + assertEquals("external edit", readDocument(uri)); + assertEquals("local edit", editorText(scenario)); + assertNotNull(new RecoveryRepository(context).load( + RecoveryKeys.forDocumentUri(uri.toString()), uri.toString())); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void overwriteConflictWritesLocalVersionInSimpleLayout() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + setPreference(SettingsService.SETTING_USE_SIMPLE_SCROLLING, true); + Uri uri = createDocument("original"); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + invokeSave(scenario); + + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()); + assertEquals("local edit", readDocument(uri)); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void reloadConflictKeepsDraftUntilExternalReadSucceeds() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + invokeSave(scenario); + + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + onView(withId(android.R.id.button2)).inRoot(isDialog()).perform(click()); + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()); + assertEquals("external edit", editorText(scenario)); + org.junit.Assert.assertNull(new RecoveryRepository(context).load(key, uri.toString())); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void conflictSaveAsLaunchesCreateDocumentAndPreservesOriginal() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + Intents.init(); + try (ActivityScenario scenario = launch(uri)) { + intending(hasAction(Intent.ACTION_CREATE_DOCUMENT)) + .respondWith(new ActivityResult(Activity.RESULT_CANCELED, null)); + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + invokeSave(scenario); + + onView(withId(android.R.id.button3)).inRoot(isDialog()).perform(click()); + intended(hasAction(Intent.ACTION_CREATE_DOCUMENT)); + assertEquals("external edit", readDocument(uri)); + } finally { + Intents.release(); + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void autosaveDefersConflictAndRetainsRecovery() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + setPreference(SettingsService.SETTING_AUTO_SAVE_CURRENT_FILE, true); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + + scenario.moveToState(Lifecycle.State.CREATED); + assertEquals("external edit", readDocument(uri)); + assertNotNull(new RecoveryRepository(context).load(key, uri.toString())); + + scenario.moveToState(Lifecycle.State.RESUMED); + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + pressBack(); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void guardedLegacySaveDetectsExternalChange() throws Exception { + setPreference(SettingsService.SETTING_LEGASY_FILE_PICKER, true); + File file = new File(context.getCacheDir(), "external-change-" + java.lang.System.nanoTime() + ".txt"); + writeFile(file, "original"); + Intent intent = new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.fromFile(file)); + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + edit(scenario, "local edit"); + writeFile(file, "external edit"); + invokeSave(scenario); + + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + pressBack(); + assertEquals("external edit", readFile(file)); + } finally { + file.delete(); + } + } + + @Test + public void restoringDraftDetectsChangeMadeBeforeActivityCreation() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + RecoveryMetadata metadata = new RecoveryMetadata( + key, uri.toString(), "notes.txt", false, "UTF-8", false, + 8L, null, DocumentSaveValidator.sha256("original".getBytes(StandardCharsets.UTF_8)), + 0, 0, 0, 0, 1 + ); + new RecoveryRepository(context).write(metadata, "local edit"); + writeDocument(uri, "external edit"); + + try (ActivityScenario scenario = launch(uri)) { + onView(withText(R.string.Restore)).perform(click()); + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + pressBack(); + assertEquals("local edit", editorText(scenario)); + assertEquals("external edit", readDocument(uri)); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void failedReloadRetainsLocalEditorAndRecovery() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + writeDocument(uri, "external edit"); + invokeSave(scenario); + + onView(withText(R.string.External_change_detected)).inRoot(isDialog()).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + onView(withId(android.R.id.button2)).inRoot(isDialog()).perform(click()); + context.getContentResolver().delete(uri, null, null); + onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click()); + + assertEquals("local edit", editorText(scenario)); + assertNotNull(new RecoveryRepository(context).load(key, uri.toString())); + } + } + + @Test + public void equivalentExternalContentCompletesWithoutRewriting() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "same result"); + writeDocument(uri, "same result"); + invokeSave(scenario); + + assertEquals("same result", readDocument(uri)); + org.junit.Assert.assertNull(new RecoveryRepository(context).load(key, uri.toString())); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void olderSaveCompletionRetainsRecoveryForNewerEditorGeneration() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + long[] olderGeneration = new long[1]; + edit(scenario, "older local edit"); + scenario.onActivity(activity -> olderGeneration[0] = longField(activity, "editorGeneration")); + edit(scenario, "newer local edit"); + writeDocument(uri, "older local edit"); + + scenario.onActivity(activity -> invokeSaveCompletion( + activity, + olderGeneration[0], + key, + "older local edit".getBytes(StandardCharsets.UTF_8) + )); + scenario.moveToState(Lifecycle.State.CREATED); + + assertEquals("newer local edit", new RecoveryRepository(context).load(key, uri.toString()).text); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void cleanSafDocumentReloadsWhenReturningToForeground() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + try (ActivityScenario scenario = launch(uri)) { + scenario.moveToState(Lifecycle.State.CREATED); + writeDocument(uri, "external edit"); + scenario.moveToState(Lifecycle.State.RESUMED); + + assertEquals("external edit", editorText(scenario)); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void dirtySimpleLayoutShowsConflictImmediatelyOnForeground() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + setPreference(SettingsService.SETTING_USE_SIMPLE_SCROLLING, true); + Uri uri = createDocument("original"); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "local edit"); + scenario.moveToState(Lifecycle.State.CREATED); + writeDocument(uri, "external edit"); + scenario.moveToState(Lifecycle.State.RESUMED); + + onView(withText(R.string.External_change_detected)).inRoot(isDialog()) + .check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + pressBack(); + assertEquals("local edit", editorText(scenario)); + assertEquals("external edit", readDocument(uri)); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void equivalentContentIsCompletedWhenReturningToForeground() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri uri = createDocument("original"); + String key = RecoveryKeys.forDocumentUri(uri.toString()); + try (ActivityScenario scenario = launch(uri)) { + edit(scenario, "same result"); + scenario.moveToState(Lifecycle.State.CREATED); + writeDocument(uri, "same result"); + scenario.moveToState(Lifecycle.State.RESUMED); + + assertEquals("same result", editorText(scenario)); + org.junit.Assert.assertNull(new RecoveryRepository(context).load(key, uri.toString())); + } finally { + context.getContentResolver().delete(uri, null, null); + } + } + + @Test + public void unreadableLegacyDocumentLeavesForegroundEditorUnchanged() throws Exception { + setPreference(SettingsService.SETTING_LEGASY_FILE_PICKER, true); + File file = new File(context.getCacheDir(), "foreground-unreadable-" + java.lang.System.nanoTime() + ".txt"); + writeFile(file, "original"); + Intent intent = new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.fromFile(file)); + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + scenario.moveToState(Lifecycle.State.CREATED); + file.delete(); + scenario.moveToState(Lifecycle.State.RESUMED); + + assertEquals("original", editorText(scenario)); + } finally { + file.delete(); + } + } + + @Test + public void cleanLegacyDocumentReloadsWhenReturningToForeground() throws Exception { + setPreference(SettingsService.SETTING_LEGASY_FILE_PICKER, true); + File file = new File(context.getCacheDir(), "foreground-reload-" + java.lang.System.nanoTime() + ".txt"); + writeFile(file, "original"); + Intent intent = new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.fromFile(file)); + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + scenario.moveToState(Lifecycle.State.CREATED); + writeFile(file, "external edit"); + scenario.moveToState(Lifecycle.State.RESUMED); + + assertEquals("external edit", editorText(scenario)); + } finally { + file.delete(); + } + } + + private ActivityScenario launch(Uri uri) { + return ActivityScenario.launch(new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(uri)); + } + + private void edit(ActivityScenario scenario, String text) { + scenario.onActivity(activity -> ((EditText) activity.findViewById(R.id.editText1)).setText(text)); + } + + private String editorText(ActivityScenario scenario) { + String[] value = new String[1]; + scenario.onActivity(activity -> value[0] = ((EditText) activity.findViewById(R.id.editText1)).getText().toString()); + return value[0]; + } + + private void invokeSave(ActivityScenario scenario) { + scenario.onActivity(activity -> { + try { + Method method = EditorActivity.class.getDeclaredMethod("saveFileIfNamed"); + method.setAccessible(true); + method.invoke(activity); + } catch (Exception error) { + throw new AssertionError(error); + } + }); + } + + private static long longField(EditorActivity activity, String name) { + try { + Field field = EditorActivity.class.getDeclaredField(name); + field.setAccessible(true); + return field.getLong(activity); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private static void invokeSaveCompletion( + EditorActivity activity, + long generation, + String recoveryKey, + byte[] bytes + ) { + try { + Method method = EditorActivity.class.getDeclaredMethod( + "completeSuccessfulSave", long.class, String.class, byte[].class, Long.class); + method.setAccessible(true); + method.invoke(activity, generation, recoveryKey, bytes, null); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private Uri createDocument(String content) throws Exception { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, "textpad-external-" + java.lang.System.nanoTime() + ".txt"); + values.put(MediaStore.MediaColumns.MIME_TYPE, "text/plain"); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/TextPadTests"); + Uri uri = context.getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values); + if (uri == null) { + throw new IllegalStateException("Unable to create test document"); + } + writeDocument(uri, content); + return uri; + } + + private void writeDocument(Uri uri, String content) throws Exception { + try (java.io.OutputStream output = context.getContentResolver().openOutputStream(uri, "wt")) { + if (output == null) { + throw new IllegalStateException("Unable to write test document"); + } + output.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + private String readDocument(Uri uri) throws Exception { + try (InputStream input = context.getContentResolver().openInputStream(uri)) { + if (input == null) { + throw new IllegalStateException("Unable to read test document"); + } + return readAll(input); + } + } + + private void writeFile(File file, String content) throws Exception { + try (FileOutputStream output = new FileOutputStream(file)) { + output.write(content.getBytes(StandardCharsets.UTF_8)); + } + } + + private String readFile(File file) throws Exception { + try (InputStream input = new java.io.FileInputStream(file)) { + return readAll(input); + } + } + + private String readAll(InputStream input) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return output.toString(StandardCharsets.UTF_8.name()); + } + + private void setPreference(String key, boolean value) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).commit(); + ServiceLocator.getInstance().getSettingsService(context).reloadSettings(context); + } + + private void clearRecovery() { + context.getSharedPreferences("editor_recovery", Context.MODE_PRIVATE).edit().clear().commit(); + deleteRecursively(new RecoveryRepository(context).getDirectoryForTests()); + } + + private static void deleteRecursively(File file) { + if (!file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + file.delete(); + } +} diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java index b14d7da..81458d1 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java @@ -140,6 +140,22 @@ public void dismissingRecoveryKeepsDraft() throws Exception { } } + @Test + public void pendingRecoveryDecisionCannotOverwriteDraftDuringRecreation() throws Exception { + String content = "recoverable after recreation"; + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> ((EditText) activity.findViewById(R.id.editText1)).setText(content)); + scenario.recreate(); + onView(withText(R.string.Restore)).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)).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"; diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java index 8d80154..1da645c 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java @@ -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'); } @@ -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)); } @@ -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.getApplicationContext() @@ -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) { @@ -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; diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java index 49f0c6d..ff96ee0 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java @@ -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 { @@ -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, diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index 9d61063..4aa6b8c 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -10,11 +10,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; -import java.security.MessageDigest; import java.text.DateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -70,6 +68,7 @@ import com.maxistar.textpad.recovery.RecoveryRepository; import com.maxistar.textpad.recovery.RecoveryWriter; import com.maxistar.textpad.utils.EditTextUndoRedo; +import com.maxistar.textpad.utils.DocumentSaveValidator; import com.maxistar.textpad.utils.FileNameHelper; import com.maxistar.textpad.utils.System; import com.maxistar.textpad.utils.TextConverter; @@ -151,9 +150,25 @@ public class EditorActivity extends AppCompatActivity { private String recoveryKey; private long editorGeneration = 0; private boolean suppressRecoveryTracking = false; + private boolean recoveryDecisionPending = false; private Long originalSize; private Long originalLastModified; private String originalContentSha256; + private SaveRequest pendingExternalConflict; + private boolean nextSaveCreatesDocument; + private boolean hasEnteredForeground; + + private static final class SaveRequest { + final long generation; + final String recoveryKey; + final byte[] bytes; + + SaveRequest(long generation, String recoveryKey, byte[] bytes) { + this.generation = generation; + this.recoveryKey = recoveryKey; + this.bytes = bytes; + } + } SettingsService settingsService; @@ -350,9 +365,23 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { protected void onResume() { super.onResume(); + + if (hasEnteredForeground && pendingExternalConflict == null) { + validateOpenDocumentOnForeground(); + } + hasEnteredForeground = true; + mText.addTextChangedListener(textWatcher); applyStoredSelection(); + if (pendingExternalConflict != null) { + mText.post(() -> { + if (pendingExternalConflict != null && !isFinishing()) { + showExternalChangeDialog(pendingExternalConflict); + } + }); + } + if (SettingsService.isLanguageWasChanged()) { Intent intent = getIntent(); finish(); @@ -366,7 +395,7 @@ protected void onResume() { protected void onPause() { if (settingsService.isAutosavingActive() && !isFilenameEmpty() && isChanged()) { - this.saveFileIfNamed(); + this.saveFileIfNamed(true); } mText.removeTextChangedListener(textWatcher); @@ -430,6 +459,7 @@ private void offerActiveUntitledRecovery() { } private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { + recoveryDecisionPending = true; String timestamp = DateFormat.getDateTimeInstance().format(new Date(draft.metadata.draftUpdatedAt)); String name = draft.metadata.displayName == null || draft.metadata.displayName.isEmpty() ? TPStrings.NEW_FILE_TXT @@ -439,17 +469,19 @@ private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { .setMessage(getString(R.string.Recovery_draft_message, name, timestamp)) .setPositiveButton(R.string.Restore, (dialog, which) -> restoreDraft(draft)) .setNegativeButton(R.string.Discard_draft, (dialog, which) -> { + recoveryDecisionPending = false; recoveryRepository.delete(draft.metadata.recoveryKey); if (draft.metadata.recoveryKey.equals(recoveryKey)) { recoveryKey = null; } discardLoader.run(); }) - .setOnCancelListener(dialog -> { }) + .setCancelable(false) .show(); } private void restoreDraft(RecoveryDraft draft) { + recoveryDecisionPending = false; recoveryKey = draft.metadata.recoveryKey; urlFilename = draft.metadata.documentUri == null ? TPStrings.EMPTY : filenameFromIdentity(draft.metadata.documentUri); editorGeneration = draft.metadata.generation; @@ -460,6 +492,9 @@ private void restoreDraft(RecoveryDraft draft) { originalContentSha256 = draft.metadata.originalContentSha256; setEditorText(draft.text, true); updateTitle(); + if (!draft.metadata.untitled) { + mText.post(this::validateRestoredDraft); + } } private void applyStoredSelection() { @@ -493,7 +528,7 @@ private void flushRecoverySnapshot() { } private RecoveryWriter.Snapshot createRecoverySnapshot() { - if (!changed) { + if (!changed || recoveryDecisionPending) { return null; } String identity = documentIdentityUri(); @@ -554,7 +589,7 @@ private String currentDisplayName() { private void recordLoadedDocument(byte[] originalBytes, Long lastModified) { originalSize = (long) originalBytes.length; originalLastModified = lastModified; - originalContentSha256 = sha256(originalBytes); + originalContentSha256 = DocumentSaveValidator.sha256(originalBytes); String identity = documentIdentityUri(); recoveryKey = identity == null ? null : RecoveryKeys.forDocumentUri(identity); editorGeneration = 0; @@ -562,20 +597,6 @@ private void recordLoadedDocument(byte[] originalBytes, Long lastModified) { selectionEnd = 0; } - private static String sha256(byte[] value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(value); - StringBuilder result = new StringBuilder(); - for (byte item : hash) { - result.append(String.format(Locale.ROOT, "%02x", item & 0xff)); - } - return result.toString(); - } catch (Exception error) { - return null; - } - } - /** * @param outState Bundle */ @@ -1097,7 +1118,7 @@ public void clearFile() { } private void discardCurrentRecovery() { - recoveryWriter.cancelAndWait(editorGeneration, 2000); + recoveryWriter.cancelAndWait(recoveryKey, editorGeneration, 2000); recoveryRepository.delete(recoveryKey); recoveryKey = null; } @@ -1219,11 +1240,11 @@ protected void saveFile() { } protected void saveFileIfNamed() { - if (useAndroidManager()) { - saveNamedFile(); - } else { - saveNamedFileLegacy(); - } + saveFileIfNamed(false); + } + + private void saveFileIfNamed(boolean autosave) { + guardedSaveNamedFile(autosave); } protected void saveFileWithConfirmation() { @@ -1239,8 +1260,10 @@ protected void saveFileWithConfirmation() { EditorActivity.this.saveFile(); }).setNegativeButton(R.string.No, (dialog, which) -> { - //do nothing!! - }).show(); + nextSaveCreatesDocument = false; + }) + .setOnCancelListener(dialog -> nextSaveCreatesDocument = false) + .show(); } else { saveFileIfNamed(); } @@ -1252,63 +1275,7 @@ protected boolean fileAlreadyExists() { } protected void saveNamedFileLegacy() { - long savedGeneration = editorGeneration; - String previousRecoveryKey = recoveryKey; - try { - File f = new File(getFilename()); - if (!f.exists()) { - if (!f.createNewFile()) { - showToast(R.string.Can_not_write_file); - return; - } - } - - FileOutputStream fos = new FileOutputStream(f); - String s = this.mText.getText().toString(); - - s = applyEndings(s); - - fos.write(s.getBytes(settingsService.getFileEncoding())); - fos.close(); - completeSuccessfulSave(savedGeneration, previousRecoveryKey, s.getBytes(settingsService.getFileEncoding()), f.lastModified()); - showToast(R.string.File_Written); - if (editorGeneration == savedGeneration) { - initEditor(); - } - updateTitle(); - - if (next_action == DO_OPEN) { - // because of multithread nature - // figure out better way to do - // it - next_action = DO_NOTHING; - openNewFile(); - } - if (next_action == DO_NEW) { - // because of multithread nature - // figure out better way to do - // it - next_action = DO_NOTHING; - clearFile(); - } - if (next_action == DO_SHOW_SETTINGS) { // because of multithread nature - next_action = DO_NOTHING; - showSettingsActivity(); - } - if (next_action == DO_OPEN_RECENT) { - next_action = DO_NOTHING; - openFileByName(next_action_filename); - } - if (next_action == DO_EXIT) { - exitApplication(); - } - } catch (FileNotFoundException e) { - this.showToast(R.string.File_not_found); - } catch (IOException e) { - this.showToast(R.string.Can_not_write_file); - } catch (Exception e) { - this.showToast(R.string.Can_not_write_file); - } + guardedSaveNamedFile(false); } protected void saveFile(Uri uri) throws IOException { @@ -1330,43 +1297,42 @@ protected void saveFile(Uri uri) throws IOException { } protected void saveNamedFile() { - long savedGeneration = editorGeneration; - String previousRecoveryKey = recoveryKey; + guardedSaveNamedFile(false); + } + + private void guardedSaveNamedFile(boolean autosave) { try { - Uri uri = Uri.parse(getFilename()); - saveFile(uri); String persistedText = applyEndings(mText.getText().toString()); - completeSuccessfulSave( - savedGeneration, - previousRecoveryKey, - persistedText.getBytes(settingsService.getFileEncoding()), - null + SaveRequest request = new SaveRequest( + editorGeneration, + recoveryKey, + persistedText.getBytes(settingsService.getFileEncoding()) ); - - showToast(R.string.File_Written); - if (editorGeneration == savedGeneration) { - initEditor(); + boolean creatingDocument = nextSaveCreatesDocument || originalContentSha256 == null; + nextSaveCreatesDocument = false; + if (creatingDocument) { + writeAndComplete(request); + return; } - updateTitle(); - if (next_action == DO_OPEN) { // because of multithread nature - next_action = DO_NOTHING; - openNewFile(); - } - if (next_action == DO_NEW) { // because of multithread nature - next_action = DO_NOTHING; - clearFile(); - } - if (next_action == DO_SHOW_SETTINGS) { // because of multithread nature - next_action = DO_NOTHING; - showSettingsActivity(); - } - if (next_action == DO_OPEN_RECENT) { - next_action = DO_NOTHING; - openFileByName(next_action_filename); - } - if (next_action == DO_EXIT) { - exitApplication(); + byte[] currentBytes = readNamedDocumentBytes(); + DocumentSaveValidator.Outcome outcome = DocumentSaveValidator.classify( + currentBytes, + originalContentSha256, + request.bytes + ); + if (outcome == DocumentSaveValidator.Outcome.BASELINE_MATCH) { + writeAndComplete(request); + } else if (outcome == DocumentSaveValidator.Outcome.INTENDED_CONTENT_MATCH) { + completeEquivalentSave(request, currentBytes); + } else if (outcome == DocumentSaveValidator.Outcome.CONFLICT) { + pendingExternalConflict = request; + flushRecoverySnapshot(); + if (!autosave) { + showExternalChangeDialog(request); + } + } else { + showToast(R.string.Can_not_read_file); } } catch (FileNotFoundException e) { this.showToast(R.string.File_not_found); @@ -1377,6 +1343,221 @@ protected void saveNamedFile() { } } + private byte[] readNamedDocumentBytes() throws IOException { + InputStream inputStream; + if (useAndroidManager()) { + inputStream = getContentResolver().openInputStream(Uri.parse(getFilename())); + } else { + inputStream = new FileInputStream(new File(getFilename())); + } + if (inputStream == null) { + throw new IOException("Document cannot be opened for validation"); + } + try (InputStream input = inputStream; + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return output.toByteArray(); + } + } + + private void writeNamedDocumentBytes(byte[] bytes) throws IOException { + if (useAndroidManager()) { + OutputStream output = getContentResolver().openOutputStream(Uri.parse(getFilename()), "wt"); + if (output == null) { + throw new IOException("Document cannot be opened for writing"); + } + try (OutputStream closeable = output) { + closeable.write(bytes); + } + return; + } + + File file = new File(getFilename()); + if (!file.exists() && !file.createNewFile()) { + throw new IOException("Document cannot be created"); + } + try (FileOutputStream output = new FileOutputStream(file)) { + output.write(bytes); + } + } + + private void writeAndComplete(SaveRequest request) throws IOException { + writeNamedDocumentBytes(request.bytes); + Long lastModified = useAndroidManager() ? null : new File(getFilename()).lastModified(); + completeSuccessfulSave(request.generation, request.recoveryKey, request.bytes, lastModified); + pendingExternalConflict = null; + finishSuccessfulSave(request.generation); + } + + private void completeEquivalentSave(SaveRequest request, byte[] currentBytes) { + Long lastModified = useAndroidManager() ? null : new File(getFilename()).lastModified(); + completeSuccessfulSave(request.generation, request.recoveryKey, currentBytes, lastModified); + pendingExternalConflict = null; + finishSuccessfulSave(request.generation); + } + + private void finishSuccessfulSave(long savedGeneration) { + showToast(R.string.File_Written); + if (editorGeneration == savedGeneration) { + initEditor(); + } + updateTitle(); + + if (next_action == DO_OPEN) { + next_action = DO_NOTHING; + openNewFile(); + } else if (next_action == DO_NEW) { + next_action = DO_NOTHING; + clearFile(); + } else if (next_action == DO_SHOW_SETTINGS) { + next_action = DO_NOTHING; + showSettingsActivity(); + } else if (next_action == DO_OPEN_RECENT) { + next_action = DO_NOTHING; + openFileByName(next_action_filename); + } else if (next_action == DO_EXIT) { + next_action = DO_NOTHING; + exitApplication(); + } + } + + private void showExternalChangeDialog(SaveRequest request) { + if (isFinishing() || request != pendingExternalConflict) { + return; + } + new AlertDialog.Builder(this) + .setTitle(R.string.External_change_detected) + .setMessage(getString(R.string.External_change_message, currentDisplayName())) + .setPositiveButton(R.string.Overwrite, (dialog, which) -> overwriteAfterConflict(request)) + .setNegativeButton(R.string.Reload, (dialog, which) -> confirmReloadExternal(request)) + .setNeutralButton(R.string.Save_As, (dialog, which) -> saveAs()) + .setOnCancelListener(dialog -> { + pendingExternalConflict = request; + flushRecoverySnapshot(); + }) + .show(); + } + + private void overwriteAfterConflict(SaveRequest request) { + try { + // Confirm that the target is still readable immediately before the authorized overwrite. + readNamedDocumentBytes(); + writeAndComplete(request); + } catch (FileNotFoundException error) { + showToast(R.string.File_not_found); + } catch (IOException error) { + showToast(R.string.Can_not_write_file); + } + } + + private void confirmReloadExternal(SaveRequest request) { + new AlertDialog.Builder(this) + .setTitle(R.string.Reload_external_title) + .setMessage(R.string.Reload_external_message) + .setPositiveButton(R.string.Reload, (dialog, which) -> reloadExternalDocument(request)) + .setNegativeButton(R.string.Cancel, (dialog, which) -> { + pendingExternalConflict = request; + flushRecoverySnapshot(); + }) + .setOnCancelListener(dialog -> { + pendingExternalConflict = request; + flushRecoverySnapshot(); + }) + .show(); + } + + private void reloadExternalDocument(SaveRequest request) { + try { + byte[] externalBytes = readNamedDocumentBytes(); + recoveryWriter.cancelAndWait(request.recoveryKey, editorGeneration, 2000); + recoveryRepository.delete(request.recoveryKey); + pendingExternalConflict = null; + applyExternalDocument(externalBytes); + } catch (FileNotFoundException error) { + showToast(R.string.File_not_found); + pendingExternalConflict = request; + } catch (Exception error) { + showToast(R.string.Can_not_read_file); + pendingExternalConflict = request; + } + } + + private void validateOpenDocumentOnForeground() { + if (isFilenameEmpty() || originalContentSha256 == null || pendingExternalConflict != null) { + return; + } + try { + byte[] currentBytes = readNamedDocumentBytes(); + if (originalContentSha256.equals(DocumentSaveValidator.sha256(currentBytes))) { + return; + } + if (!changed) { + applyExternalDocument(currentBytes); + showToast(R.string.File_reloaded_after_external_change); + return; + } + + byte[] intendedBytes = applyEndings(mText.getText().toString()) + .getBytes(settingsService.getFileEncoding()); + SaveRequest request = new SaveRequest(editorGeneration, recoveryKey, intendedBytes); + DocumentSaveValidator.Outcome outcome = DocumentSaveValidator.classify( + currentBytes, + originalContentSha256, + intendedBytes + ); + if (outcome == DocumentSaveValidator.Outcome.INTENDED_CONTENT_MATCH) { + completeEquivalentSave(request, currentBytes); + } else if (outcome == DocumentSaveValidator.Outcome.CONFLICT) { + pendingExternalConflict = request; + flushRecoverySnapshot(); + mText.post(() -> showExternalChangeDialog(request)); + } + } catch (Exception error) { + // Foreground validation is best effort. Save repeats the mandatory validation. + } + } + + private void applyExternalDocument(byte[] externalBytes) throws Exception { + String externalText = new String(externalBytes, settingsService.getFileEncoding()); + externalText = toUnixEndings(externalText); + setEditorText(externalText, false); + initEditor(); + recordLoadedDocument( + externalBytes, + useAndroidManager() ? null : new File(getFilename()).lastModified() + ); + updateTitle(); + } + + private void validateRestoredDraft() { + if (!changed || isFilenameEmpty() || originalContentSha256 == null) { + return; + } + try { + byte[] intendedBytes = applyEndings(mText.getText().toString()) + .getBytes(settingsService.getFileEncoding()); + SaveRequest request = new SaveRequest(editorGeneration, recoveryKey, intendedBytes); + byte[] currentBytes = readNamedDocumentBytes(); + DocumentSaveValidator.Outcome outcome = DocumentSaveValidator.classify( + currentBytes, + originalContentSha256, + intendedBytes + ); + if (outcome == DocumentSaveValidator.Outcome.INTENDED_CONTENT_MATCH) { + completeEquivalentSave(request, currentBytes); + } else if (outcome == DocumentSaveValidator.Outcome.CONFLICT) { + pendingExternalConflict = request; + showExternalChangeDialog(request); + } + } catch (Exception error) { + // Keep the restored local draft; a later Save will retry validation and report failure. + } + } + private void completeSuccessfulSave( long savedGeneration, String previousRecoveryKey, @@ -1387,10 +1568,10 @@ private void completeSuccessfulSave( String targetKey = identity == null ? null : RecoveryKeys.forDocumentUri(identity); originalSize = (long) persistedBytes.length; originalLastModified = lastModified; - originalContentSha256 = sha256(persistedBytes); + originalContentSha256 = DocumentSaveValidator.sha256(persistedBytes); if (editorGeneration == savedGeneration) { - recoveryWriter.cancelAndWait(savedGeneration, 2000); + recoveryWriter.cancelAndWait(previousRecoveryKey, savedGeneration, 2000); recoveryRepository.delete(previousRecoveryKey); if (targetKey != null && !targetKey.equals(previousRecoveryKey)) { recoveryRepository.delete(targetKey); @@ -1658,6 +1839,7 @@ public synchronized void onActivityResult( setFilename( data.getStringExtra(TPStrings.RESULT_PATH) ); + nextSaveCreatesDocument = true; this.saveFileWithConfirmation(); } else if (resultCode == Activity.RESULT_CANCELED) { showToast(R.string.Operation_Canceled); @@ -1691,6 +1873,7 @@ public synchronized void onActivityResult( Uri uri = data.getData(); if (uri != null) { setFilename(uri.toString()); + nextSaveCreatesDocument = true; this.saveFileWithConfirmation(); } } diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java index 3b4bc0d..4ea0151 100644 --- a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java @@ -13,12 +13,17 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.util.Locale; +import java.util.UUID; public class RecoveryRepository { private static final String DIRECTORY = "recovery"; private static final String PREFERENCES = "editor_recovery"; private static final String ACTIVE_KEY = "active_key"; + private static final String GENERATIONS_SUFFIX = ".generations"; + private static final String CURRENT_SUFFIX = ".current"; + private static final String TEMPORARY_SUFFIX = ".tmp"; + private static final String DRAFT_NAME = "draft.draft"; + private static final String METADATA_NAME = "metadata.json"; private final File directory; private final SharedPreferences preferences; @@ -31,9 +36,7 @@ public RecoveryRepository(Context context) { public synchronized RecoveryMetadata write(RecoveryMetadata metadata, String text) throws Exception { requireValidKey(metadata.recoveryKey); - if (!directory.exists() && !directory.mkdirs() && !directory.isDirectory()) { - throw new IOException("Cannot create recovery directory"); - } + ensureDirectory(directory); byte[] content = text.getBytes(StandardCharsets.UTF_8); RecoveryMetadata published = metadata.published( @@ -41,39 +44,51 @@ public synchronized RecoveryMetadata write(RecoveryMetadata metadata, String tex sha256(content), java.lang.System.currentTimeMillis() ); - writeAtomic(draftFile(metadata.recoveryKey), content); - writeAtomic(metadataFile(metadata.recoveryKey), published.toJson().toString().getBytes(StandardCharsets.UTF_8)); - setActiveKey(metadata.recoveryKey); - return published; + String previousGeneration = readCurrentGeneration(metadata.recoveryKey); + File generations = generationsDirectory(metadata.recoveryKey); + ensureDirectory(generations); + + String generation = "generation-" + UUID.randomUUID(); + File temporary = new File(generations, generation + TEMPORARY_SUFFIX); + File complete = new File(generations, generation); + if (!temporary.mkdir()) { + throw new IOException("Cannot create recovery generation"); + } + try { + writeFile(new File(temporary, DRAFT_NAME), content); + writeFile(new File(temporary, METADATA_NAME), published.toJson().toString().getBytes(StandardCharsets.UTF_8)); + if (loadPair(metadata.recoveryKey, null, temporary) == null) { + throw new IOException("Recovery generation validation failed"); + } + if (!temporary.renameTo(complete)) { + throw new IOException("Cannot publish recovery generation"); + } + writeAtomic(currentFile(metadata.recoveryKey), generation.getBytes(StandardCharsets.UTF_8)); + setActiveKey(metadata.recoveryKey); + deleteLegacyArtifacts(metadata.recoveryKey); + cleanupGenerations(metadata.recoveryKey, generation, previousGeneration); + return published; + } finally { + if (temporary.exists()) { + deleteRecursively(temporary); + } + } } public synchronized RecoveryDraft load(String recoveryKey, String expectedDocumentUri) { if (!RecoveryKeys.isValid(recoveryKey)) { return null; } - File draft = draftFile(recoveryKey); - File sidecar = metadataFile(recoveryKey); - if (!draft.isFile() || !sidecar.isFile()) { - return null; - } try { - RecoveryMetadata metadata = RecoveryMetadata.fromJson( - new JSONObject(new String(readFully(sidecar), StandardCharsets.UTF_8)) - ); - if (!recoveryKey.equals(metadata.recoveryKey)) { - return null; - } - if (expectedDocumentUri != null && !expectedDocumentUri.equals(metadata.documentUri)) { - return null; - } - byte[] content = readFully(draft); - if (content.length != metadata.draftSize) { - return null; - } - if (!sha256(content).equals(metadata.draftContentSha256)) { - return null; + String generation = readCurrentGeneration(recoveryKey); + if (generation != null) { + RecoveryDraft draft = loadPair(recoveryKey, expectedDocumentUri, + new File(generationsDirectory(recoveryKey), generation)); + if (draft != null) { + return draft; + } } - return new RecoveryDraft(metadata, new String(content, StandardCharsets.UTF_8)); + return loadLegacyPair(recoveryKey, expectedDocumentUri); } catch (Exception ignored) { return null; } @@ -104,8 +119,9 @@ public synchronized void delete(String recoveryKey) { if (!RecoveryKeys.isValid(recoveryKey)) { return; } - new AtomicFile(draftFile(recoveryKey)).delete(); - new AtomicFile(metadataFile(recoveryKey)).delete(); + new AtomicFile(currentFile(recoveryKey)).delete(); + deleteRecursively(generationsDirectory(recoveryKey)); + deleteLegacyArtifacts(recoveryKey); clearActiveKey(recoveryKey); } @@ -119,12 +135,12 @@ public synchronized void cleanupIncompleteArtifacts() { } for (File file : files) { String name = file.getName(); - if (name.endsWith(".bak") || name.endsWith(".new")) { - String baseName = name.substring(0, name.length() - 4); - File base = new File(directory, baseName); - if (base.exists()) { - file.delete(); - } + if (name.endsWith(".bak")) { + restoreBackup(file, new File(directory, name.substring(0, name.length() - 4))); + } else if (name.endsWith(".new")) { + file.delete(); + } else if (name.endsWith(GENERATIONS_SUFFIX) && file.isDirectory()) { + cleanupTemporaryGenerations(file); } } } @@ -133,6 +149,94 @@ public File getDirectoryForTests() { return directory; } + private RecoveryDraft loadLegacyPair(String recoveryKey, String expectedDocumentUri) throws Exception { + File draft = draftFile(recoveryKey); + File sidecar = metadataFile(recoveryKey); + if (!draft.isFile() || !sidecar.isFile()) { + return null; + } + return loadPair(recoveryKey, expectedDocumentUri, draft, sidecar, true); + } + + private RecoveryDraft loadPair(String recoveryKey, String expectedDocumentUri, File generation) throws Exception { + return loadPair(recoveryKey, expectedDocumentUri, + new File(generation, DRAFT_NAME), new File(generation, METADATA_NAME), false); + } + + private RecoveryDraft loadPair( + String recoveryKey, + String expectedDocumentUri, + File draft, + File sidecar, + boolean atomicLegacyFiles + ) throws Exception { + if (!draft.isFile() || !sidecar.isFile()) { + return null; + } + byte[] sidecarBytes = atomicLegacyFiles ? readAtomic(sidecar) : readFully(sidecar); + RecoveryMetadata metadata = RecoveryMetadata.fromJson( + new JSONObject(new String(sidecarBytes, StandardCharsets.UTF_8)) + ); + if (!recoveryKey.equals(metadata.recoveryKey)) { + return null; + } + if (expectedDocumentUri != null && !expectedDocumentUri.equals(metadata.documentUri)) { + return null; + } + byte[] content = atomicLegacyFiles ? readAtomic(draft) : readFully(draft); + if (content.length != metadata.draftSize || !sha256(content).equals(metadata.draftContentSha256)) { + return null; + } + return new RecoveryDraft(metadata, new String(content, StandardCharsets.UTF_8)); + } + + private String readCurrentGeneration(String recoveryKey) throws IOException { + File marker = currentFile(recoveryKey); + if (!marker.exists() && !new File(marker.getPath() + ".bak").exists()) { + return null; + } + String generation = new String(readAtomic(marker), StandardCharsets.UTF_8).trim(); + return generation.startsWith("generation-") && !generation.contains("/") ? generation : null; + } + + private void cleanupGenerations(String key, String currentGeneration, String previousGeneration) { + File[] generations = generationsDirectory(key).listFiles(); + if (generations == null) { + return; + } + for (File generation : generations) { + String name = generation.getName(); + if (name.endsWith(TEMPORARY_SUFFIX) + || (!name.equals(currentGeneration) && !name.equals(previousGeneration))) { + deleteRecursively(generation); + } + } + } + + private static void cleanupTemporaryGenerations(File generations) { + File[] children = generations.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.getName().endsWith(TEMPORARY_SUFFIX)) { + deleteRecursively(child); + } + } + } + + private static void restoreBackup(File backup, File base) { + if (base.exists() && !base.delete()) { + return; + } + backup.renameTo(base); + } + + private void deleteLegacyArtifacts(String recoveryKey) { + new AtomicFile(draftFile(recoveryKey)).delete(); + new AtomicFile(metadataFile(recoveryKey)).delete(); + } + private void setActiveKey(String recoveryKey) { preferences.edit().putString(ACTIVE_KEY, recoveryKey).commit(); } @@ -143,6 +247,14 @@ private void clearActiveKey(String recoveryKey) { } } + private File generationsDirectory(String key) { + return new File(directory, key + GENERATIONS_SUFFIX); + } + + private File currentFile(String key) { + return new File(directory, key + CURRENT_SUFFIX); + } + private File draftFile(String key) { return new File(directory, key + ".draft"); } @@ -151,6 +263,15 @@ private File metadataFile(String key) { return new File(directory, key + ".json"); } + private static void ensureDirectory(File path) throws IOException { + if (!path.exists() && !path.mkdirs()) { + throw new IOException("Cannot create recovery directory"); + } + if (!path.isDirectory()) { + throw new IOException("Recovery path is not a directory"); + } + } + private static void requireValidKey(String key) { if (!RecoveryKeys.isValid(key)) { throw new IllegalArgumentException("Invalid recovery key"); @@ -163,6 +284,7 @@ private static void writeAtomic(File file, byte[] content) throws IOException { try { stream.write(content); stream.flush(); + stream.getFD().sync(); atomicFile.finishWrite(stream); } catch (IOException error) { atomicFile.failWrite(stream); @@ -170,9 +292,28 @@ private static void writeAtomic(File file, byte[] content) throws IOException { } } + private static void writeFile(File file, byte[] content) throws IOException { + try (FileOutputStream output = new FileOutputStream(file)) { + output.write(content); + output.flush(); + output.getFD().sync(); + } + } + + private static byte[] readAtomic(File file) throws IOException { + try (FileInputStream input = new AtomicFile(file).openRead()) { + return readFully(input, file.length()); + } + } + private static byte[] readFully(File file) throws IOException { - try (FileInputStream input = new FileInputStream(file); - ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(file.length(), 1024 * 1024))) { + try (FileInputStream input = new FileInputStream(file)) { + return readFully(input, file.length()); + } + } + + private static byte[] readFully(FileInputStream input, long length) throws IOException { + try (ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(length, 1024 * 1024))) { byte[] buffer = new byte[8192]; int count; while ((count = input.read(buffer)) != -1) { @@ -182,13 +323,26 @@ private static byte[] readFully(File file) throws IOException { } } + private static void deleteRecursively(File file) { + if (!file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + file.delete(); + } + private static String sha256(byte[] value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(value); StringBuilder result = new StringBuilder(); for (byte item : hash) { - result.append(String.format(Locale.ROOT, "%02x", item & 0xff)); + result.append(String.format(java.util.Locale.ROOT, "%02x", item & 0xff)); } return result.toString(); } catch (Exception error) { diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java index 995f64f..671108d 100644 --- a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java @@ -2,6 +2,8 @@ import android.util.Log; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -19,26 +21,29 @@ public final class RecoveryWriter { return thread; }); private ScheduledFuture pending; - private long newestGeneration; + private String pendingRecoveryKey; + private final Map newestGenerations = new HashMap<>(); public RecoveryWriter(RecoveryRepository repository) { this.repository = repository; } public synchronized void schedule(Snapshot snapshot) { - newestGeneration = Math.max(newestGeneration, snapshot.metadata.generation); + recordGeneration(snapshot.metadata.recoveryKey, snapshot.metadata.generation); if (pending != null) { pending.cancel(false); } + pendingRecoveryKey = snapshot.metadata.recoveryKey; pending = executor.schedule(() -> writeIfCurrent(snapshot), DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS); } public boolean flushAndWait(Snapshot snapshot, long timeoutMillis) { synchronized (this) { - newestGeneration = Math.max(newestGeneration, snapshot.metadata.generation); - if (pending != null) { + recordGeneration(snapshot.metadata.recoveryKey, snapshot.metadata.generation); + if (pending != null && snapshot.metadata.recoveryKey.equals(pendingRecoveryKey)) { pending.cancel(false); pending = null; + pendingRecoveryKey = null; } } Future future = executor.submit(() -> writeIfCurrent(snapshot)); @@ -55,15 +60,17 @@ public synchronized void cancelPending() { if (pending != null) { pending.cancel(false); pending = null; + pendingRecoveryKey = null; } } - public boolean cancelAndWait(long generation, long timeoutMillis) { + public boolean cancelAndWait(String recoveryKey, long generation, long timeoutMillis) { synchronized (this) { - newestGeneration = Math.max(newestGeneration, generation); - if (pending != null) { + recordGeneration(recoveryKey, generation); + if (pending != null && recoveryKey != null && recoveryKey.equals(pendingRecoveryKey)) { pending.cancel(false); pending = null; + pendingRecoveryKey = null; } } Future barrier = executor.submit(() -> { }); @@ -82,7 +89,8 @@ public void shutdown() { private void writeIfCurrent(Snapshot snapshot) { synchronized (this) { - if (snapshot.metadata.generation < newestGeneration) { + Long newestGeneration = newestGenerations.get(snapshot.metadata.recoveryKey); + if (newestGeneration != null && snapshot.metadata.generation < newestGeneration) { return; } } @@ -93,6 +101,14 @@ private void writeIfCurrent(Snapshot snapshot) { } } + private void recordGeneration(String recoveryKey, long generation) { + if (recoveryKey == null) { + return; + } + Long newest = newestGenerations.get(recoveryKey); + newestGenerations.put(recoveryKey, newest == null ? generation : Math.max(newest, generation)); + } + public static final class Snapshot { public final RecoveryMetadata metadata; public final String text; diff --git a/app/src/main/java/com/maxistar/textpad/utils/DocumentSaveValidator.java b/app/src/main/java/com/maxistar/textpad/utils/DocumentSaveValidator.java new file mode 100644 index 0000000..e8c2943 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/utils/DocumentSaveValidator.java @@ -0,0 +1,48 @@ +package com.maxistar.textpad.utils; + +import java.security.MessageDigest; +import java.util.Locale; + +/** Pure byte-level classification used before replacing an open document. */ +public final class DocumentSaveValidator { + public enum Outcome { + BASELINE_MATCH, + INTENDED_CONTENT_MATCH, + CONFLICT, + UNREADABLE + } + + private DocumentSaveValidator() { + } + + public static Outcome classify(byte[] currentBytes, String baselineSha256, byte[] intendedBytes) { + if (currentBytes == null || baselineSha256 == null || intendedBytes == null) { + return Outcome.UNREADABLE; + } + String currentSha256 = sha256(currentBytes); + if (baselineSha256.equals(currentSha256)) { + return Outcome.BASELINE_MATCH; + } + if (sha256(intendedBytes).equals(currentSha256)) { + return Outcome.INTENDED_CONTENT_MATCH; + } + return Outcome.CONFLICT; + } + + public static String sha256(byte[] value) { + if (value == null) { + return null; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value); + StringBuilder result = new StringBuilder(hash.length * 2); + for (byte item : hash) { + result.append(String.format(Locale.ROOT, "%02x", item & 0xff)); + } + return result.toString(); + } catch (Exception error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } +} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7bf10f9..90c2746 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -115,4 +115,11 @@ Найдена несохранённая версия «%1$s» от %2$s. Восстановить Удалить черновик + Обнаружено внешнее изменение + Файл «%1$s» изменён вне TextPad. Можно загрузить внешнюю версию, перезаписать её вашими правками, сохранить правки в другой файл или отменить действие и продолжить редактирование. + Перезаписать + Загрузить заново + Загрузить внешнюю версию? + Несохранённые правки TextPad будут удалены только после успешной загрузки внешней версии. + Файл обновлён после внешнего изменения diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44912d7..c2c00bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,13 @@ An unsaved version of “%1$s” from %2$s was found. Restore Discard draft + External change detected + “%1$s” changed outside TextPad. Reload the external version, overwrite it with your edits, save your edits as another file, or cancel to keep editing. + Overwrite + Reload + Reload external version? + Your unsaved TextPad edits will be discarded after the external version is loaded successfully. + File reloaded after an external change Settings About Continue diff --git a/app/src/test/java/com/maxistar/textpad/utils/DocumentSaveValidatorTest.java b/app/src/test/java/com/maxistar/textpad/utils/DocumentSaveValidatorTest.java new file mode 100644 index 0000000..bdbe09d --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/utils/DocumentSaveValidatorTest.java @@ -0,0 +1,76 @@ +package com.maxistar.textpad.utils; + +import static com.maxistar.textpad.utils.DocumentSaveValidator.Outcome.BASELINE_MATCH; +import static com.maxistar.textpad.utils.DocumentSaveValidator.Outcome.CONFLICT; +import static com.maxistar.textpad.utils.DocumentSaveValidator.Outcome.INTENDED_CONTENT_MATCH; +import static com.maxistar.textpad.utils.DocumentSaveValidator.Outcome.UNREADABLE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +public class DocumentSaveValidatorTest { + @Test + public void unchangedStoredBytesMatchBaseline() { + byte[] original = bytes("original"); + assertEquals(BASELINE_MATCH, DocumentSaveValidator.classify( + original, + DocumentSaveValidator.sha256(original), + bytes("local edit") + )); + } + + @Test + public void externallyWrittenIntendedBytesAreEquivalent() { + byte[] original = bytes("original"); + byte[] intended = bytes("local edit"); + assertEquals(INTENDED_CONTENT_MATCH, DocumentSaveValidator.classify( + intended, + DocumentSaveValidator.sha256(original), + intended + )); + } + + @Test + public void independentlyChangedVersionsConflict() { + assertEquals(CONFLICT, DocumentSaveValidator.classify( + bytes("external edit"), + DocumentSaveValidator.sha256(bytes("original")), + bytes("local edit") + )); + } + + @Test + public void missingBaselineOrCurrentBytesAreUnreadable() { + assertEquals(UNREADABLE, DocumentSaveValidator.classify(bytes("current"), null, bytes("local"))); + assertEquals(UNREADABLE, DocumentSaveValidator.classify(null, "hash", bytes("local"))); + } + + @Test + public void validationDoesNotRequireProviderMetadata() { + byte[] original = bytes("same content"); + assertEquals(BASELINE_MATCH, DocumentSaveValidator.classify( + original, + DocumentSaveValidator.sha256(original), + bytes("new content") + )); + } + + @Test + public void bomAndEncodingChangesAreByteChanges() { + byte[] plain = bytes("note"); + byte[] withBom = new byte[]{(byte) 0xef, (byte) 0xbb, (byte) 0xbf, 'n', 'o', 't', 'e'}; + assertNotEquals(DocumentSaveValidator.sha256(plain), DocumentSaveValidator.sha256(withBom)); + assertEquals(CONFLICT, DocumentSaveValidator.classify( + withBom, + DocumentSaveValidator.sha256(plain), + bytes("local edit") + )); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} From e9ef0e242efa9feba8957aff734ec5e529f076d5 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Fri, 14 Aug 2026 19:35:33 +0200 Subject: [PATCH 2/3] fix tests --- .../textpad/test/EditorRecoveryTest.java | 8 ++++-- .../textpad/activities/EditorActivity.java | 27 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java index 81458d1..e081294 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java @@ -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; @@ -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()); @@ -145,13 +147,15 @@ public void pendingRecoveryDecisionCannotOverwriteDraftDuringRecreation() throws String content = "recoverable after recreation"; try (ActivityScenario 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)).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + 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)).perform(click()); + onView(withText(R.string.Restore)).inRoot(isDialog()).perform(click()); onView(withId(R.id.editText1)).check(matches(withText(content))); } } diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index 4aa6b8c..f09fdda 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -151,6 +151,9 @@ public class EditorActivity extends AppCompatActivity { private long editorGeneration = 0; private boolean suppressRecoveryTracking = false; private boolean recoveryDecisionPending = false; + private boolean recoveryDialogShowing = false; + private RecoveryDraft pendingRecoveryDraft; + private Runnable pendingRecoveryDiscardLoader; private Long originalSize; private Long originalLastModified; private String originalContentSha256; @@ -382,6 +385,10 @@ protected void onResume() { }); } + if (recoveryDecisionPending && !recoveryDialogShowing && pendingRecoveryDraft != null) { + mText.post(this::showPendingRecoveryDialog); + } + if (SettingsService.isLanguageWasChanged()) { Intent intent = getIntent(); finish(); @@ -460,6 +467,9 @@ private void offerActiveUntitledRecovery() { private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { recoveryDecisionPending = true; + pendingRecoveryDraft = draft; + pendingRecoveryDiscardLoader = discardLoader; + recoveryDialogShowing = true; String timestamp = DateFormat.getDateTimeInstance().format(new Date(draft.metadata.draftUpdatedAt)); String name = draft.metadata.displayName == null || draft.metadata.displayName.isEmpty() ? TPStrings.NEW_FILE_TXT @@ -469,7 +479,7 @@ private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { .setMessage(getString(R.string.Recovery_draft_message, name, timestamp)) .setPositiveButton(R.string.Restore, (dialog, which) -> restoreDraft(draft)) .setNegativeButton(R.string.Discard_draft, (dialog, which) -> { - recoveryDecisionPending = false; + clearPendingRecoveryDecision(); recoveryRepository.delete(draft.metadata.recoveryKey); if (draft.metadata.recoveryKey.equals(recoveryKey)) { recoveryKey = null; @@ -477,11 +487,18 @@ private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { discardLoader.run(); }) .setCancelable(false) + .setOnDismissListener(dialog -> recoveryDialogShowing = false) .show(); } + private void showPendingRecoveryDialog() { + if (recoveryDecisionPending && !recoveryDialogShowing && pendingRecoveryDraft != null) { + showRecoveryDialog(pendingRecoveryDraft, pendingRecoveryDiscardLoader); + } + } + private void restoreDraft(RecoveryDraft draft) { - recoveryDecisionPending = false; + clearPendingRecoveryDecision(); recoveryKey = draft.metadata.recoveryKey; urlFilename = draft.metadata.documentUri == null ? TPStrings.EMPTY : filenameFromIdentity(draft.metadata.documentUri); editorGeneration = draft.metadata.generation; @@ -497,6 +514,12 @@ private void restoreDraft(RecoveryDraft draft) { } } + private void clearPendingRecoveryDecision() { + recoveryDecisionPending = false; + pendingRecoveryDraft = null; + pendingRecoveryDiscardLoader = null; + } + private void applyStoredSelection() { int length = mText.length(); int start = Math.max(0, Math.min(selectionStart, length)); From f7f9558982fce6fdbede440d8a3947f316fda1d8 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 15 Aug 2026 20:21:47 +0200 Subject: [PATCH 3/3] fix translation and keyboard scrolling --- .../textpad/test/EditorImeInsetsTest.java | 202 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../textpad/activities/EditorActivity.java | 35 ++- app/src/main/res/values-ar/strings.xml | 7 + app/src/main/res/values-de/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-fr/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-ja/strings.xml | 7 + app/src/main/res/values-pl/strings.xml | 7 + app/src/main/res/values-pt/strings.xml | 7 + app/src/main/res/values-tr/strings.xml | 7 + app/src/main/res/values-uk/strings.xml | 7 + app/src/main/res/values-zh/strings.xml | 7 + 14 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/EditorImeInsetsTest.java diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorImeInsetsTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorImeInsetsTest.java new file mode 100644 index 0000000..68ce9a8 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorImeInsetsTest.java @@ -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 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 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 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 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); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e5ab751..c923b9d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:launchMode="singleTop" android:configChanges="locale" android:theme="@style/AppTheme.Editor" + android:windowSoftInputMode="adjustResize" android:exported="true" > diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index f09fdda..dc2795b 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -26,6 +26,7 @@ import android.content.pm.PackageManager; import android.graphics.Typeface; import android.graphics.Insets; +import android.graphics.Rect; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -263,21 +264,47 @@ private boolean simpleScrolling() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private void applyEdgeToEdgeInsets() { View editorRoot = findViewById(R.id.editor_root); + int initialPaddingLeft = editorRoot.getPaddingLeft(); + int initialPaddingTop = editorRoot.getPaddingTop(); + int initialPaddingRight = editorRoot.getPaddingRight(); + int initialPaddingBottom = editorRoot.getPaddingBottom(); editorRoot.setOnApplyWindowInsetsListener((view, windowInsets) -> { Insets bars = windowInsets.getInsets( WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() ); + Insets ime = windowInsets.getInsets(WindowInsets.Type.ime()); view.setPadding( - bars.left, - bars.top, - bars.right, - bars.bottom + initialPaddingLeft + bars.left, + initialPaddingTop + bars.top, + initialPaddingRight + bars.right, + initialPaddingBottom + Math.max(bars.bottom, ime.bottom) ); + view.post(() -> requestFocusedCaretOnScreen(view)); return windowInsets; }); editorRoot.requestApplyInsets(); } + private void requestFocusedCaretOnScreen(View editorRoot) { + EditText editor = editorRoot.findViewById(R.id.editText1); + if (editor == null || !editor.isFocused() || editor.getLayout() == null) { + return; + } + int selection = editor.getSelectionStart(); + if (selection < 0) { + return; + } + int line = editor.getLayout().getLineForOffset(selection); + Rect caret = new Rect( + editor.getTotalPaddingLeft(), + editor.getTotalPaddingTop() + editor.getLayout().getLineTop(line), + Math.max(editor.getTotalPaddingLeft() + 1, + editor.getWidth() - editor.getTotalPaddingRight()), + editor.getTotalPaddingTop() + editor.getLayout().getLineBottom(line) + ); + editor.requestRectangleOnScreen(caret, false); + } + private void openFileByUri(Uri u) { if (useAndroidManager()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 93d9b88..25c7cf0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -118,4 +118,11 @@ تم العثور على نسخة غير محفوظة من «%1$s» بتاريخ %2$s. استرداد تجاهل المسودة + تم اكتشاف تغيير خارجي + تم تغيير ”%1$s“ خارج TextPad. أعد تحميل الإصدار الخارجي، أو استبدله بتعديلاتك، أو احفظ تعديلاتك في ملف آخر، أو ألغِ للمتابعة في التحرير. + استبدال + إعادة تحميل + إعادة تحميل الإصدار الخارجي؟ + سيتم تجاهل تعديلات TextPad غير المحفوظة بعد تحميل الإصدار الخارجي بنجاح. + تمت إعادة تحميل الملف بعد تغيير خارجي diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a76f70e..74c9705 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -117,4 +117,11 @@ Eine ungespeicherte Version von „%1$s“ vom %2$s wurde gefunden. Wiederherstellen Entwurf verwerfen + Externe Änderung erkannt + „%1$s“ wurde außerhalb von TextPad geändert. Laden Sie die externe Version neu, überschreiben Sie sie mit Ihren Änderungen, speichern Sie Ihre Änderungen in einer anderen Datei oder brechen Sie ab, um weiterzuarbeiten. + Überschreiben + Neu laden + Externe Version neu laden? + Ihre nicht gespeicherten TextPad-Änderungen werden verworfen, nachdem die externe Version erfolgreich geladen wurde. + Datei nach externer Änderung neu geladen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 037eb9b..f6bfaf5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -115,4 +115,11 @@ Se encontró una versión sin guardar de «%1$s» del %2$s. Restaurar Descartar borrador + Se detectó un cambio externo + “%1$s” se modificó fuera de TextPad. Vuelve a cargar la versión externa, sobrescríbela con tus cambios, guarda tus cambios en otro archivo o cancela para seguir editando. + Sobrescribir + Volver a cargar + ¿Volver a cargar la versión externa? + Los cambios no guardados en TextPad se descartarán después de cargar correctamente la versión externa. + Archivo actualizado después de un cambio externo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 52823c9..a84e856 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -115,4 +115,11 @@ Une version non enregistrée de « %1$s » datant du %2$s a été trouvée. Restaurer Supprimer le brouillon + Modification externe détectée + « %1$s » a été modifié en dehors de TextPad. Rechargez la version externe, remplacez-la par vos modifications, enregistrez vos modifications dans un autre fichier ou annulez pour continuer la modification. + Écraser + Recharger + Recharger la version externe ? + Vos modifications TextPad non enregistrées seront supprimées après le chargement réussi de la version externe. + Fichier rechargé après une modification externe diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9a93b27..e716a17 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -115,4 +115,11 @@ È stata trovata una versione non salvata di «%1$s» del %2$s. Ripristina Elimina bozza + Rilevata una modifica esterna + “%1$s” è stato modificato al di fuori di TextPad. Ricarica la versione esterna, sovrascrivila con le tue modifiche, salva le modifiche in un altro file oppure annulla per continuare a modificare. + Sovrascrivi + Ricarica + Ricaricare la versione esterna? + Le modifiche non salvate in TextPad verranno eliminate dopo il caricamento corretto della versione esterna. + File ricaricato dopo una modifica esterna diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index f1f3b38..3ec5bac 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -115,4 +115,11 @@ %2$s に保存された「%1$s」の未保存バージョンが見つかりました。 復元 下書きを破棄 + 外部での変更を検出しました + 「%1$s」はTextPadの外部で変更されました。外部バージョンを再読み込みするか、編集内容で上書きするか、別のファイルに保存するか、キャンセルして編集を続けてください。 + 上書き + 再読み込み + 外部バージョンを再読み込みしますか? + 外部バージョンが正常に読み込まれた後、TextPadの未保存の編集内容は破棄されます。 + 外部での変更後にファイルを再読み込みしました diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1f7b6a4..dbe8bda 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -117,4 +117,11 @@ Znaleziono niezapisaną wersję „%1$s” z %2$s. Przywróć Odrzuć wersję roboczą + Wykryto zmianę zewnętrzną + Plik „%1$s” został zmieniony poza aplikacją TextPad. Wczytaj ponownie wersję zewnętrzną, zastąp ją swoimi zmianami, zapisz zmiany w innym pliku albo anuluj, aby kontynuować edycję. + Zastąp + Wczytaj ponownie + Wczytać ponownie wersję zewnętrzną? + Niezapisane zmiany w TextPad zostaną odrzucone po pomyślnym wczytaniu wersji zewnętrznej. + Plik wczytano ponownie po zmianie zewnętrznej diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 6205694..e1e73bb 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -117,4 +117,11 @@ Foi encontrada uma versão não guardada de «%1$s» de %2$s. Restaurar Descartar rascunho + Alteração externa detetada + “%1$s” foi alterado fora do TextPad. Recarregue a versão externa, substitua-a pelas suas alterações, guarde as alterações noutro ficheiro ou cancele para continuar a editar. + Substituir + Recarregar + Recarregar a versão externa? + As alterações não guardadas no TextPad serão descartadas depois de a versão externa ser carregada com sucesso. + Ficheiro recarregado após uma alteração externa diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2eb08a7..027fcd9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -118,4 +118,11 @@ “%1$s” belgesinin %2$s tarihli kaydedilmemiş bir sürümü bulundu. Geri yükle Taslağı sil + Harici değişiklik algılandı + “%1$s” TextPad dışında değiştirildi. Harici sürümü yeniden yükleyin, kendi değişikliklerinizle üzerine yazın, değişikliklerinizi başka bir dosyaya kaydedin veya düzenlemeye devam etmek için iptal edin. + Üzerine yaz + Yeniden yükle + Harici sürüm yeniden yüklensin mi? + Harici sürüm başarıyla yüklendikten sonra kaydedilmemiş TextPad değişiklikleriniz silinecektir. + Harici değişiklikten sonra dosya yeniden yüklendi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8c7842d..fcd70c9 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -113,4 +113,11 @@ Знайдено незбережену версію «%1$s» від %2$s. Відновити Видалити чернетку + Виявлено зовнішню зміну + Файл «%1$s» змінено поза TextPad. Завантажте зовнішню версію, перезапишіть її своїми змінами, збережіть зміни в іншому файлі або скасуйте дію, щоб продовжити редагування. + Перезаписати + Завантажити знову + Завантажити зовнішню версію? + Незбережені зміни TextPad буде відкинуто після успішного завантаження зовнішньої версії. + Файл оновлено після зовнішньої зміни diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2a7b377..0e5a743 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -111,4 +111,11 @@ 发现“%1$s”在 %2$s 的未保存版本。 恢复 丢弃草稿 + 检测到外部更改 + “%1$s”已在 TextPad 外部被更改。请重新加载外部版本、用您的编辑内容覆盖它、将编辑内容另存为其他文件,或取消并继续编辑。 + 覆盖 + 重新加载 + 重新加载外部版本? + 成功加载外部版本后,TextPad 中未保存的编辑内容将被丢弃。 + 已在外部更改后重新加载文件