From 2e2121de837cf0c04b5c0add9118b317db684e8e Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sun, 26 Jul 2026 21:15:47 +0200 Subject: [PATCH 1/8] update dependencies --- app/build.gradle | 29 +++++++++---------- .../textpad/utils/TextConverterTest.java | 6 ++-- build.gradle | 16 ++++------ 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f5b14fcb..94c92458 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,8 +4,8 @@ android { defaultConfig { applicationId "com.maxistar.textpad" minSdkVersion 21 - compileSdkVersion 35 - targetSdkVersion 35 + compileSdkVersion 36 + targetSdkVersion 36 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -36,21 +36,26 @@ android { buildTypes { release { minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt' signingConfig signingConfigs.release } } namespace 'com.maxistar.textpad' + testOptions { + unitTests.all { + useJUnitPlatform() + } + } + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation "androidx.annotation:annotation:" + rootProject.androidxAnnotationVersion - implementation platform("org.jetbrains.kotlin:kotlin-bom:1.8.0") - - - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' androidTestImplementation "androidx.test:core:" + rootProject.coreVersion; androidTestImplementation "androidx.test.ext:junit:" + rootProject.extJUnitVersion; @@ -58,13 +63,5 @@ android { androidTestImplementation "androidx.test:rules:" + rootProject.rulesVersion; androidTestImplementation "androidx.test.espresso:espresso-core:" + rootProject.espressoVersion; androidTestImplementation "androidx.test.espresso:espresso-intents:" + rootProject.espressoVersion; - androidTestImplementation "androidx.test.ext:truth:" + rootProject.extTruthVersion; - androidTestImplementation "androidx.test.ext:junit:" + rootProject.extJUnitVersion; - androidTestImplementation "org.robolectric:annotations:" + rootProject.robolectricVersion; - - } } - - - diff --git a/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java b/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java index 0fc22463..2b9b1c80 100644 --- a/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java +++ b/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java @@ -7,14 +7,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import java.util.function.Supplier; - public class TextConverterTest { @org.junit.jupiter.api.Test public void getInstance() { TextConverter converter = TextConverter.getInstance(); - assertNotNull((Object) "Should return instance", (Supplier) converter); + assertNotNull(converter, "Should return instance"); } @org.junit.jupiter.api.Test @@ -44,4 +42,4 @@ public void applyMacEndings() { String result = converter.applyEndings("\r\n\r\n", TextConverter.MACOS); assertEquals("\r\r", result); } -} \ No newline at end of file +} diff --git a/build.gradle b/build.gradle index 19388a71..bd662550 100644 --- a/build.gradle +++ b/build.gradle @@ -17,14 +17,10 @@ allprojects { } ext { - androidxAnnotationVersion = "1.5.0" - androidxCoreVersion = "1.9.0" - coreVersion = "1.6.1" - extJUnitVersion = "1.2.1" - runnerVersion = "1.6.1" - rulesVersion = "1.6.1" - espressoVersion = "3.6.1" - extJUnitVersion = "1.2.1" - extTruthVersion = "1.6.0" - robolectricVersion = "4.13" + androidxAnnotationVersion = "1.9.1" + coreVersion = "1.7.0" + extJUnitVersion = "1.3.0" + runnerVersion = "1.7.0" + rulesVersion = "1.7.0" + espressoVersion = "3.7.0" } From a776b16f537662007d7bcd1346f1835ace3ec47b Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Mon, 27 Jul 2026 08:38:31 +0200 Subject: [PATCH 2/8] fix window size --- .gitignore | 3 +- .../textpad/test/BasicActivityTest.java | 19 +++++++++++++ .../textpad/activities/EditorActivity.java | 28 +++++++++++++++++++ app/src/main/res/values-v35/styles.xml | 10 ------- 4 files changed, 49 insertions(+), 11 deletions(-) delete mode 100644 app/src/main/res/values-v35/styles.xml diff --git a/.gitignore b/.gitignore index 9b8cc191..bfd342ca 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ /releases images/scaled scripts/artifacts -scripts/dist \ No newline at end of file +scripts/dist +.DS_Store diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java index ae443b37..6eda90da 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java @@ -58,6 +58,25 @@ public void typeText() { // assertEquals("me.maxistar.testinteractivetestingsetup", appContext.getPackageName()); } + @Test + public void editorStartsBelowActionBar() { + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + EditorActivity activity = intentsTestRule.getActivity(); + View editor = activity.findViewById(R.id.editText1); + View actionBar = activity.findViewById(androidx.appcompat.R.id.action_bar_container); + int[] editorLocation = new int[2]; + int[] actionBarLocation = new int[2]; + + editor.getLocationOnScreen(editorLocation); + actionBar.getLocationOnScreen(actionBarLocation); + + assertTrue( + "Editor starts behind the ActionBar", + editorLocation[1] >= actionBarLocation[1] + actionBar.getHeight() + ); + } + public static ViewAction setTextInTextView(final String value){ return new ViewAction() { @Override 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 030ed057..448f09c8 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -23,6 +23,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Typeface; +import android.graphics.Insets; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -40,6 +41,7 @@ import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; +import android.view.WindowInsets; import android.view.inputmethod.EditorInfo; import android.webkit.WebView; import android.webkit.WebViewClient; @@ -161,6 +163,9 @@ public void onCreate(Bundle savedInstanceState) { } else { setContentView(R.layout.main); } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + applyEdgeToEdgeInsets(); + } mText = this.findViewById(R.id.editText1); mText.setBackgroundResource(android.R.color.transparent); if (!settingsService.isAutoWrapping()) { @@ -207,6 +212,29 @@ private boolean simpleScrolling() { return settingsService.isUseSimpleScrolling(); } + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + private void applyEdgeToEdgeInsets() { + View content = findViewById(android.R.id.content); + int initialPaddingLeft = content.getPaddingLeft(); + int initialPaddingTop = content.getPaddingTop(); + int initialPaddingRight = content.getPaddingRight(); + int initialPaddingBottom = content.getPaddingBottom(); + + content.setOnApplyWindowInsetsListener((view, windowInsets) -> { + Insets bars = windowInsets.getInsets( + WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() + ); + view.setPadding( + initialPaddingLeft + bars.left, + initialPaddingTop + bars.top, + initialPaddingRight + bars.right, + initialPaddingBottom + bars.bottom + ); + return windowInsets; + }); + content.requestApplyInsets(); + } + private void openFileByUri(Uri u) { if (useAndroidManager()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml deleted file mode 100644 index f4af0840..00000000 --- a/app/src/main/res/values-v35/styles.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file From e7a9f1367fe4378dab6c35b7c151ac772312da3b Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Fri, 31 Jul 2026 09:20:48 +0200 Subject: [PATCH 3/8] add action bar --- .../textpad/test/BasicActivityTest.java | 12 +-- .../textpad/test/EditorLayoutTest.java | 78 +++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../textpad/activities/EditorActivity.java | 22 +++--- app/src/main/res/layout/editor_toolbar.xml | 9 +++ app/src/main/res/layout/main.xml | 43 ++++++---- .../main/res/layout/main_simple_scrolling.xml | 36 +++++---- app/src/main/res/values/styles.xml | 5 ++ 8 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/EditorLayoutTest.java create mode 100644 app/src/main/res/layout/editor_toolbar.xml diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java index 6eda90da..f12ec7fe 100644 --- a/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java +++ b/app/src/androidTest/java/com/maxistar/textpad/test/BasicActivityTest.java @@ -59,21 +59,21 @@ public void typeText() { } @Test - public void editorStartsBelowActionBar() { + public void editorStartsBelowToolbar() { InstrumentationRegistry.getInstrumentation().waitForIdleSync(); EditorActivity activity = intentsTestRule.getActivity(); View editor = activity.findViewById(R.id.editText1); - View actionBar = activity.findViewById(androidx.appcompat.R.id.action_bar_container); + View toolbar = activity.findViewById(R.id.editor_toolbar); int[] editorLocation = new int[2]; - int[] actionBarLocation = new int[2]; + int[] toolbarLocation = new int[2]; editor.getLocationOnScreen(editorLocation); - actionBar.getLocationOnScreen(actionBarLocation); + toolbar.getLocationOnScreen(toolbarLocation); assertTrue( - "Editor starts behind the ActionBar", - editorLocation[1] >= actionBarLocation[1] + actionBar.getHeight() + "Editor starts behind the Toolbar", + editorLocation[1] >= toolbarLocation[1] + toolbar.getHeight() ); } diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorLayoutTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorLayoutTest.java new file mode 100644 index 00000000..00c8c190 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorLayoutTest.java @@ -0,0 +1,78 @@ +package com.maxistar.textpad.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.content.Context; +import android.preference.PreferenceManager; +import android.view.View; + +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.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class EditorLayoutTest { + + @After + public void restoreStandardScrolling() { + setSimpleScrolling(false); + } + + @Test + public void standardScrollingStartsBelowToolbar() { + setSimpleScrolling(false); + + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + assertNotNull(activity.findViewById(R.id.vscroll)); + assertNull(activity.findViewById(R.id.linear_layout)); + assertEditorStartsBelowToolbar(activity); + }); + } + } + + @Test + public void simpleScrollingStartsBelowToolbarWithoutScrollView() { + setSimpleScrolling(true); + + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + assertNotNull(activity.findViewById(R.id.linear_layout)); + assertNull(activity.findViewById(R.id.vscroll)); + assertEditorStartsBelowToolbar(activity); + }); + } + } + + private void assertEditorStartsBelowToolbar(EditorActivity activity) { + View toolbar = activity.findViewById(R.id.editor_toolbar); + View editor = activity.findViewById(R.id.editText1); + int[] toolbarLocation = new int[2]; + int[] editorLocation = new int[2]; + + toolbar.getLocationOnScreen(toolbarLocation); + editor.getLocationOnScreen(editorLocation); + + assertEquals(toolbarLocation[1] + toolbar.getHeight(), editorLocation[1]); + } + + 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 cc9d08a8..9d7b5111 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ android:name=".activities.EditorActivity" android:launchMode="singleTop" android:configChanges="locale" + android:theme="@style/AppTheme.Editor" 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 448f09c8..a2cb7329 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -69,6 +69,7 @@ import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.view.menu.MenuBuilder; +import androidx.appcompat.widget.Toolbar; import android.content.DialogInterface; @@ -163,6 +164,8 @@ public void onCreate(Bundle savedInstanceState) { } else { setContentView(R.layout.main); } + Toolbar toolbar = findViewById(R.id.editor_toolbar); + setSupportActionBar(toolbar); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { applyEdgeToEdgeInsets(); } @@ -214,25 +217,20 @@ private boolean simpleScrolling() { @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) private void applyEdgeToEdgeInsets() { - View content = findViewById(android.R.id.content); - int initialPaddingLeft = content.getPaddingLeft(); - int initialPaddingTop = content.getPaddingTop(); - int initialPaddingRight = content.getPaddingRight(); - int initialPaddingBottom = content.getPaddingBottom(); - - content.setOnApplyWindowInsetsListener((view, windowInsets) -> { + View editorRoot = findViewById(R.id.editor_root); + editorRoot.setOnApplyWindowInsetsListener((view, windowInsets) -> { Insets bars = windowInsets.getInsets( WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() ); view.setPadding( - initialPaddingLeft + bars.left, - initialPaddingTop + bars.top, - initialPaddingRight + bars.right, - initialPaddingBottom + bars.bottom + bars.left, + bars.top, + bars.right, + bars.bottom ); return windowInsets; }); - content.requestApplyInsets(); + editorRoot.requestApplyInsets(); } private void openFileByUri(Uri u) { diff --git a/app/src/main/res/layout/editor_toolbar.xml b/app/src/main/res/layout/editor_toolbar.xml new file mode 100644 index 00000000..51ada5ec --- /dev/null +++ b/app/src/main/res/layout/editor_toolbar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index 1f3fc62e..3ed731d9 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -1,20 +1,29 @@ - + - - + + + + + + + diff --git a/app/src/main/res/layout/main_simple_scrolling.xml b/app/src/main/res/layout/main_simple_scrolling.xml index d3a47cb2..c9c51b5b 100644 --- a/app/src/main/res/layout/main_simple_scrolling.xml +++ b/app/src/main/res/layout/main_simple_scrolling.xml @@ -2,22 +2,30 @@ + android:orientation="vertical"> - + + + android:layout_height="0dp" + android:layout_weight="1"> + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 3eb1679a..797f3399 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -3,4 +3,9 @@ @color/colorPrimary @color/textColorPrimary + + From d3cf71b5fb72444c003db08caf191c090824b42e Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Thu, 6 Aug 2026 12:08:31 +0200 Subject: [PATCH 4/8] add recovery functions --- .../textpad/test/EditorRecoveryTest.java | 325 +++++++++++++++ .../test/recovery/RecoveryRepositoryTest.java | 166 ++++++++ .../test/recovery/RecoveryWriterTest.java | 127 ++++++ .../textpad/activities/EditorActivity.java | 375 ++++++++++++++++-- .../textpad/recovery/RecoveryDraft.java | 11 + .../textpad/recovery/RecoveryKeys.java | 43 ++ .../textpad/recovery/RecoveryMetadata.java | 193 +++++++++ .../textpad/recovery/RecoveryRepository.java | 198 +++++++++ .../textpad/recovery/RecoveryWriter.java | 105 +++++ app/src/main/res/layout/main.xml | 3 +- .../main/res/layout/main_simple_scrolling.xml | 1 + app/src/main/res/values-ar/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values-pl/strings.xml | 4 + app/src/main/res/values-pt/strings.xml | 4 + app/src/main/res/values-ru/strings.xml | 4 + app/src/main/res/values-tr/strings.xml | 4 + app/src/main/res/values-uk/strings.xml | 4 + app/src/main/res/values-zh/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../textpad/recovery/RecoveryKeysTest.java | 33 ++ 25 files changed, 1605 insertions(+), 27 deletions(-) create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java create mode 100644 app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java create mode 100644 app/src/main/java/com/maxistar/textpad/recovery/RecoveryDraft.java create mode 100644 app/src/main/java/com/maxistar/textpad/recovery/RecoveryKeys.java create mode 100644 app/src/main/java/com/maxistar/textpad/recovery/RecoveryMetadata.java create mode 100644 app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java create mode 100644 app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java create mode 100644 app/src/test/java/com/maxistar/textpad/recovery/RecoveryKeysTest.java diff --git a/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java new file mode 100644 index 00000000..b14d7da7 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/EditorRecoveryTest.java @@ -0,0 +1,325 @@ +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.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.ContentValues; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +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.ext.junit.runners.AndroidJUnit4; + +import com.maxistar.textpad.R; +import com.maxistar.textpad.ServiceLocator; +import com.maxistar.textpad.activities.EditorActivity; +import com.maxistar.textpad.activities.SettingsActivity; +import com.maxistar.textpad.recovery.RecoveryRepository; +import com.maxistar.textpad.recovery.RecoveryKeys; +import com.maxistar.textpad.recovery.RecoveryMetadata; +import com.maxistar.textpad.service.SettingsService; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.Assume; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +@RunWith(AndroidJUnit4.class) +public class EditorRecoveryTest { + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + clearRecovery(); + setSimpleScrolling(false); + } + + @After + public void tearDown() { + clearRecovery(); + setSimpleScrolling(false); + } + + @Test + public void largeDocumentStateIsBinderSafeInStandardLayout() { + assertLargeDocumentStateIsBinderSafe(false); + } + + @Test + public void largeDocumentStateIsBinderSafeInSimpleLayout() { + assertLargeDocumentStateIsBinderSafe(true); + } + + @Test + public void largeDocumentCanOpenSettingsWithoutProcessDeath() { + String content = generatedDocument(1_050_000); + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + ((EditText) activity.findViewById(R.id.editText1)).setText(content); + activity.startActivity(new Intent(activity, SettingsActivity.class)); + }); + onView(withText(R.string.Main_Settings)).check(matches(androidx.test.espresso.matcher.ViewMatchers.isDisplayed())); + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (android.app.Activity activity : androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry + .getInstance().getActivitiesInStage(androidx.test.runner.lifecycle.Stage.RESUMED)) { + if (activity instanceof SettingsActivity) { + activity.finish(); + } + } + }); + onView(withId(R.id.editText1)).check((view, noViewFoundException) -> assertEquals( + content.length(), + ((EditText) view).length() + )); + } + } + + @Test + public void changedUntitledDocumentCanBeRestoredAfterRecreation() { + String content = generatedDocument(220_000); + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + EditText editor = activity.findViewById(R.id.editText1); + editor.setText(content); + editor.setSelection(1234); + }); + + scenario.recreate(); + onView(withText(R.string.Restore)).perform(click()); + onView(withId(R.id.editText1)).check(matches(withText(content))); + scenario.onActivity(activity -> assertEquals( + 1234, + ((EditText) activity.findViewById(R.id.editText1)).getSelectionStart() + )); + } + } + + @Test + public void coldUntitledDraftCanBeDiscarded() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + new RecoveryRepository(context).write(metadata(key), "recoverable"); + + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + onView(withText(R.string.Discard_draft)).perform(click()); + onView(withId(R.id.editText1)).check(matches(withText(""))); + assertTrue(new RecoveryRepository(context).load(key, null) == null); + } + } + + @Test + public void dismissingRecoveryKeepsDraft() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + new RecoveryRepository(context).write(metadata(key), "recoverable"); + + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + pressBack(); + assertTrue(new RecoveryRepository(context).load(key, null) != null); + } + } + + @Test + public void namedDraftIsDetectedByExactDocumentUri() throws Exception { + String documentUri = "content://recovery-test/document/notes.txt"; + String key = RecoveryKeys.forDocumentUri(documentUri); + new RecoveryRepository(context).write(metadata(key, documentUri), "named recovery"); + Intent intent = new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse(documentUri)); + + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + onView(withText(R.string.Restore)).perform(click()); + onView(withId(R.id.editText1)).check(matches(withText("named recovery"))); + } + } + + @Test + public void successfulNamedSaveDeletesMatchingRecovery() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri documentUri = createTestDocument("original"); + String key = RecoveryKeys.forDocumentUri(documentUri.toString()); + new RecoveryRepository(context).write(metadata(key, documentUri.toString()), "recovered and saved"); + Intent intent = new Intent(context, EditorActivity.class) + .setAction(Intent.ACTION_VIEW) + .setData(documentUri); + + try (ActivityScenario scenario = ActivityScenario.launch(intent)) { + onView(withText(R.string.Restore)).perform(click()); + scenario.onActivity(activity -> invokeNoArgument(activity, "saveNamedFile")); + assertTrue(new RecoveryRepository(context).load(key, documentUri.toString()) == null); + assertEquals("recovered and saved", readDocument(documentUri)); + } finally { + context.getContentResolver().delete(documentUri, null, null); + } + } + + @Test + public void successfulSaveAsRemovesUntitledRecoveryAndUsesNamedIdentity() throws Exception { + Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + Uri documentUri = createTestDocument(""); + String untitledKey = RecoveryKeys.forUntitledDocument(); + new RecoveryRepository(context).write(metadata(untitledKey), "saved as named"); + + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + onView(withText(R.string.Restore)).perform(click()); + scenario.onActivity(activity -> { + invokeOneStringArgument(activity, "setFilename", documentUri.toString()); + invokeNoArgument(activity, "saveNamedFile"); + }); + assertTrue(new RecoveryRepository(context).load(untitledKey, null) == null); + assertEquals("saved as named", readDocument(documentUri)); + } finally { + context.getContentResolver().delete(documentUri, null, null); + } + } + + private void assertLargeDocumentStateIsBinderSafe(boolean simpleScrolling) { + setSimpleScrolling(simpleScrolling); + String content = generatedDocument(1_050_000); + try (ActivityScenario scenario = ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> ((EditText) activity.findViewById(R.id.editText1)).setText(content)); + scenario.onActivity(activity -> { + Bundle state = new Bundle(); + activity.onSaveInstanceState(state); + Parcel parcel = Parcel.obtain(); + try { + parcel.writeBundle(state); + assertTrue("Saved state must remain compact", parcel.dataSize() < 100_000); + } finally { + parcel.recycle(); + } + }); + + scenario.moveToState(Lifecycle.State.CREATED); + scenario.moveToState(Lifecycle.State.RESUMED); + scenario.onActivity(activity -> assertEquals( + content.length(), + ((EditText) activity.findViewById(R.id.editText1)).length() + )); + } + } + + private static String generatedDocument(int length) { + StringBuilder value = new StringBuilder(length); + while (value.length() < length) { + value.append("0123456789abcdef\n"); + } + return value.substring(0, length); + } + + private RecoveryMetadata metadata(String key) { + return metadata(key, null); + } + + private RecoveryMetadata metadata(String key, String documentUri) { + return new RecoveryMetadata( + key, documentUri, documentUri == null ? "newfile.txt" : "notes.txt", + documentUri == null, "UTF-8", false, + null, null, null, 0, 0, 0, 0, 1 + ); + } + + private Uri createTestDocument(String content) throws Exception { + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, "textpad-recovery-" + 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"); + } + 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)); + } + return uri; + } + + private String readDocument(Uri uri) { + try (InputStream input = context.getContentResolver().openInputStream(uri); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + if (input == null) { + throw new IllegalStateException("Unable to read test document"); + } + 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()); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private static void invokeNoArgument(EditorActivity activity, String methodName) { + try { + Method method = EditorActivity.class.getDeclaredMethod(methodName); + method.setAccessible(true); + method.invoke(activity); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private static void invokeOneStringArgument(EditorActivity activity, String methodName, String value) { + try { + Method method = EditorActivity.class.getDeclaredMethod(methodName, String.class); + method.setAccessible(true); + method.invoke(activity, value); + } catch (Exception error) { + throw new AssertionError(error); + } + } + + private void setSimpleScrolling(boolean enabled) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(SettingsService.SETTING_USE_SIMPLE_SCROLLING, enabled) + .putBoolean(SettingsService.SETTING_OPEN_LAST_FILE, false) + .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/recovery/RecoveryRepositoryTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java new file mode 100644 index 00000000..8d801548 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryRepositoryTest.java @@ -0,0 +1,166 @@ +package com.maxistar.textpad.test.recovery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.maxistar.textpad.recovery.RecoveryDraft; +import com.maxistar.textpad.recovery.RecoveryKeys; +import com.maxistar.textpad.recovery.RecoveryMetadata; +import com.maxistar.textpad.recovery.RecoveryRepository; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; + +@RunWith(AndroidJUnit4.class) +public class RecoveryRepositoryTest { + private RecoveryRepository repository; + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + context.getSharedPreferences("editor_recovery", Context.MODE_PRIVATE).edit().clear().commit(); + repository = new RecoveryRepository(context); + deleteRecursively(repository.getDirectoryForTests()); + } + + @After + public void tearDown() { + deleteRecursively(repository.getDirectoryForTests()); + } + + @Test + public void metadataRoundTripIgnoresUnknownFields() throws Exception { + RecoveryMetadata source = metadata(RecoveryKeys.forUntitledDocument(), null, 7) + .published(5, "0123456789abcdef", 123456L); + JSONObject json = source.toJson(); + json.put("futureField", "ignored"); + + RecoveryMetadata restored = RecoveryMetadata.fromJson(json); + + assertEquals(source.recoveryKey, restored.recoveryKey); + assertEquals(source.encoding, restored.encoding); + assertEquals(source.cursorStart, restored.cursorStart); + assertEquals(source.generation, restored.generation); + } + + @Test(expected = Exception.class) + public void futureMetadataVersionIsRejected() throws Exception { + JSONObject json = metadata(RecoveryKeys.forUntitledDocument(), null, 1).toJson(); + json.put("formatVersion", RecoveryMetadata.CURRENT_FORMAT_VERSION + 1); + RecoveryMetadata.fromJson(json); + } + + @Test + public void writesSeparateDraftAndMetadataAndLoadsThem() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + repository.write(metadata(key, null, 3), "recover me"); + + RecoveryDraft loaded = repository.load(key, null); + + 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()); + } + + @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"); + try (FileOutputStream output = new FileOutputStream(draft, true)) { + output.write('x'); + } + assertNull(repository.load(key, null)); + + draft.delete(); + assertNull(repository.load(key, null)); + } + + @Test + public void sameLengthContentMismatchIsRejected() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + repository.write(metadata(key, null, 1), "first"); + File draft = new File(repository.getDirectoryForTests(), key + ".draft"); + try (FileOutputStream output = new FileOutputStream(draft, false)) { + output.write("other".getBytes(StandardCharsets.UTF_8)); + } + + assertNull(repository.load(key, null)); + } + + @Test + public void staleActivePointerIsCleared() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + repository.write(metadata(key, null, 1), "draft"); + new File(repository.getDirectoryForTests(), key + ".draft").delete(); + + assertNull(repository.loadActive()); + assertNull(ApplicationProvider.getApplicationContext() + .getSharedPreferences("editor_recovery", Context.MODE_PRIVATE) + .getString("active_key", null)); + } + + @Test + public void untitledDraftMigratesToUriAndDeletesOldPair() throws Exception { + String oldKey = RecoveryKeys.forUntitledDocument(); + String uri = "content://provider/document/notes.txt"; + repository.write(metadata(oldKey, null, 9), "draft"); + + RecoveryDraft migrated = repository.migrate(oldKey, uri, "notes.txt"); + + assertNotNull(migrated); + assertNotEquals(oldKey, migrated.metadata.recoveryKey); + assertEquals(uri, migrated.metadata.documentUri); + assertNull(repository.load(oldKey, null)); + assertNotNull(repository.load(RecoveryKeys.forDocumentUri(uri), uri)); + } + + @Test + public void deleteRemovesPairAndActivePointer() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + repository.write(metadata(key, null, 1), "draft"); + repository.delete(key); + + assertNull(repository.load(key, null)); + assertFalse(new File(repository.getDirectoryForTests(), key + ".draft").exists()); + assertFalse(new File(repository.getDirectoryForTests(), key + ".json").exists()); + } + + private RecoveryMetadata metadata(String key, String uri, long generation) { + return new RecoveryMetadata( + key, uri, "notes.txt", uri == null, "UTF-8", false, + 12L, null, "fingerprint", 0, 0, 2, 4, generation + ); + } + + 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/recovery/RecoveryWriterTest.java b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java new file mode 100644 index 00000000..49f0c6d1 --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/test/recovery/RecoveryWriterTest.java @@ -0,0 +1,127 @@ +package com.maxistar.textpad.test.recovery; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.maxistar.textpad.recovery.RecoveryDraft; +import com.maxistar.textpad.recovery.RecoveryKeys; +import com.maxistar.textpad.recovery.RecoveryMetadata; +import com.maxistar.textpad.recovery.RecoveryRepository; +import com.maxistar.textpad.recovery.RecoveryWriter; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +@RunWith(AndroidJUnit4.class) +public class RecoveryWriterTest { + private RecoveryRepository repository; + private RecoveryWriter writer; + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + context.getSharedPreferences("editor_recovery", Context.MODE_PRIVATE).edit().clear().commit(); + repository = new RecoveryRepository(context); + deleteRecursively(repository.getDirectoryForTests()); + writer = new RecoveryWriter(repository); + } + + @After + public void tearDown() { + writer.shutdown(); + deleteRecursively(repository.getDirectoryForTests()); + } + + @Test + public void newerGenerationSupersedesDebouncedSnapshot() { + String key = RecoveryKeys.forUntitledDocument(); + writer.schedule(snapshot(key, 1, "old")); + + writer.flushAndWait(snapshot(key, 2, "new"), 2000); + + RecoveryDraft draft = repository.load(key, null); + assertNotNull(draft); + assertEquals("new", draft.text); + assertEquals(2, draft.metadata.generation); + } + + @Test + public void staleGenerationCannotReplaceNewerSnapshot() { + String key = RecoveryKeys.forUntitledDocument(); + writer.flushAndWait(snapshot(key, 5, "newest"), 2000); + writer.flushAndWait(snapshot(key, 4, "stale"), 2000); + + RecoveryDraft draft = repository.load(key, null); + assertNotNull(draft); + assertEquals("newest", draft.text); + assertEquals(5, draft.metadata.generation); + } + + @Test + public void failedWritePreservesPreviousValidGeneration() throws Exception { + String key = RecoveryKeys.forUntitledDocument(); + repository.write(snapshot(key, 1, "valid").metadata, "valid"); + RecoveryRepository failingRepository = new RecoveryRepository(ApplicationProvider.getApplicationContext()) { + @Override + public synchronized RecoveryMetadata write(RecoveryMetadata metadata, String text) throws Exception { + throw new java.io.IOException("expected test failure"); + } + }; + RecoveryWriter failingWriter = new RecoveryWriter(failingRepository); + try { + failingWriter.flushAndWait(snapshot(key, 2, "failed"), 2000); + } finally { + failingWriter.shutdown(); + } + + RecoveryDraft draft = repository.load(key, null); + assertNotNull(draft); + assertEquals("valid", draft.text); + assertEquals(1, draft.metadata.generation); + } + + @Test + public void cancellationBarrierPreventsQueuedDraftFromReappearingAfterDelete() { + String key = RecoveryKeys.forUntitledDocument(); + writer.schedule(snapshot(key, 3, "queued")); + writer.cancelAndWait(3, 2000); + repository.delete(key); + + try { + Thread.sleep(800); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + assertEquals(null, repository.load(key, null)); + } + + private RecoveryWriter.Snapshot snapshot(String key, long generation, String text) { + RecoveryMetadata metadata = new RecoveryMetadata( + key, null, "newfile.txt", true, "UTF-8", false, + null, null, null, 0, 0, 0, 0, generation + ); + return new RecoveryWriter.Snapshot(metadata, text); + } + + 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/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index a2cb7329..9d61063c 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -1,6 +1,7 @@ package com.maxistar.textpad.activities; import java.io.DataInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -9,6 +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; @@ -60,6 +64,11 @@ import com.maxistar.textpad.service.AlternativeUrlsService; import com.maxistar.textpad.service.RecentFilesService; import com.maxistar.textpad.service.ThemeService; +import com.maxistar.textpad.recovery.RecoveryDraft; +import com.maxistar.textpad.recovery.RecoveryKeys; +import com.maxistar.textpad.recovery.RecoveryMetadata; +import com.maxistar.textpad.recovery.RecoveryRepository; +import com.maxistar.textpad.recovery.RecoveryWriter; import com.maxistar.textpad.utils.EditTextUndoRedo; import com.maxistar.textpad.utils.FileNameHelper; import com.maxistar.textpad.utils.System; @@ -85,6 +94,8 @@ public class EditorActivity extends AppCompatActivity { private static final String STATE_FILENAME = "filename"; private static final String STATE_CHANGED = "changed"; private static final String STATE_CURSOR_POSITION = "cursor-position"; + private static final String STATE_CURSOR_END = "cursor-end"; + private static final String STATE_RECOVERY_KEY = "recovery-key"; private static final int REQUEST_OPEN = 1; private static final int REQUEST_SAVE = 2; @@ -133,6 +144,16 @@ public class EditorActivity extends AppCompatActivity { private String next_action_filename = ""; static int selectionStart = 0; + private int selectionEnd = 0; + + private RecoveryRepository recoveryRepository; + private RecoveryWriter recoveryWriter; + private String recoveryKey; + private long editorGeneration = 0; + private boolean suppressRecoveryTracking = false; + private Long originalSize; + private Long originalLastModified; + private String originalContentSha256; SettingsService settingsService; @@ -158,6 +179,9 @@ public void onCreate(Bundle savedInstanceState) { settingsService = ServiceLocator.getInstance().getSettingsService(this.getApplicationContext()); recentFilesService = ServiceLocator.getInstance().getRecentFilesService(); alternativeUrlsService = ServiceLocator.getInstance().getAlternativeUrlsService(); + recoveryRepository = new RecoveryRepository(getApplicationContext()); + recoveryRepository.cleanupIncompleteArtifacts(); + recoveryWriter = new RecoveryWriter(recoveryRepository); if (simpleScrolling()) { setContentView(R.layout.main_simple_scrolling); @@ -175,6 +199,7 @@ public void onCreate(Bundle savedInstanceState) { disableEditorAutowrapping(); } editTextUndoRedo = new EditTextUndoRedo(mText, this); + setTextWatcher(); if (simpleScrolling()) { linearLayout = findViewById(R.id.linear_layout); @@ -185,6 +210,7 @@ public void onCreate(Bundle savedInstanceState) { if (savedInstanceState != null) { restoreState(savedInstanceState); + restoreEditorContent(); } else { verifyPermissions(this); @@ -199,12 +225,13 @@ public void onCreate(Bundle savedInstanceState) { if (isFilenameEmpty()) { if (settingsService.isOpenLastFile()) { openLastFile(); + } else { + offerActiveUntitledRecovery(); } } } } - setTextWatcher(); updateTitle(); mText.requestFocus(); @@ -252,6 +279,9 @@ private void setTextWatcher() { textWatcher = new TextWatcher() { @Override public void afterTextChanged(Editable s) { + if (!suppressRecoveryTracking && changed) { + scheduleRecoverySnapshot(); + } } @Override @@ -260,11 +290,14 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - if (changed) { + if (suppressRecoveryTracking) { return; } - changed = true; - updateTitle(); + editorGeneration++; + if (!changed) { + changed = true; + updateTitle(); + } } }; } @@ -317,11 +350,8 @@ public boolean onKeyDown(int keyCode, KeyEvent event) { protected void onResume() { super.onResume(); - String t = mText.getText().toString().toLowerCase(Locale.getDefault()); mText.addTextChangedListener(textWatcher); - if (selectionStart < t.length()) { - mText.setSelection(selectionStart, selectionStart); - } + applyStoredSelection(); if (SettingsService.isLanguageWasChanged()) { Intent intent = getIntent(); @@ -341,6 +371,10 @@ protected void onPause() { mText.removeTextChangedListener(textWatcher); selectionStart = mText.getSelectionStart(); + selectionEnd = mText.getSelectionEnd(); + if (changed) { + flushRecoverySnapshot(); + } if (settingsService.useWakeLock()) { ServiceLocator.getInstance().getWakeLockService().releaseLock(); } @@ -358,22 +392,216 @@ private void restoreState(Bundle state) { urlFilename = state.getString(STATE_FILENAME); changed = state.getBoolean(STATE_CHANGED); selectionStart = state.getInt(STATE_CURSOR_POSITION); + selectionEnd = state.getInt(STATE_CURSOR_END, selectionStart); + recoveryKey = state.getString(STATE_RECOVERY_KEY); + } + + private void restoreEditorContent() { + if (changed && recoveryKey != null) { + RecoveryDraft draft = recoveryRepository.load(recoveryKey, documentIdentityUri()); + if (draft != null) { + showRecoveryDialog(draft, this::loadStoredDocumentAfterDiscard); + return; + } + } + loadStoredDocumentAfterDiscard(); + } + + private void loadStoredDocumentAfterDiscard() { + if (isFilenameEmpty()) { + setEditorText(TPStrings.EMPTY, false); + return; + } + if (useAndroidManager()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + openNamedFileDirect(Uri.parse(urlFilename)); + } + } else { + openNamedFileLegacyDirect(urlFilename); + } + } + + private void offerActiveUntitledRecovery() { + RecoveryDraft draft = recoveryRepository.loadActive(); + if (draft != null && draft.metadata.untitled) { + recoveryKey = draft.metadata.recoveryKey; + showRecoveryDialog(draft, () -> setEditorText(TPStrings.EMPTY, false)); + } + } + + private void showRecoveryDialog(RecoveryDraft draft, Runnable discardLoader) { + String timestamp = DateFormat.getDateTimeInstance().format(new Date(draft.metadata.draftUpdatedAt)); + String name = draft.metadata.displayName == null || draft.metadata.displayName.isEmpty() + ? TPStrings.NEW_FILE_TXT + : draft.metadata.displayName; + new AlertDialog.Builder(this) + .setTitle(R.string.Recovery_draft_found) + .setMessage(getString(R.string.Recovery_draft_message, name, timestamp)) + .setPositiveButton(R.string.Restore, (dialog, which) -> restoreDraft(draft)) + .setNegativeButton(R.string.Discard_draft, (dialog, which) -> { + recoveryRepository.delete(draft.metadata.recoveryKey); + if (draft.metadata.recoveryKey.equals(recoveryKey)) { + recoveryKey = null; + } + discardLoader.run(); + }) + .setOnCancelListener(dialog -> { }) + .show(); + } + + private void restoreDraft(RecoveryDraft draft) { + recoveryKey = draft.metadata.recoveryKey; + urlFilename = draft.metadata.documentUri == null ? TPStrings.EMPTY : filenameFromIdentity(draft.metadata.documentUri); + editorGeneration = draft.metadata.generation; + selectionStart = draft.metadata.cursorStart; + selectionEnd = draft.metadata.cursorEnd; + originalSize = draft.metadata.originalSize; + originalLastModified = draft.metadata.originalLastModified; + originalContentSha256 = draft.metadata.originalContentSha256; + setEditorText(draft.text, true); + updateTitle(); + } + + private void applyStoredSelection() { + int length = mText.length(); + int start = Math.max(0, Math.min(selectionStart, length)); + int end = Math.max(0, Math.min(selectionEnd, length)); + mText.setSelection(Math.min(start, end), Math.max(start, end)); + } + + private void setEditorText(String text, boolean markChanged) { + suppressRecoveryTracking = true; + mText.setText(text); + editTextUndoRedo.clearHistory(); + suppressRecoveryTracking = false; + changed = markChanged; + applyStoredSelection(); + } + + private void scheduleRecoverySnapshot() { + RecoveryWriter.Snapshot snapshot = createRecoverySnapshot(); + if (snapshot != null) { + recoveryWriter.schedule(snapshot); + } + } + + private void flushRecoverySnapshot() { + RecoveryWriter.Snapshot snapshot = createRecoverySnapshot(); + if (snapshot != null) { + recoveryWriter.flushAndWait(snapshot, 2000); + } + } + + private RecoveryWriter.Snapshot createRecoverySnapshot() { + if (!changed) { + return null; + } + String identity = documentIdentityUri(); + if (recoveryKey == null) { + recoveryKey = identity == null + ? RecoveryKeys.forUntitledDocument() + : RecoveryKeys.forDocumentUri(identity); + } + int start = Math.max(0, mText.getSelectionStart()); + int end = Math.max(0, mText.getSelectionEnd()); + RecoveryMetadata metadata = new RecoveryMetadata( + recoveryKey, + identity, + currentDisplayName(), + identity == null, + settingsService.getFileEncoding(), + false, + originalSize, + originalLastModified, + originalContentSha256, + 0, + 0, + start, + end, + editorGeneration + ); + return new RecoveryWriter.Snapshot(metadata, mText.getText().toString()); + } + + private String documentIdentityUri() { + if (isFilenameEmpty()) { + return null; + } + if (useAndroidManager()) { + return urlFilename; + } + return Uri.fromFile(new File(urlFilename)).toString(); + } + + private String filenameFromIdentity(String identity) { + if (useAndroidManager()) { + return identity; + } + String path = Uri.parse(identity).getPath(); + return path == null ? TPStrings.EMPTY : path; + } + + private String currentDisplayName() { + if (isFilenameEmpty()) { + return TPStrings.NEW_FILE_TXT; + } + if (useAndroidManager()) { + return FileNameHelper.getFilenameByUri(this, Uri.parse(urlFilename)); + } + return new File(urlFilename).getName(); + } + + private void recordLoadedDocument(byte[] originalBytes, Long lastModified) { + originalSize = (long) originalBytes.length; + originalLastModified = lastModified; + originalContentSha256 = sha256(originalBytes); + String identity = documentIdentityUri(); + recoveryKey = identity == null ? null : RecoveryKeys.forDocumentUri(identity); + editorGeneration = 0; + selectionStart = 0; + 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 */ public void onSaveInstanceState(@NonNull Bundle outState) { + if (changed) { + flushRecoverySnapshot(); + } super.onSaveInstanceState(outState); outState.putString(STATE_FILENAME, urlFilename); outState.putBoolean(STATE_CHANGED, changed); outState.putInt(STATE_CURSOR_POSITION, mText.getSelectionStart()); + outState.putInt(STATE_CURSOR_END, mText.getSelectionEnd()); + outState.putString(STATE_RECOVERY_KEY, recoveryKey); } protected void onStop() { super.onStop(); } + @Override + protected void onDestroy() { + recoveryWriter.shutdown(); + editTextUndoRedo.disconnect(); + super.onDestroy(); + } + @Override public void onBackPressed() { if (this.changed && !exitDialogShown) { @@ -381,6 +609,7 @@ public void onBackPressed() { .setTitle(R.string.You_have_made_some_changes) .setMessage(R.string.Are_you_sure_to_quit) .setNegativeButton(R.string.Yes, (arg0, arg1) -> { + discardCurrentRecovery(); EditorActivity.super.onBackPressed(); exitDialogShown = false; }) @@ -424,6 +653,8 @@ void openLastFile() { this.openNamedFileLegacy(settingsService.getLastFilename()); } showToast(formatString(R.string.opened_last_edited_file, settingsService.getLastFilename())); + } else { + offerActiveUntitledRecovery(); } } @@ -812,7 +1043,10 @@ private void openRecentFile(int i) { EditorActivity.this.saveFile(); }) .setNegativeButton(R.string.No, - (dialog, which) -> openFileByName(filename)).show(); + (dialog, which) -> { + discardCurrentRecovery(); + openFileByName(filename); + }).show(); } else { openFileByName(filename); } @@ -849,12 +1083,25 @@ public void newFile() { } public void clearFile() { - mText.setText(TPStrings.EMPTY); + discardCurrentRecovery(); + editorGeneration = 0; + originalSize = null; + originalLastModified = null; + originalContentSha256 = null; + selectionStart = 0; + selectionEnd = 0; + setEditorText(TPStrings.EMPTY, false); setFilename(TPStrings.EMPTY); initEditor(); updateTitle(); } + private void discardCurrentRecovery() { + recoveryWriter.cancelAndWait(editorGeneration, 2000); + recoveryRepository.delete(recoveryKey); + recoveryKey = null; + } + private void setFilename(String value) { this.urlFilename = value; storeLastFileName(value); @@ -912,7 +1159,10 @@ protected void openFile() { saveFile(); }) .setNegativeButton(R.string.No, - (dialog, which) -> openNewFile()).show(); + (dialog, which) -> { + discardCurrentRecovery(); + openNewFile(); + }).show(); } else { openNewFile(); } @@ -931,7 +1181,10 @@ protected void exitApplication() { EditorActivity.this.saveFile(); }) .setNegativeButton(R.string.No, - (dialog, which) -> System.exitFromApp(EditorActivity.this)).show(); + (dialog, which) -> { + discardCurrentRecovery(); + System.exitFromApp(EditorActivity.this); + }).show(); } else { System.exitFromApp(EditorActivity.this); } @@ -999,6 +1252,8 @@ protected boolean fileAlreadyExists() { } protected void saveNamedFileLegacy() { + long savedGeneration = editorGeneration; + String previousRecoveryKey = recoveryKey; try { File f = new File(getFilename()); if (!f.exists()) { @@ -1015,8 +1270,11 @@ protected void saveNamedFileLegacy() { fos.write(s.getBytes(settingsService.getFileEncoding())); fos.close(); + completeSuccessfulSave(savedGeneration, previousRecoveryKey, s.getBytes(settingsService.getFileEncoding()), f.lastModified()); showToast(R.string.File_Written); - initEditor(); + if (editorGeneration == savedGeneration) { + initEditor(); + } updateTitle(); if (next_action == DO_OPEN) { @@ -1072,12 +1330,23 @@ protected void saveFile(Uri uri) throws IOException { } protected void saveNamedFile() { + long savedGeneration = editorGeneration; + String previousRecoveryKey = recoveryKey; try { Uri uri = Uri.parse(getFilename()); saveFile(uri); + String persistedText = applyEndings(mText.getText().toString()); + completeSuccessfulSave( + savedGeneration, + previousRecoveryKey, + persistedText.getBytes(settingsService.getFileEncoding()), + null + ); showToast(R.string.File_Written); - initEditor(); + if (editorGeneration == savedGeneration) { + initEditor(); + } updateTitle(); if (next_action == DO_OPEN) { // because of multithread nature @@ -1108,7 +1377,50 @@ protected void saveNamedFile() { } } + private void completeSuccessfulSave( + long savedGeneration, + String previousRecoveryKey, + byte[] persistedBytes, + Long lastModified + ) { + String identity = documentIdentityUri(); + String targetKey = identity == null ? null : RecoveryKeys.forDocumentUri(identity); + originalSize = (long) persistedBytes.length; + originalLastModified = lastModified; + originalContentSha256 = sha256(persistedBytes); + + if (editorGeneration == savedGeneration) { + recoveryWriter.cancelAndWait(savedGeneration, 2000); + recoveryRepository.delete(previousRecoveryKey); + if (targetKey != null && !targetKey.equals(previousRecoveryKey)) { + recoveryRepository.delete(targetKey); + } + recoveryKey = targetKey; + return; + } + + if (previousRecoveryKey != null && identity != null && !targetKey.equals(previousRecoveryKey)) { + try { + recoveryRepository.migrate(previousRecoveryKey, identity, currentDisplayName()); + } catch (Exception ignored) { + // The previous valid draft remains available if migration fails. + } + } + recoveryKey = targetKey; + scheduleRecoverySnapshot(); + } + protected void openNamedFileLegacy(String filename) { + String identity = Uri.fromFile(new File(filename)).toString(); + RecoveryDraft draft = recoveryRepository.load(RecoveryKeys.forDocumentUri(identity), identity); + if (draft != null) { + showRecoveryDialog(draft, () -> openNamedFileLegacyDirect(filename)); + } else { + openNamedFileLegacyDirect(filename); + } + } + + private void openNamedFileLegacyDirect(String filename) { try { File f = new File(filename); FileInputStream fis = new FileInputStream(f); @@ -1126,16 +1438,15 @@ protected void openNamedFileLegacy(String filename) { ttt = toUnixEndings(ttt); - mText.setText(ttt); - editTextUndoRedo.clearHistory(); + setEditorText(ttt, false); showToast(getBaseContext().getResources().getString(R.string.File_opened_, filename)); initEditor(); this.setFilename(filename); + recordLoadedDocument(b, f.lastModified()); if (!settingsService.getLastFilename().equals(filename)) { settingsService.setLastFilename(filename, this.getApplicationContext()); } - selectionStart = 0; updateTitle(); } catch (FileNotFoundException e) { this.showToast(R.string.File_not_found); @@ -1149,6 +1460,17 @@ protected void openNamedFileLegacy(String filename) { @RequiresApi(api = Build.VERSION_CODES.KITKAT) protected void openNamedFile(final Uri uri) { + String identity = uri.toString(); + RecoveryDraft draft = recoveryRepository.load(RecoveryKeys.forDocumentUri(identity), identity); + if (draft != null) { + showRecoveryDialog(draft, () -> openNamedFileDirect(uri)); + } else { + openNamedFileDirect(uri); + } + } + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private void openNamedFileDirect(final Uri uri) { try { ContentResolver contentResolver = getContentResolver(); @@ -1156,27 +1478,28 @@ protected void openNamedFile(final Uri uri) { if (inputStream == null) { throw new IOException(); } - int size = inputStream.available(); - DataInputStream dis = new DataInputStream(inputStream); - byte[] b = new byte[size]; - int length = dis.read(b, 0, size); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(Math.max(8192, inputStream.available())); + byte[] buffer = new byte[8192]; + int count; + while ((count = inputStream.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + byte[] b = bytes.toByteArray(); - String ttt = new String(b, 0, length, settingsService.getFileEncoding()); + String ttt = new String(b, settingsService.getFileEncoding()); ttt = toUnixEndings(ttt); inputStream.close(); - dis.close(); - mText.setText(ttt); - editTextUndoRedo.clearHistory(); + setEditorText(ttt, false); showToast(getBaseContext().getResources().getString(R.string.File_opened_, getFilename())); initEditor(); setFilename(uri.toString()); + recordLoadedDocument(b, null); if (!settingsService.getLastFilename().equals(getFilename())) { settingsService.setLastFilename(getFilename(), this.getApplicationContext()); } - selectionStart = 0; if (lastTriedSystemUri != null) { alternativeUrlsService.addAlternativeUrl(lastTriedSystemUri, uri, getApplicationContext()); lastTriedSystemUri = null; diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryDraft.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryDraft.java new file mode 100644 index 00000000..730bc425 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryDraft.java @@ -0,0 +1,11 @@ +package com.maxistar.textpad.recovery; + +public final class RecoveryDraft { + public final RecoveryMetadata metadata; + public final String text; + + public RecoveryDraft(RecoveryMetadata metadata, String text) { + this.metadata = metadata; + this.text = text; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryKeys.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryKeys.java new file mode 100644 index 00000000..4d9c81e7 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryKeys.java @@ -0,0 +1,43 @@ +package com.maxistar.textpad.recovery; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; +import java.util.UUID; + +public final class RecoveryKeys { + private static final String URI_PREFIX = "uri-"; + private static final String NEW_PREFIX = "new-"; + + private RecoveryKeys() { + } + + public static String forDocumentUri(String documentUri) { + if (documentUri == null || documentUri.isEmpty()) { + throw new IllegalArgumentException("documentUri must not be empty"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(documentUri.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(URI_PREFIX); + for (byte value : hash) { + result.append(String.format(Locale.ROOT, "%02x", value & 0xff)); + } + return result.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public static String forUntitledDocument() { + return NEW_PREFIX + UUID.randomUUID(); + } + + public static boolean isValid(String recoveryKey) { + return recoveryKey != null && ( + recoveryKey.matches("uri-[0-9a-f]{64}") + || recoveryKey.matches("new-[0-9a-fA-F-]{36}") + ); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryMetadata.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryMetadata.java new file mode 100644 index 00000000..7772b468 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryMetadata.java @@ -0,0 +1,193 @@ +package com.maxistar.textpad.recovery; + +import org.json.JSONException; +import org.json.JSONObject; + +public final class RecoveryMetadata { + public static final int CURRENT_FORMAT_VERSION = 1; + + public final int formatVersion; + public final String recoveryKey; + public final String documentUri; + public final String displayName; + public final boolean untitled; + public final String encoding; + public final boolean hasBom; + public final Long originalSize; + public final Long originalLastModified; + public final String originalContentSha256; + public final long draftSize; + public final String draftContentSha256; + public final long draftUpdatedAt; + public final int cursorStart; + public final int cursorEnd; + public final long generation; + + public RecoveryMetadata( + String recoveryKey, + String documentUri, + String displayName, + boolean untitled, + String encoding, + boolean hasBom, + Long originalSize, + Long originalLastModified, + String originalContentSha256, + long draftSize, + long draftUpdatedAt, + int cursorStart, + int cursorEnd, + long generation + ) { + this( + CURRENT_FORMAT_VERSION, + recoveryKey, + documentUri, + displayName, + untitled, + encoding, + hasBom, + originalSize, + originalLastModified, + originalContentSha256, + draftSize, + null, + draftUpdatedAt, + cursorStart, + cursorEnd, + generation + ); + } + + private RecoveryMetadata( + int formatVersion, + String recoveryKey, + String documentUri, + String displayName, + boolean untitled, + String encoding, + boolean hasBom, + Long originalSize, + Long originalLastModified, + String originalContentSha256, + long draftSize, + String draftContentSha256, + long draftUpdatedAt, + int cursorStart, + int cursorEnd, + long generation + ) { + this.formatVersion = formatVersion; + this.recoveryKey = recoveryKey; + this.documentUri = documentUri; + this.displayName = displayName; + this.untitled = untitled; + this.encoding = encoding; + this.hasBom = hasBom; + this.originalSize = originalSize; + this.originalLastModified = originalLastModified; + this.originalContentSha256 = originalContentSha256; + this.draftSize = draftSize; + this.draftContentSha256 = draftContentSha256; + this.draftUpdatedAt = draftUpdatedAt; + this.cursorStart = cursorStart; + this.cursorEnd = cursorEnd; + this.generation = generation; + } + + public RecoveryMetadata published(long size, String contentSha256, long updatedAt) { + return new RecoveryMetadata( + formatVersion, + recoveryKey, + documentUri, + displayName, + untitled, + encoding, + hasBom, + originalSize, + originalLastModified, + originalContentSha256, + size, + contentSha256, + updatedAt, + cursorStart, + cursorEnd, + generation + ); + } + + public RecoveryMetadata withIdentity(String newKey, String newUri, String newDisplayName) { + return new RecoveryMetadata( + formatVersion, + newKey, + newUri, + newDisplayName, + false, + encoding, + hasBom, + originalSize, + originalLastModified, + originalContentSha256, + draftSize, + draftContentSha256, + draftUpdatedAt, + cursorStart, + cursorEnd, + generation + ); + } + + public JSONObject toJson() throws JSONException { + JSONObject value = new JSONObject(); + value.put("formatVersion", formatVersion); + value.put("recoveryKey", recoveryKey); + value.put("documentUri", documentUri == null ? JSONObject.NULL : documentUri); + value.put("displayName", displayName); + value.put("isUntitled", untitled); + value.put("encoding", encoding); + value.put("hasBom", hasBom); + value.put("originalSize", originalSize == null ? JSONObject.NULL : originalSize); + value.put("originalLastModified", originalLastModified == null ? JSONObject.NULL : originalLastModified); + value.put("originalContentSha256", originalContentSha256 == null ? JSONObject.NULL : originalContentSha256); + value.put("draftSize", draftSize); + value.put("draftContentSha256", draftContentSha256); + value.put("draftUpdatedAt", draftUpdatedAt); + value.put("cursorStart", cursorStart); + value.put("cursorEnd", cursorEnd); + value.put("generation", generation); + return value; + } + + public static RecoveryMetadata fromJson(JSONObject value) throws JSONException { + int version = value.getInt("formatVersion"); + if (version != CURRENT_FORMAT_VERSION) { + throw new JSONException("Unsupported recovery metadata version: " + version); + } + return new RecoveryMetadata( + version, + value.getString("recoveryKey"), + nullableString(value, "documentUri"), + value.optString("displayName", ""), + value.getBoolean("isUntitled"), + value.optString("encoding", "UTF-8"), + value.optBoolean("hasBom", false), + nullableLong(value, "originalSize"), + nullableLong(value, "originalLastModified"), + nullableString(value, "originalContentSha256"), + value.getLong("draftSize"), + value.getString("draftContentSha256"), + value.getLong("draftUpdatedAt"), + value.optInt("cursorStart", 0), + value.optInt("cursorEnd", value.optInt("cursorStart", 0)), + value.optLong("generation", 0) + ); + } + + private static String nullableString(JSONObject value, String name) throws JSONException { + return value.isNull(name) ? null : value.getString(name); + } + + private static Long nullableLong(JSONObject value, String name) throws JSONException { + return value.isNull(name) ? null : value.getLong(name); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java new file mode 100644 index 00000000..3b4bc0db --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryRepository.java @@ -0,0 +1,198 @@ +package com.maxistar.textpad.recovery; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.AtomicFile; + +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Locale; + +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 final File directory; + private final SharedPreferences preferences; + + public RecoveryRepository(Context context) { + Context applicationContext = context.getApplicationContext(); + directory = new File(applicationContext.getFilesDir(), DIRECTORY); + preferences = applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + } + + 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"); + } + + byte[] content = text.getBytes(StandardCharsets.UTF_8); + RecoveryMetadata published = metadata.published( + content.length, + 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; + } + + 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; + } + return new RecoveryDraft(metadata, new String(content, StandardCharsets.UTF_8)); + } catch (Exception ignored) { + return null; + } + } + + public synchronized RecoveryDraft loadActive() { + String key = preferences.getString(ACTIVE_KEY, null); + RecoveryDraft draft = load(key, null); + if (key != null && draft == null) { + clearActiveKey(key); + } + return draft; + } + + public synchronized RecoveryDraft migrate(String oldKey, String documentUri, String displayName) throws Exception { + RecoveryDraft oldDraft = load(oldKey, null); + if (oldDraft == null) { + return null; + } + String newKey = RecoveryKeys.forDocumentUri(documentUri); + RecoveryMetadata migrated = oldDraft.metadata.withIdentity(newKey, documentUri, displayName); + RecoveryMetadata published = write(migrated, oldDraft.text); + delete(oldKey); + return new RecoveryDraft(published, oldDraft.text); + } + + public synchronized void delete(String recoveryKey) { + if (!RecoveryKeys.isValid(recoveryKey)) { + return; + } + new AtomicFile(draftFile(recoveryKey)).delete(); + new AtomicFile(metadataFile(recoveryKey)).delete(); + clearActiveKey(recoveryKey); + } + + public synchronized void cleanupIncompleteArtifacts() { + if (!directory.isDirectory()) { + return; + } + File[] files = directory.listFiles(); + if (files == null) { + return; + } + 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(); + } + } + } + } + + public File getDirectoryForTests() { + return directory; + } + + private void setActiveKey(String recoveryKey) { + preferences.edit().putString(ACTIVE_KEY, recoveryKey).commit(); + } + + private void clearActiveKey(String recoveryKey) { + if (recoveryKey != null && recoveryKey.equals(preferences.getString(ACTIVE_KEY, null))) { + preferences.edit().remove(ACTIVE_KEY).commit(); + } + } + + private File draftFile(String key) { + return new File(directory, key + ".draft"); + } + + private File metadataFile(String key) { + return new File(directory, key + ".json"); + } + + private static void requireValidKey(String key) { + if (!RecoveryKeys.isValid(key)) { + throw new IllegalArgumentException("Invalid recovery key"); + } + } + + private static void writeAtomic(File file, byte[] content) throws IOException { + AtomicFile atomicFile = new AtomicFile(file); + FileOutputStream stream = atomicFile.startWrite(); + try { + stream.write(content); + stream.flush(); + atomicFile.finishWrite(stream); + } catch (IOException error) { + atomicFile.failWrite(stream); + throw error; + } + } + + 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))) { + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + return output.toByteArray(); + } + } + + 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) { + throw new IllegalStateException("SHA-256 is unavailable", 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 new file mode 100644 index 00000000..995f64ff --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/recovery/RecoveryWriter.java @@ -0,0 +1,105 @@ +package com.maxistar.textpad.recovery; + +import android.util.Log; + +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public final class RecoveryWriter { + private static final String LOG_TAG = "EditorRecovery"; + private static final long DEBOUNCE_MILLIS = 600; + + private final RecoveryRepository repository; + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "textpad-recovery"); + thread.setDaemon(true); + return thread; + }); + private ScheduledFuture pending; + private long newestGeneration; + + public RecoveryWriter(RecoveryRepository repository) { + this.repository = repository; + } + + public synchronized void schedule(Snapshot snapshot) { + newestGeneration = Math.max(newestGeneration, snapshot.metadata.generation); + if (pending != null) { + pending.cancel(false); + } + 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) { + pending.cancel(false); + pending = null; + } + } + Future future = executor.submit(() -> writeIfCurrent(snapshot)); + try { + future.get(timeoutMillis, TimeUnit.MILLISECONDS); + return true; + } catch (Exception error) { + Log.w(LOG_TAG, "Recovery flush did not complete", error); + return false; + } + } + + public synchronized void cancelPending() { + if (pending != null) { + pending.cancel(false); + pending = null; + } + } + + public boolean cancelAndWait(long generation, long timeoutMillis) { + synchronized (this) { + newestGeneration = Math.max(newestGeneration, generation); + if (pending != null) { + pending.cancel(false); + pending = null; + } + } + Future barrier = executor.submit(() -> { }); + try { + barrier.get(timeoutMillis, TimeUnit.MILLISECONDS); + return true; + } catch (Exception error) { + Log.w(LOG_TAG, "Recovery writer did not become idle", error); + return false; + } + } + + public void shutdown() { + executor.shutdown(); + } + + private void writeIfCurrent(Snapshot snapshot) { + synchronized (this) { + if (snapshot.metadata.generation < newestGeneration) { + return; + } + } + try { + repository.write(snapshot.metadata, snapshot.text); + } catch (Exception error) { + Log.e(LOG_TAG, "Unable to write recovery draft", error); + } + } + + public static final class Snapshot { + public final RecoveryMetadata metadata; + public final String text; + + public Snapshot(RecoveryMetadata metadata, String text) { + this.metadata = metadata; + this.text = text; + } + } +} diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index 3ed731d9..7cec1f83 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -24,6 +24,7 @@ android:hint="@string/Type_Something_Here" android:inputType="textMultiLine|textAutoComplete|textAutoCorrect" android:minLines="10" - android:padding="5dp" /> + android:padding="5dp" + android:saveEnabled="false" /> diff --git a/app/src/main/res/layout/main_simple_scrolling.xml b/app/src/main/res/layout/main_simple_scrolling.xml index c9c51b5b..73368790 100644 --- a/app/src/main/res/layout/main_simple_scrolling.xml +++ b/app/src/main/res/layout/main_simple_scrolling.xml @@ -26,6 +26,7 @@ android:inputType="text|textAutoComplete|textAutoCorrect" android:overScrollMode="ifContentScrolls" android:padding="5dip" + android:saveEnabled="false" android:scrollbars="vertical" /> diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 86610488..93d9b88b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -114,4 +114,8 @@ المتابعة في وضع القراءة فقط + مسودة استرداد غير محفوظة + تم العثور على نسخة غير محفوظة من «%1$s» بتاريخ %2$s. + استرداد + تجاهل المسودة diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7aa4b14e..a76f70ec 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -113,4 +113,8 @@ Im Nur-Lese-Modus fortfahren + Ungespeicherter Entwurf + Eine ungespeicherte Version von „%1$s“ vom %2$s wurde gefunden. + Wiederherstellen + Entwurf verwerfen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 00ccd0ca..037eb9b1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -111,4 +111,8 @@ Continuar en modo solo lectura Corregir desplazamiento errático Activa esto si el texto salta inesperadamente al final durante el desplazamiento + Borrador sin guardar + Se encontró una versión sin guardar de «%1$s» del %2$s. + Restaurar + Descartar borrador diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cbd983a6..52823c9f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -111,4 +111,8 @@ Continuer en lecture seule Corriger le défilement erratique Activez ceci si le texte saute de manière inattendue à la fin pendant le défilement + Brouillon non enregistré + Une version non enregistrée de « %1$s » datant du %2$s a été trouvée. + Restaurer + Supprimer le brouillon diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b291d7ef..9a93b278 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -111,4 +111,8 @@ Continua in sola lettura Correggi scorrimento irregolare Attiva questa opzione se il testo salta inaspettatamente alla fine durante lo scorrimento + Bozza non salvata + È stata trovata una versione non salvata di «%1$s» del %2$s. + Ripristina + Elimina bozza diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fd304d54..f1f3b385 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -111,4 +111,8 @@ 読み取り専用で続行 不安定なスクロールを修正 スクロール中にテキストが予期せず最後までジャンプする場合に有効にしてください + 未保存の復元用下書き + %2$s に保存された「%1$s」の未保存バージョンが見つかりました。 + 復元 + 下書きを破棄 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index caa78238..1f7b6a43 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -113,4 +113,8 @@ Kontynuuj w trybie tylko do odczytu + Niezapisana wersja robocza + Znaleziono niezapisaną wersję „%1$s” z %2$s. + Przywróć + Odrzuć wersję roboczą diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 91a12c9b..62056940 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -113,4 +113,8 @@ Continuar em modo somente leitura + Rascunho não guardado + Foi encontrada uma versão não guardada de «%1$s» de %2$s. + Restaurar + Descartar rascunho diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index aeef1a5f..7bf10f9f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,4 +111,8 @@ Продолжить в режиме только для чтения Исправить нестабильную прокрутку Включите, если текст неожиданно перепрыгивает в конец при прокрутке + Найден несохранённый черновик + Найдена несохранённая версия «%1$s» от %2$s. + Восстановить + Удалить черновик diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 33a912b7..2eb08a7a 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -114,4 +114,8 @@ Salt okunur olarak devam et + Kaydedilmemiş taslak + “%1$s” belgesinin %2$s tarihli kaydedilmemiş bir sürümü bulundu. + Geri yükle + Taslağı sil diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index b4c11837..8c7842dc 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -109,4 +109,8 @@ Продовжити в режимі лише для читання + Незбережена чернетка + Знайдено незбережену версію «%1$s» від %2$s. + Відновити + Видалити чернетку diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2339f3ae..2a7b377f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -107,4 +107,8 @@ 以只读模式继续 修复滚动问题 如果文本在滚动时意外跳转到末尾,请启用此选项 + 未保存的恢复草稿 + 发现“%1$s”在 %2$s 的未保存版本。 + 恢复 + 丢弃草稿 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87d74d61..44912d76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,10 @@ File not found Can not read file Operation Canceled + Unsaved recovery draft + An unsaved version of “%1$s” from %2$s was found. + Restore + Discard draft Settings About Continue diff --git a/app/src/test/java/com/maxistar/textpad/recovery/RecoveryKeysTest.java b/app/src/test/java/com/maxistar/textpad/recovery/RecoveryKeysTest.java new file mode 100644 index 00000000..1a9b6adc --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/recovery/RecoveryKeysTest.java @@ -0,0 +1,33 @@ +package com.maxistar.textpad.recovery; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RecoveryKeysTest { + @Test + public void uriKeyIsStableSha256() { + String uri = "content://provider/document/notes.txt"; + assertEquals(RecoveryKeys.forDocumentUri(uri), RecoveryKeys.forDocumentUri(uri)); + assertTrue(RecoveryKeys.forDocumentUri(uri).matches("uri-[0-9a-f]{64}")); + } + + @Test + public void differentUrisWithSameDisplayNameHaveDifferentKeys() { + assertNotEquals( + RecoveryKeys.forDocumentUri("content://one/folder/notes.txt"), + RecoveryKeys.forDocumentUri("content://two/folder/notes.txt") + ); + } + + @Test + public void untitledKeysAreUniqueAndValid() { + String first = RecoveryKeys.forUntitledDocument(); + String second = RecoveryKeys.forUntitledDocument(); + assertNotEquals(first, second); + assertTrue(RecoveryKeys.isValid(first)); + assertTrue(RecoveryKeys.isValid(second)); + } +} From 635df102a8fe9aa0bbf147234b4f01f3513c20c1 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 8 Aug 2026 13:55:01 +0200 Subject: [PATCH 5/8] add release automation --- .github/workflows/android-deploy.yml | 9 +- .github/workflows/android-test.yml | 15 +- .github/workflows/create-release.yml | 73 +++++ .github/workflows/promote-production.yml | 54 ++++ .github/workflows/release-pr.yml | 43 +++ .github/workflows/sync-master-to-dev.yml | 32 ++ .github/workflows/tagged-release.yml | 109 +++++++ .gitignore | 2 + app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 4 +- fastlane/Fastfile | 93 +++++- release-config.json | 14 + scripts/release_tool.py | 373 +++++++++++++++++++++++ scripts/test_release_tool.py | 130 ++++++++ scripts/test_release_workflows.py | 60 ++++ 15 files changed, 1005 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/promote-production.yml create mode 100644 .github/workflows/release-pr.yml create mode 100644 .github/workflows/sync-master-to-dev.yml create mode 100644 .github/workflows/tagged-release.yml create mode 100644 release-config.json create mode 100644 scripts/release_tool.py create mode 100644 scripts/test_release_tool.py create mode 100644 scripts/test_release_workflows.py diff --git a/.github/workflows/android-deploy.yml b/.github/workflows/android-deploy.yml index ef29099f..26e6c233 100644 --- a/.github/workflows/android-deploy.yml +++ b/.github/workflows/android-deploy.yml @@ -3,14 +3,18 @@ name: Android Build & Deploy on: workflow_dispatch: +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest + environment: release steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' @@ -39,6 +43,7 @@ jobs: - name: Build & deploy Android release run: bundle exec fastlane android deploy env: + ALLOW_LEGACY_PRODUCTION: "true" ANDROID_KEYSTORE: "../android_keystore.jks" KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS}} diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml index 875cda65..ca292efd 100644 --- a/.github/workflows/android-test.yml +++ b/.github/workflows/android-test.yml @@ -4,16 +4,19 @@ on: push: branches: [ "**" ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' @@ -23,5 +26,9 @@ jobs: run: chmod +x gradlew - name: Build with Gradle run: ./gradlew test - env: - ANDROID_KEYSTORE: "../android_keystore.jks" + - uses: actions/upload-artifact@v6 + if: always() + with: + name: android-test-reports + path: app/build/reports/tests/ + retention-days: 14 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..3b79ee35 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,73 @@ +name: Create Android Release + +on: + workflow_dispatch: + inputs: + version: + description: Expected versionName without the v prefix + required: true + type: string + commit_sha: + description: Exact merged master commit to tag + required: true + type: string + +permissions: + contents: write + +jobs: + create-tag: + runs-on: ubuntu-latest + environment: release + outputs: + tag: ${{ steps.identity.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: master + fetch-depth: 0 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.6 + bundler-cache: true + - name: Verify exact master commit + env: + EXPECTED_SHA: ${{ inputs.commit_sha }} + run: | + git fetch origin master --tags + test "$(git rev-parse HEAD)" = "$EXPECTED_SHA" + test "$(git rev-parse origin/master)" = "$EXPECTED_SHA" + - name: Resolve and verify identity + id: identity + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + python3 scripts/release_tool.py metadata > release-metadata.json + ACTUAL_VERSION=$(python3 -c 'import json; print(json.load(open("release-metadata.json"))["versionName"])') + TAG=$(python3 -c 'import json; print(json.load(open("release-metadata.json"))["tag"])') + test "$ACTUAL_VERSION" = "$EXPECTED_VERSION" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Decode Google Play service account + env: + SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json + - name: Repeat repository and Google Play validation + run: >- + python3 scripts/release_tool.py validate --skip-branch --check-play + --service-account service-account.json + - name: Create immutable annotated tag + env: + TAG: ${{ steps.identity.outputs.tag }} + EXPECTED_SHA: ${{ inputs.commit_sha }} + run: | + test -z "$(git tag --list "$TAG")" + git tag -a "$TAG" "$EXPECTED_SHA" -m "Release $TAG" + git push origin "refs/tags/$TAG" + + publish: + needs: create-tag + uses: ./.github/workflows/tagged-release.yml + with: + tag: ${{ needs.create-tag.outputs.tag }} + secrets: inherit + diff --git a/.github/workflows/promote-production.yml b/.github/workflows/promote-production.yml new file mode 100644 index 00000000..d6efd28a --- /dev/null +++ b/.github/workflows/promote-production.yml @@ -0,0 +1,54 @@ +name: Promote Android Production + +on: + workflow_dispatch: + inputs: + tag: + description: Immutable v release tag + required: true + type: string + version_code: + description: Existing internal-track versionCode + required: true + type: number + +permissions: + contents: read + +jobs: + promote: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.6 + bundler-cache: true + - name: Verify tag, commit, and versionCode + env: + TAG: ${{ inputs.tag }} + EXPECTED_CODE: ${{ inputs.version_code }} + run: | + git fetch origin --tags + test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "$TAG")" + test "$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag"])')" = "$TAG" + test "$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])')" = "$EXPECTED_CODE" + - name: Decode Google Play service account + env: + SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json + - name: Require the exact version on internal + run: >- + bundle exec fastlane android assert_track_version_code + version_code:${{ inputs.version_code }} track:internal + json_key:${{ github.workspace }}/service-account.json + - name: Promote existing internal artifact to completed production + run: >- + bundle exec fastlane android promote_release + version_code:${{ inputs.version_code }} + json_key:${{ github.workspace }}/service-account.json + diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 00000000..b64c755b --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,43 @@ +name: Validate Android Release PR + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + validate: + if: startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + - name: Validate release identity and localized notes + run: python3 scripts/release_tool.py validate + - name: Test release tooling + run: python3 -m unittest scripts/test_release_tool.py + - name: Test, lint, and assemble release outputs + run: ./gradlew test lint assembleRelease bundleRelease --no-daemon + - name: Verify assembled release identity + run: >- + python3 scripts/release_tool.py verify-build + --manifest app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml + --metadata app/build/intermediates/merged_manifests/release/processReleaseManifest/output-metadata.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: release-pr-validation + path: | + app/build/reports/ + app/build/outputs/apk/release/ + app/build/outputs/bundle/release/ + retention-days: 14 diff --git a/.github/workflows/sync-master-to-dev.yml b/.github/workflows/sync-master-to-dev.yml new file mode 100644 index 00000000..dba20d2d --- /dev/null +++ b/.github/workflows/sync-master-to-dev.yml @@ -0,0 +1,32 @@ +name: Sync Released Master to Dev + +on: + workflow_dispatch: + inputs: + tag: + description: Released v tag on master + required: true + type: string + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v6 + with: + ref: dev + fetch-depth: 0 + - name: Merge released master into dev + env: + TAG: ${{ inputs.tag }} + run: | + git fetch origin master dev --tags + test "$(git rev-list -n 1 "$TAG")" = "$(git rev-parse origin/master)" + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git merge --no-ff origin/master -m "Merge released $TAG back into dev" + git push origin HEAD:dev diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml new file mode 100644 index 00000000..ed0bd054 --- /dev/null +++ b/.github/workflows/tagged-release.yml @@ -0,0 +1,109 @@ +name: Publish Tagged Android Release + +on: + push: + tags: ['v*'] + workflow_call: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + steps: + - name: Resolve requested tag + id: requested + env: + INPUT_TAG: ${{ inputs.tag }} + REF_TAG: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = "workflow_call" ]; then TAG="$INPUT_TAG"; else TAG="$REF_TAG"; fi + case "$TAG" in v[0-9]*.[0-9]*.[0-9]*) ;; *) exit 2 ;; esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6 + with: + ref: ${{ steps.requested.outputs.tag }} + fetch-depth: 0 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0.6 + bundler-cache: true + - name: Verify tag and release identity + env: + TAG: ${{ steps.requested.outputs.tag }} + run: | + git fetch origin --tags + test "$(git rev-parse HEAD)" = "$(git rev-list -n 1 "$TAG")" + test "$(git cat-file -t "$TAG")" = "tag" + test "$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag"])')" = "$TAG" + python3 scripts/release_tool.py validate --skip-branch --allow-existing-tag + - name: Detect an existing complete GitHub Release + id: existing + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.requested.outputs.tag }} + run: | + mkdir -p release-artifacts + if gh release view "$TAG" >/dev/null 2>&1; then + gh release download "$TAG" --dir release-artifacts + (cd release-artifacts && sha256sum --check SHA256SUMS) + echo "complete=true" >> "$GITHUB_OUTPUT" + else + echo "complete=false" >> "$GITHUB_OUTPUT" + fi + - name: Decode Android keystore + if: steps.existing.outputs.complete != 'true' + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android-keystore.jks + - name: Build signed APK and AAB once + if: steps.existing.outputs.complete != 'true' + env: + ANDROID_KEYSTORE: ${{ github.workspace }}/android-keystore.jks + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + TAG: ${{ steps.requested.outputs.tag }} + run: | + ./gradlew clean test lint assembleRelease bundleRelease --no-daemon + cp app/build/outputs/apk/release/app-release.apk "release-artifacts/TextPad-$TAG.apk" + cp app/build/outputs/bundle/release/app-release.aab "release-artifacts/TextPad-$TAG.aab" + (cd release-artifacts && sha256sum "TextPad-$TAG.apk" "TextPad-$TAG.aab" > SHA256SUMS) + (cd release-artifacts && sha256sum --check SHA256SUMS) + - name: Publish GitHub Release and immutable assets + if: steps.existing.outputs.complete != 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.requested.outputs.tag }} + run: | + CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])') + NOTES="fastlane/metadata/android/en-US/changelogs/$CODE.txt" + gh release create "$TAG" release-artifacts/* --verify-tag --title "$TAG" --notes-file "$NOTES" + - name: Decode Google Play service account + env: + SERVICE_ACCOUNT_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} + run: printf '%s' "$SERVICE_ACCOUNT_BASE64" | base64 --decode > service-account.json + - name: Upload the published AAB to Play internal + env: + TAG: ${{ steps.requested.outputs.tag }} + run: | + CODE=$(python3 scripts/release_tool.py metadata | python3 -c 'import json,sys; print(json.load(sys.stdin)["versionCode"])') + bundle exec fastlane android upload_release \ + apk:${{ github.workspace }}/release-artifacts/TextPad-$TAG.apk \ + aab:${{ github.workspace }}/release-artifacts/TextPad-$TAG.aab \ + version_code:$CODE \ + metadata_path:${{ github.workspace }}/fastlane/metadata/android \ + json_key:${{ github.workspace }}/service-account.json \ + track:internal diff --git a/.gitignore b/.gitignore index bfd342ca..29f7b51f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ images/scaled scripts/artifacts scripts/dist +scripts/__pycache__/ +*.pyc .DS_Store diff --git a/app/build.gradle b/app/build.gradle index 94c92458..4c4e9afb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,8 @@ apply plugin: 'com.android.application' android { defaultConfig { applicationId "com.maxistar.textpad" + versionCode 60 + versionName "1.30.0" minSdkVersion 21 compileSdkVersion 36 targetSdkVersion 36 @@ -37,7 +39,9 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt' - signingConfig signingConfigs.release + if (System.getenv("ANDROID_KEYSTORE")) { + signingConfig signingConfigs.release + } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d7b5111..e5ab751a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,5 @@ - + diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 19c557cc..d6176657 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -30,9 +30,98 @@ platform :android do # You can also use other beta testing services here end - desc "Deploy a new version to the Google Play" + desc "Fail when a version code already exists on a Google Play track" + lane :validate_play_version_code do |options| + version_code = Integer(options.fetch(:version_code)) + json_key = options.fetch(:json_key) + tracks = %w[internal alpha beta production] + existing = tracks.to_h do |track| + codes = google_play_track_version_codes( + package_name: "com.maxistar.textpad", + json_key: json_key, + track: track + ).map(&:to_i) + [track, codes] + end + duplicates = existing.select { |_track, codes| codes.include?(version_code) }.keys + UI.user_error!("versionCode #{version_code} already exists on: #{duplicates.join(', ')}") unless duplicates.empty? + end + + desc "Require a version code to exist on one explicit Google Play track" + lane :assert_track_version_code do |options| + version_code = Integer(options.fetch(:version_code)) + track = options.fetch(:track) + codes = google_play_track_version_codes( + package_name: "com.maxistar.textpad", + json_key: options.fetch(:json_key), + track: track + ).map(&:to_i) + UI.user_error!("versionCode #{version_code} does not exist on #{track}") unless codes.include?(version_code) + end + + desc "Upload one already-built AAB and localized metadata to an explicit track" + lane :upload_release do |options| + apk = File.expand_path(options.fetch(:apk)) + aab = File.expand_path(options.fetch(:aab)) + version_code = Integer(options.fetch(:version_code)) + json_key = File.expand_path(options.fetch(:json_key)) + track = options.fetch(:track) + UI.user_error!("APK does not exist: #{apk}") unless File.file?(apk) + UI.user_error!("AAB does not exist: #{aab}") unless File.file?(aab) + existing = google_play_track_version_codes( + package_name: "com.maxistar.textpad", + json_key: json_key, + track: track + ).map(&:to_i) + if existing.include?(version_code) + UI.message("versionCode #{version_code} already exists on #{track}; upload is complete") + next + end + upload_to_play_store( + package_name: "com.maxistar.textpad", + aab: aab, + json_key: json_key, + metadata_path: File.expand_path(options.fetch(:metadata_path)), + track: track, + release_status: "completed", + skip_upload_apk: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end + + desc "Promote an existing internal version to a completed production release" + lane :promote_release do |options| + version_code = Integer(options.fetch(:version_code)) + json_key = File.expand_path(options.fetch(:json_key)) + production_codes = google_play_track_version_codes( + package_name: "com.maxistar.textpad", + json_key: json_key, + track: "production" + ).map(&:to_i) + if production_codes.include?(version_code) + UI.message("versionCode #{version_code} already exists on production; promotion is complete") + next + end + upload_to_play_store( + package_name: "com.maxistar.textpad", + json_key: json_key, + track: "internal", + track_promote_to: "production", + version_code: version_code, + track_promote_release_status: "completed", + skip_upload_apk: true, + skip_upload_aab: true, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end + + desc "Legacy direct production deploy retained only until the protected release rehearsal succeeds" lane :deploy do + UI.user_error!("Set ALLOW_LEGACY_PRODUCTION=true only for the documented emergency path") unless ENV["ALLOW_LEGACY_PRODUCTION"] == "true" gradle(task: "clean assembleRelease") - upload_to_play_store + upload_to_play_store(track: "production", release_status: "completed") end end diff --git a/release-config.json b/release-config.json new file mode 100644 index 00000000..ba5beafc --- /dev/null +++ b/release-config.json @@ -0,0 +1,14 @@ +{ + "branches": { + "development": "dev", + "stable": "master", + "releasePrefix": "release/", + "hotfixPrefix": "hotfix/" + }, + "tagPrefix": "v", + "metadataRoot": "fastlane/metadata/android", + "requiredLocales": ["en-US", "es-ES", "pl-PL", "ru-RU"], + "ignoredLocales": [], + "englishLocale": "en-US", + "placeholderMarkers": ["TODO: RELEASE NOTES", "TODO_RELEASE_NOTES"] +} diff --git a/scripts/release_tool.py b/scripts/release_tool.py new file mode 100644 index 00000000..74e98e8f --- /dev/null +++ b/scripts/release_tool.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Repository-owned Android release preparation and validation tool.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + + +ROOT = Path(__file__).resolve().parents[1] +CONFIG_PATH = ROOT / "release-config.json" +GRADLE_PATH = ROOT / "app" / "build.gradle" +MANIFEST_PATH = ROOT / "app" / "src" / "main" / "AndroidManifest.xml" +SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +VERSION_CODE_RE = re.compile(r"(?m)^\s*versionCode\s+(\d+)\s*$") +VERSION_NAME_RE = re.compile(r'(?m)^\s*versionName\s+["\']([^"\']+)["\']\s*$') + + +class ReleaseError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Version: + name: str + code: int + + @property + def semver(self) -> tuple[int, int, int]: + return parse_semver(self.name) + + +def load_config(path: Path = CONFIG_PATH) -> dict: + with path.open(encoding="utf-8") as stream: + config = json.load(stream) + required = {"branches", "tagPrefix", "metadataRoot", "requiredLocales", "placeholderMarkers"} + missing = required.difference(config) + if missing: + raise ReleaseError(f"release-config.json is missing: {', '.join(sorted(missing))}") + return config + + +def read_version(path: Path = GRADLE_PATH) -> Version: + text = path.read_text(encoding="utf-8") + codes = VERSION_CODE_RE.findall(text) + names = VERSION_NAME_RE.findall(text) + if len(codes) != 1 or len(names) != 1: + raise ReleaseError("Gradle must contain exactly one versionCode and versionName") + parse_semver(names[0]) + return Version(names[0], int(codes[0])) + + +def write_version(version: Version, path: Path = GRADLE_PATH) -> None: + text = path.read_text(encoding="utf-8") + text, code_count = VERSION_CODE_RE.subn(f" versionCode {version.code}", text) + text, name_count = VERSION_NAME_RE.subn(f' versionName "{version.name}"', text) + if code_count != 1 or name_count != 1: + raise ReleaseError("Refusing to update ambiguous Gradle version declarations") + path.write_text(text, encoding="utf-8") + + +def parse_semver(value: str) -> tuple[int, int, int]: + match = SEMVER_RE.fullmatch(value) + if not match: + raise ReleaseError(f"Invalid release SemVer: {value}") + return tuple(int(part) for part in match.groups()) + + +def next_version(current: Version, bump: str | None, explicit: str | None) -> Version: + if bool(bump) == bool(explicit): + raise ReleaseError("Specify exactly one of --bump or --version") + major, minor, patch = current.semver + if explicit: + candidate = parse_semver(explicit) + if candidate <= current.semver: + raise ReleaseError("Explicit version must be greater than the current version") + name = explicit + elif bump == "major": + name = f"{major + 1}.0.0" + elif bump == "minor": + name = f"{major}.{minor + 1}.0" + else: + name = f"{major}.{minor}.{patch + 1}" + return Version(name, current.code + 1) + + +def run(command: Sequence[str], *, check: bool = True) -> str: + result = subprocess.run(command, cwd=ROOT, check=False, text=True, capture_output=True) + if check and result.returncode: + message = result.stderr.strip() or result.stdout.strip() or "command failed" + raise ReleaseError(f"{' '.join(command)}: {message}") + return result.stdout.strip() + + +def git_ref_exists(ref: str) -> bool: + return subprocess.run( + ["git", "show-ref", "--verify", "--quiet", ref], cwd=ROOT, check=False + ).returncode == 0 + + +def discover_locales(config: dict, root: Path = ROOT) -> list[str]: + metadata_root = root / config["metadataRoot"] + ignored = set(config.get("ignoredLocales", [])) + locales = sorted( + item.name for item in metadata_root.iterdir() + if item.is_dir() and item.name not in ignored and (item / "title.txt").is_file() + ) + missing = sorted(set(config["requiredLocales"]) - set(locales)) + if missing: + raise ReleaseError(f"Required Fastlane locales are missing: {', '.join(missing)}") + if not locales: + raise ReleaseError("No Fastlane metadata locales discovered") + return locales + + +def changelog_paths(version: Version, config: dict, root: Path = ROOT) -> list[Path]: + return [ + root / config["metadataRoot"] / locale / "changelogs" / f"{version.code}.txt" + for locale in discover_locales(config, root) + ] + + +def validate_changelogs(version: Version, config: dict, root: Path = ROOT) -> None: + markers = tuple(config["placeholderMarkers"]) + errors = [] + for path in changelog_paths(version, config, root): + if not path.is_file(): + errors.append(f"missing {path.relative_to(root)}") + continue + content = path.read_text(encoding="utf-8").strip() + if not content: + errors.append(f"empty {path.relative_to(root)}") + elif any(marker in content for marker in markers): + errors.append(f"placeholder remains in {path.relative_to(root)}") + if errors: + raise ReleaseError("Invalid localized release notes:\n- " + "\n- ".join(errors)) + + +def verify_build_identity(version: Version, manifest_path: Path, metadata_path: Path) -> None: + android = "{http://schemas.android.com/apk/res/android}" + root = ET.parse(manifest_path).getroot() + manifest_code = int(root.attrib[android + "versionCode"]) + manifest_name = root.attrib[android + "versionName"] + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + element = metadata["elements"][0] + identities = { + (version.code, version.name), + (manifest_code, manifest_name), + (int(element["versionCode"]), element["versionName"]), + } + if len(identities) != 1: + raise ReleaseError(f"Build output identity does not match Gradle: {sorted(identities)}") + + +def version_from_tag(tag: str, config: dict) -> Version | None: + gradle = run(["git", "show", f"{tag}:app/build.gradle"], check=False) + code_match = VERSION_CODE_RE.search(gradle) + name_match = VERSION_NAME_RE.search(gradle) + if code_match and name_match: + return Version(name_match.group(1), int(code_match.group(1))) + manifest = run(["git", "show", f"{tag}:app/src/main/AndroidManifest.xml"], check=False) + code_match = re.search(r'android:versionCode="(\d+)"', manifest) + name_match = re.search(r'android:versionName="([^"]+)"', manifest) + if code_match and name_match: + return Version(name_match.group(1), int(code_match.group(1))) + return None + + +def validate_history(version: Version, config: dict, *, require_absent_tag: bool = True) -> None: + expected_tag = config["tagPrefix"] + version.name + if require_absent_tag and git_ref_exists(f"refs/tags/{expected_tag}"): + raise ReleaseError(f"Tag already exists: {expected_tag}") + historical = [ + parsed for tag in run(["git", "tag", "--list", f'{config["tagPrefix"]}*']).splitlines() + if require_absent_tag or tag != expected_tag + if (parsed := version_from_tag(tag, config)) is not None + ] + if historical and version.code <= max(item.code for item in historical): + raise ReleaseError("versionCode must be greater than all tagged releases") + same_code = [item.name for item in historical if item.code == version.code] + if same_code: + raise ReleaseError(f"versionCode {version.code} already belongs to {same_code[0]}") + + +def current_branch() -> str: + branch = run(["git", "branch", "--show-current"]) + if not branch: + raise ReleaseError("A branch checkout is required") + return branch + + +def validate_branch(version: Version, config: dict, *, mode: str | None = None) -> str: + branch = current_branch() + branches = config["branches"] + inferred = mode + if inferred is None: + if branch == f'{branches["releasePrefix"]}{version.name}': + inferred = "release" + elif branch == f'{branches["hotfixPrefix"]}{version.name}': + inferred = "hotfix" + else: + raise ReleaseError(f"Unexpected release branch: {branch}") + base = branches["development"] if inferred == "release" else branches["stable"] + expected = f'{branches[inferred + "Prefix"]}{version.name}' + if branch != expected: + raise ReleaseError(f"Expected branch {expected}, found {branch}") + base_ref = base if git_ref_exists(f"refs/heads/{base}") else f"origin/{base}" + if subprocess.run(["git", "merge-base", "--is-ancestor", base_ref, "HEAD"], cwd=ROOT).returncode: + raise ReleaseError(f"{branch} is not based on {base}") + return inferred + + +def candidate_conflicts(version: Version, config: dict, mode: str) -> list[str]: + branches = config["branches"] + branch = f'{branches[mode + "Prefix"]}{version.name}' + tag = config["tagPrefix"] + version.name + conflicts = [] + for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}", f"refs/tags/{tag}"): + if git_ref_exists(ref): + conflicts.append(ref) + if shutil.which("gh"): + gh = subprocess.run( + ["gh", "pr", "list", "--state", "open", "--head", branch, "--json", "number"], + cwd=ROOT, text=True, capture_output=True, check=False, + ) + if gh.returncode == 0 and json.loads(gh.stdout or "[]"): + conflicts.append(f"open pull request for {branch}") + return conflicts + + +def ensure_clean_current_base(config: dict, mode: str) -> str: + if run(["git", "status", "--porcelain"]): + raise ReleaseError("Working tree must be clean") + base = config["branches"]["development" if mode == "release" else "stable"] + if current_branch() != base: + raise ReleaseError(f"Preparation must start on {base}") + run(["git", "fetch", "origin", "--prune", "--tags"]) + local = run(["git", "rev-parse", base]) + remote = run(["git", "rev-parse", f"origin/{base}"]) + if local != remote: + raise ReleaseError(f"{base} must exactly match origin/{base}") + return base + + +def create_placeholders(version: Version, config: dict) -> None: + marker = config["placeholderMarkers"][0] + for path in changelog_paths(version, config): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{marker} ({version.name})\n", encoding="utf-8") + + +def prepare(args: argparse.Namespace) -> None: + config = load_config() + current = read_version() + candidate = next_version(current, args.bump, args.version) + mode = "hotfix" if args.hotfix else "release" + branches = config["branches"] + branch = f'{branches[mode + "Prefix"]}{candidate.name}' + base = branches["stable" if mode == "hotfix" else "development"] + summary = {"base": base, "branch": branch, "versionName": candidate.name, "versionCode": candidate.code} + if args.dry_run: + conflicts = candidate_conflicts(candidate, config, mode) + validate_history(candidate, config) + if conflicts: + raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts)) + print(json.dumps(summary, indent=2)) + return + if not shutil.which("gh"): + raise ReleaseError("GitHub CLI (gh) is required before preparation can mutate the repository") + ensure_clean_current_base(config, mode) + conflicts = candidate_conflicts(candidate, config, mode) + validate_history(candidate, config) + if conflicts: + raise ReleaseError("Release candidate already exists: " + ", ".join(conflicts)) + run(["git", "switch", "-c", branch]) + write_version(candidate) + create_placeholders(candidate, config) + run(["git", "add", "app/build.gradle", config["metadataRoot"]]) + run(["git", "commit", "-m", f"Prepare release {candidate.name}"]) + run(["git", "push", "-u", "origin", branch]) + checklist = "\n".join([ + "## Release checklist", "", "- [ ] Localized release notes completed", + "- [ ] Release validation passes", "- [ ] Release APK and AAB assemble successfully", + ]) + run(["gh", "pr", "create", "--base", branches["stable"], "--head", branch, + "--title", f"Release {candidate.name}", "--body", checklist]) + print(json.dumps(summary, indent=2)) + + +def metadata(args: argparse.Namespace) -> None: + config = load_config() + version = read_version() + print(json.dumps({ + "versionName": version.name, + "versionCode": version.code, + "tag": config["tagPrefix"] + version.name, + "locales": discover_locales(config), + "changelogs": [str(path.relative_to(ROOT)) for path in changelog_paths(version, config)], + }, indent=2)) + + +def validate(args: argparse.Namespace) -> None: + config = load_config() + version = read_version() + manifest = MANIFEST_PATH.read_text(encoding="utf-8") + if "android:versionCode" in manifest or "android:versionName" in manifest: + raise ReleaseError("Manifest contains competing Android version declarations") + validate_history(version, config, require_absent_tag=not args.allow_existing_tag) + validate_changelogs(version, config) + if not args.skip_branch: + validate_branch(version, config, args.mode) + if args.check_play: + if not args.service_account: + raise ReleaseError("--check-play requires --service-account") + run([ + "bundle", "exec", "fastlane", "android", "validate_play_version_code", + f"version_code:{version.code}", f"json_key:{Path(args.service_account).resolve()}", + ]) + print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code})) + + +def verify_build(args: argparse.Namespace) -> None: + version = read_version() + verify_build_identity(version, Path(args.manifest), Path(args.metadata)) + print(json.dumps({"valid": True, "versionName": version.name, "versionCode": version.code})) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + metadata_parser = commands.add_parser("metadata") + metadata_parser.set_defaults(handler=metadata) + validate_parser = commands.add_parser("validate") + validate_parser.add_argument("--mode", choices=("release", "hotfix")) + validate_parser.add_argument("--skip-branch", action="store_true") + validate_parser.add_argument("--allow-existing-tag", action="store_true") + validate_parser.add_argument("--check-play", action="store_true") + validate_parser.add_argument("--service-account") + validate_parser.set_defaults(handler=validate) + verify_parser = commands.add_parser("verify-build") + verify_parser.add_argument("--manifest", required=True) + verify_parser.add_argument("--metadata", required=True) + verify_parser.set_defaults(handler=verify_build) + prepare_parser = commands.add_parser("prepare") + group = prepare_parser.add_mutually_exclusive_group(required=True) + group.add_argument("--bump", choices=("patch", "minor", "major")) + group.add_argument("--version") + prepare_parser.add_argument("--hotfix", action="store_true") + prepare_parser.add_argument("--dry-run", action="store_true") + prepare_parser.set_defaults(handler=prepare) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + try: + args = build_parser().parse_args(argv) + args.handler(args) + return 0 + except (ReleaseError, OSError, json.JSONDecodeError) as error: + print(f"release-tool: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_release_tool.py b/scripts/test_release_tool.py new file mode 100644 index 00000000..c964a8a0 --- /dev/null +++ b/scripts/test_release_tool.py @@ -0,0 +1,130 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import release_tool + + +class ReleaseToolTest(unittest.TestCase): + def test_reads_single_gradle_version(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "build.gradle" + path.write_text('defaultConfig {\n versionCode 60\n versionName "1.30.0"\n}\n') + self.assertEqual(release_tool.Version("1.30.0", 60), release_tool.read_version(path)) + + def test_rejects_competing_gradle_versions(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "build.gradle" + path.write_text('versionCode 60\nversionCode 61\nversionName "1.30.0"\n') + with self.assertRaises(release_tool.ReleaseError): + release_tool.read_version(path) + + def test_semver_bumps_increment_code_once(self): + current = release_tool.Version("1.30.9", 60) + self.assertEqual(release_tool.Version("1.30.10", 61), release_tool.next_version(current, "patch", None)) + self.assertEqual(release_tool.Version("1.31.0", 61), release_tool.next_version(current, "minor", None)) + self.assertEqual(release_tool.Version("2.0.0", 61), release_tool.next_version(current, "major", None)) + self.assertEqual(release_tool.Version("3.2.1", 61), release_tool.next_version(current, None, "3.2.1")) + + def test_rejects_invalid_or_non_increasing_semver(self): + current = release_tool.Version("1.30.0", 60) + for value in ("1.30", "v1.31.0", "01.31.0", "1.29.0", "1.30.0"): + with self.subTest(value=value), self.assertRaises(release_tool.ReleaseError): + release_tool.next_version(current, None, value) + + def test_locale_discovery_and_changelog_validation(self): + config = { + "metadataRoot": "metadata", "requiredLocales": ["en-US", "ru-RU"], + "ignoredLocales": [], "placeholderMarkers": ["TODO: RELEASE NOTES"], + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for locale in config["requiredLocales"]: + base = root / "metadata" / locale + base.mkdir(parents=True) + (base / "title.txt").write_text("Title") + changes = base / "changelogs" + changes.mkdir() + (changes / "61.txt").write_text("A useful note") + self.assertEqual(["en-US", "ru-RU"], release_tool.discover_locales(config, root)) + release_tool.validate_changelogs(release_tool.Version("1.31.0", 61), config, root) + (root / "metadata/en-US/changelogs/61.txt").write_text("TODO: RELEASE NOTES") + with self.assertRaises(release_tool.ReleaseError): + release_tool.validate_changelogs(release_tool.Version("1.31.0", 61), config, root) + + def test_release_config_is_machine_readable_and_complete(self): + config = release_tool.load_config() + self.assertEqual("dev", config["branches"]["development"]) + self.assertEqual("master", config["branches"]["stable"]) + self.assertEqual("v", config["tagPrefix"]) + self.assertTrue(config["requiredLocales"]) + self.assertTrue(config["placeholderMarkers"]) + + def test_manifest_has_no_competing_version(self): + manifest = release_tool.MANIFEST_PATH.read_text(encoding="utf-8") + self.assertNotIn("android:versionCode", manifest) + self.assertNotIn("android:versionName", manifest) + + def test_build_outputs_match_gradle_identity(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "AndroidManifest.xml" + metadata = root / "output-metadata.json" + manifest.write_text( + '', encoding="utf-8" + ) + metadata.write_text(json.dumps({"elements": [{"versionCode": 60, "versionName": "1.30.0"}]})) + release_tool.verify_build_identity(release_tool.Version("1.30.0", 60), manifest, metadata) + metadata.write_text(json.dumps({"elements": [{"versionCode": 59, "versionName": "1.29.2"}]})) + with self.assertRaises(release_tool.ReleaseError): + release_tool.verify_build_identity(release_tool.Version("1.30.0", 60), manifest, metadata) + + @mock.patch.object(release_tool, "version_from_tag") + @mock.patch.object(release_tool, "run") + @mock.patch.object(release_tool, "git_ref_exists") + def test_history_rejects_duplicate_tag_and_code(self, ref_exists, run, from_tag): + config = {"tagPrefix": "v"} + ref_exists.return_value = True + with self.assertRaises(release_tool.ReleaseError): + release_tool.validate_history(release_tool.Version("1.31.0", 61), config) + ref_exists.return_value = False + run.return_value = "v1.30.0" + from_tag.return_value = release_tool.Version("1.30.0", 61) + with self.assertRaises(release_tool.ReleaseError): + release_tool.validate_history(release_tool.Version("1.31.0", 61), config) + + @mock.patch.object(release_tool.subprocess, "run") + @mock.patch.object(release_tool, "current_branch") + def test_branch_validation_rejects_incorrect_ancestry(self, branch, process): + branch.return_value = "release/1.31.0" + process.return_value = SimpleNamespace(returncode=1) + config = {"branches": {"development": "dev", "stable": "master", "releasePrefix": "release/", "hotfixPrefix": "hotfix/"}} + with self.assertRaises(release_tool.ReleaseError): + release_tool.validate_branch(release_tool.Version("1.31.0", 61), config) + + @mock.patch.object(release_tool, "run") + @mock.patch.object(release_tool, "validate_changelogs") + @mock.patch.object(release_tool, "validate_history") + @mock.patch.object(release_tool, "read_version") + @mock.patch.object(release_tool, "load_config") + def test_protected_validation_passes_code_to_fastlane(self, config, version, history, notes, run): + config.return_value = {} + version.return_value = release_tool.Version("1.31.0", 61) + args = SimpleNamespace( + allow_existing_tag=False, skip_branch=True, mode=None, + check_play=True, service_account="service.json", + ) + release_tool.validate(args) + command = run.call_args.args[0] + self.assertIn("validate_play_version_code", command) + self.assertIn("version_code:61", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_workflows.py b/scripts/test_release_workflows.py new file mode 100644 index 00000000..a7f1b9b3 --- /dev/null +++ b/scripts/test_release_workflows.py @@ -0,0 +1,60 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + + +class ReleaseWorkflowTest(unittest.TestCase): + def read(self, name): + return (WORKFLOWS / name).read_text(encoding="utf-8") + + def test_release_pr_is_read_only_and_has_no_protected_secrets(self): + workflow = self.read("release-pr.yml") + self.assertIn("contents: read", workflow) + self.assertNotIn("secrets.", workflow) + self.assertNotIn("git tag", workflow) + self.assertIn("assembleRelease bundleRelease", workflow) + + def test_create_release_checks_exact_master_before_annotated_tag(self): + workflow = self.read("create-release.yml") + self.assertIn('git rev-parse origin/master', workflow) + self.assertIn('git tag -a', workflow) + self.assertIn("--check-play", workflow) + self.assertIn("environment: release", workflow) + + def test_tagged_publication_orders_github_before_play(self): + workflow = self.read("tagged-release.yml") + github = workflow.index('gh release create') + play = workflow.index('fastlane android upload_release') + self.assertLess(github, play) + self.assertIn("sha256sum --check SHA256SUMS", workflow) + self.assertIn("environment: release", workflow) + + def test_production_has_approval_but_no_build_or_keystore(self): + workflow = self.read("promote-production.yml") + self.assertIn("environment: production", workflow) + self.assertIn("assert_track_version_code", workflow) + self.assertIn("promote_release", workflow) + self.assertNotIn("gradlew", workflow) + self.assertNotIn("ANDROID_KEYSTORE", workflow) + + def test_actions_are_versioned(self): + for path in WORKFLOWS.glob("*.yml"): + for action in re.findall(r"uses:\s*([^\s]+)", path.read_text(encoding="utf-8")): + if action.startswith("./"): + continue + self.assertRegex(action, r"@v\d+(?:\.\d+\.\d+)?$", path.name) + + def test_fastlane_release_arguments_are_explicit(self): + fastfile = (ROOT / "fastlane" / "Fastfile").read_text(encoding="utf-8") + for token in ("aab:", "apk =", "metadata_path:", "json_key:", "track:"): + self.assertIn(token, fastfile) + self.assertIn('track_promote_release_status: "completed"', fastfile) + self.assertNotIn("rollout:", fastfile) + + +if __name__ == "__main__": + unittest.main() From 10ef4d49d264a91442a0e35b360641ef574356fa Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 8 Aug 2026 14:48:36 +0200 Subject: [PATCH 6/8] Prepare release 1.31.0 --- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/61.txt | 1 + fastlane/metadata/android/es-ES/changelogs/61.txt | 1 + fastlane/metadata/android/pl-PL/changelogs/61.txt | 1 + fastlane/metadata/android/ru-RU/changelogs/61.txt | 1 + 5 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/61.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/61.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/61.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/61.txt diff --git a/app/build.gradle b/app/build.gradle index 4c4e9afb..87460580 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,8 +3,8 @@ apply plugin: 'com.android.application' android { defaultConfig { applicationId "com.maxistar.textpad" - versionCode 60 - versionName "1.30.0" + versionCode 61 + versionName "1.31.0" minSdkVersion 21 compileSdkVersion 36 targetSdkVersion 36 diff --git a/fastlane/metadata/android/en-US/changelogs/61.txt b/fastlane/metadata/android/en-US/changelogs/61.txt new file mode 100644 index 00000000..bec32697 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/61.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (1.31.0) diff --git a/fastlane/metadata/android/es-ES/changelogs/61.txt b/fastlane/metadata/android/es-ES/changelogs/61.txt new file mode 100644 index 00000000..bec32697 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/61.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (1.31.0) diff --git a/fastlane/metadata/android/pl-PL/changelogs/61.txt b/fastlane/metadata/android/pl-PL/changelogs/61.txt new file mode 100644 index 00000000..bec32697 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/61.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (1.31.0) diff --git a/fastlane/metadata/android/ru-RU/changelogs/61.txt b/fastlane/metadata/android/ru-RU/changelogs/61.txt new file mode 100644 index 00000000..bec32697 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/61.txt @@ -0,0 +1 @@ +TODO: RELEASE NOTES (1.31.0) From 5550d8a13c7c4f1d4c0765ad77c921b641176312 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 8 Aug 2026 14:56:30 +0200 Subject: [PATCH 7/8] update change notes --- fastlane/metadata/android/en-US/changelogs/61.txt | 4 +++- fastlane/metadata/android/es-ES/changelogs/61.txt | 4 +++- fastlane/metadata/android/pl-PL/changelogs/60.txt | 6 +++--- fastlane/metadata/android/pl-PL/changelogs/61.txt | 4 +++- fastlane/metadata/android/ru-RU/changelogs/61.txt | 4 +++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/fastlane/metadata/android/en-US/changelogs/61.txt b/fastlane/metadata/android/en-US/changelogs/61.txt index bec32697..c905fd4d 100644 --- a/fastlane/metadata/android/en-US/changelogs/61.txt +++ b/fastlane/metadata/android/en-US/changelogs/61.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (1.31.0) +- new recovery feature if the current file is not stored +- fixed crash when editing large files +- updated android sdk \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/changelogs/61.txt b/fastlane/metadata/android/es-ES/changelogs/61.txt index bec32697..03a0f3e9 100644 --- a/fastlane/metadata/android/es-ES/changelogs/61.txt +++ b/fastlane/metadata/android/es-ES/changelogs/61.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (1.31.0) +- nueva función de recuperación si el archivo actual no está guardado +- corregido el cierre inesperado al editar archivos grandes +- SDK de Android actualizado \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/changelogs/60.txt b/fastlane/metadata/android/pl-PL/changelogs/60.txt index a9cba491..68639184 100644 --- a/fastlane/metadata/android/pl-PL/changelogs/60.txt +++ b/fastlane/metadata/android/pl-PL/changelogs/60.txt @@ -1,3 +1,3 @@ -- Separated Chinese Traditional and Chinese Simplified translations -- Updated wordings -- Improved language selection +- Rozdzielono tłumaczenia na chiński tradycyjny i uproszczony +- Zaktualizowano sformułowania +- Usprawniono wybór języka diff --git a/fastlane/metadata/android/pl-PL/changelogs/61.txt b/fastlane/metadata/android/pl-PL/changelogs/61.txt index bec32697..aefbe901 100644 --- a/fastlane/metadata/android/pl-PL/changelogs/61.txt +++ b/fastlane/metadata/android/pl-PL/changelogs/61.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (1.31.0) +- nowa funkcja odzyskiwania danych w przypadku niezapisania bieżącego pliku +- naprawiono błąd powodujący awarię podczas edycji dużych plików +- zaktualizowano pakiet Android SDK diff --git a/fastlane/metadata/android/ru-RU/changelogs/61.txt b/fastlane/metadata/android/ru-RU/changelogs/61.txt index bec32697..6e123466 100644 --- a/fastlane/metadata/android/ru-RU/changelogs/61.txt +++ b/fastlane/metadata/android/ru-RU/changelogs/61.txt @@ -1 +1,3 @@ -TODO: RELEASE NOTES (1.31.0) +- новая функция восстановления для несохраненных файлов +- исправлен сбой при редактировании больших файлов +- обновлен Android SDK From 6366882897f772d0c8e622fef537f641767b7335 Mon Sep 17 00:00:00 2001 From: Max Starikov Date: Sat, 8 Aug 2026 15:00:19 +0200 Subject: [PATCH 8/8] fix release tool --- scripts/release_tool.py | 2 +- scripts/test_release_tool.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/release_tool.py b/scripts/release_tool.py index 74e98e8f..9f57a806 100644 --- a/scripts/release_tool.py +++ b/scripts/release_tool.py @@ -316,7 +316,7 @@ def validate(args: argparse.Namespace) -> None: validate_history(version, config, require_absent_tag=not args.allow_existing_tag) validate_changelogs(version, config) if not args.skip_branch: - validate_branch(version, config, args.mode) + validate_branch(version, config, mode=args.mode) if args.check_play: if not args.service_account: raise ReleaseError("--check-play requires --service-account") diff --git a/scripts/test_release_tool.py b/scripts/test_release_tool.py index c964a8a0..ea7df062 100644 --- a/scripts/test_release_tool.py +++ b/scripts/test_release_tool.py @@ -108,6 +108,25 @@ def test_branch_validation_rejects_incorrect_ancestry(self, branch, process): with self.assertRaises(release_tool.ReleaseError): release_tool.validate_branch(release_tool.Version("1.31.0", 61), config) + @mock.patch.object(release_tool, "validate_branch") + @mock.patch.object(release_tool, "validate_changelogs") + @mock.patch.object(release_tool, "validate_history") + @mock.patch.object(release_tool, "read_version") + @mock.patch.object(release_tool, "load_config") + def test_validate_passes_explicit_mode_as_keyword( + self, config, version, history, notes, branch + ): + config.return_value = {} + version.return_value = release_tool.Version("1.31.0", 61) + args = SimpleNamespace( + allow_existing_tag=False, skip_branch=False, mode="release", + check_play=False, service_account=None, + ) + + release_tool.validate(args) + + branch.assert_called_once_with(version.return_value, config.return_value, mode="release") + @mock.patch.object(release_tool, "run") @mock.patch.object(release_tool, "validate_changelogs") @mock.patch.object(release_tool, "validate_history")