From d3485cd49e9776b21f0dcea574d9e2c30f768751 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:20:04 +0200 Subject: [PATCH 1/9] Remove final modifier from DataVersionManager --- .../java/app/notesr/service/migration/DataVersionManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/src/main/java/app/notesr/service/migration/DataVersionManager.java b/service/src/main/java/app/notesr/service/migration/DataVersionManager.java index 6bf17922..695643ce 100644 --- a/service/src/main/java/app/notesr/service/migration/DataVersionManager.java +++ b/service/src/main/java/app/notesr/service/migration/DataVersionManager.java @@ -8,7 +8,7 @@ import android.content.Context; import android.content.SharedPreferences; -public final class DataVersionManager { +public class DataVersionManager { public static final int DEFAULT_FIRST_VERSION = 0; private static final String PREF_NAME = "migration_prefs"; private static final String KEY_DATA_VERSION = "data_schema_version"; From f38f0188bcbadac5ca493fbb7e364a33e337d546 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:20:30 +0200 Subject: [PATCH 2/9] Remove final modifier from KeySetupCompletionHandler --- .../app/notesr/activity/security/KeySetupCompletionHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java index 68587544..54f573ff 100644 --- a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java +++ b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java @@ -29,7 +29,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public final class KeySetupCompletionHandler { +public class KeySetupCompletionHandler { private final ActivityBase activity; private final AppSecurityService appSecurityService; private final KeySetupMode mode; From 5e47744abbeffd49407a00277e6ff5a815224de7 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:30:54 +0200 Subject: [PATCH 3/9] Refactor KeySetupCompletionHandler for improved flow and clarity --- .../security/KeySetupCompletionHandler.java | 47 +++++++++---------- .../activity/security/SetupKeyActivity.java | 17 +++++-- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java index 54f573ff..727b33ff 100644 --- a/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java +++ b/app/src/main/java/app/notesr/activity/security/KeySetupCompletionHandler.java @@ -32,18 +32,9 @@ public class KeySetupCompletionHandler { private final ActivityBase activity; private final AppSecurityService appSecurityService; - private final KeySetupMode mode; private final byte[] keyBytes; - public void handle() { - switch (mode) { - case FIRST_RUN -> proceedFirstRun(); - case REGENERATION -> proceedRegeneration(); - default -> throw new RuntimeException("Unknown mode: " + mode); - } - } - - private void proceedFirstRun() { + public void proceedFirstRun(DataVersionManager dataVersionManager) { try { char[] password = getCurrentPassword(); @@ -51,18 +42,7 @@ private void proceedFirstRun() { appSecurityService.setSecrets(newSecrets); Context context = activity.getApplicationContext(); - Intent nextIntent = new Intent(context, NotesListActivity.class); - - var dataVersionManager = new DataVersionManager(context); - - int lastMigrationVersion = dataVersionManager.getCurrentVersion(); - int currentDataSchemaVersion = BuildConfig.DATA_SCHEMA_VERSION; - - if (lastMigrationVersion == DataVersionManager.DEFAULT_FIRST_VERSION) { - dataVersionManager.setCurrentVersion(currentDataSchemaVersion); - } else if (lastMigrationVersion < currentDataSchemaVersion) { - nextIntent = new Intent(context, MigrationActivity.class); - } + Intent nextIntent = getNextIntent(context, dataVersionManager); activity.startActivity(nextIntent); activity.finish(); @@ -71,7 +51,22 @@ private void proceedFirstRun() { } } - private void proceedRegeneration() { + private static Intent getNextIntent(Context context, DataVersionManager dataVersionManager) { + Intent nextIntent = new Intent(context, NotesListActivity.class); + + int lastMigrationVersion = dataVersionManager.getCurrentVersion(); + int currentDataSchemaVersion = BuildConfig.DATA_SCHEMA_VERSION; + + if (lastMigrationVersion == DataVersionManager.DEFAULT_FIRST_VERSION) { + dataVersionManager.setCurrentVersion(currentDataSchemaVersion); + } else if (lastMigrationVersion < currentDataSchemaVersion) { + nextIntent = new Intent(context, MigrationActivity.class); + } + + return nextIntent; + } + + public void proceedRegeneration() { new DialogFactory(activity) .getThemedAlertDialogBuilder(R.layout.dialog_secrets_rotation_warning) .setTitle(R.string.warning) @@ -83,7 +78,7 @@ private void proceedRegeneration() { .show(); } - private void onRegenerationConfirmed() { + protected void onRegenerationConfirmed() { try { char[] password = getCurrentPassword(); byte[] passwordBytes = charsToBytes(password, StandardCharsets.UTF_8); @@ -101,13 +96,13 @@ private void onRegenerationConfirmed() { activity.finish(); } - private void onRegenerationCanceled() { + protected void onRegenerationCanceled() { if (keyBytes != null) { Arrays.fill(keyBytes, (byte) 0); } } - private char[] getCurrentPassword() throws CharacterCodingException { + protected char[] getCurrentPassword() throws CharacterCodingException { if (appSecurityService.isAuthConfigured()) { CryptoSecrets cryptoSecrets = appSecurityService.getActualSecrets(); diff --git a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java index c4be09cd..df3947be 100644 --- a/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java +++ b/app/src/main/java/app/notesr/activity/security/SetupKeyActivity.java @@ -30,6 +30,7 @@ import app.notesr.R; import app.notesr.activity.ActivityBase; import app.notesr.core.security.SecretCache; +import app.notesr.service.migration.DataVersionManager; import app.notesr.service.security.AppSecurityService; import lombok.Getter; @@ -43,6 +44,7 @@ public final class SetupKeyActivity extends ActivityBase { private KeySetupMode mode; private ActivityResultLauncher importKeyLauncher; + private DataVersionManager dataVersionManager; private AppSecurityService appSecurityService; private byte[] newKey; @@ -61,6 +63,7 @@ protected void onCreate(Bundle savedInstanceState) { importKeyLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), getImportKeyCallback()); + dataVersionManager = new DataVersionManager(getApplicationContext()); appSecurityService = getAppSecurityService(); newKey = appSecurityService.generateMasterKey(); @@ -143,7 +146,7 @@ private ActivityResultCallback getImportKeyCallback() { return result -> { if (result.getResultCode() == RESULT_OK) { newKey = SecretCache.take(ImportKeyActivity.CACHE_KEY_HEX_KEY); - getCompletionHandler(newKey).handle(); + handleKeyCompletion(getCompletionHandler(newKey)); } }; } @@ -153,11 +156,19 @@ private AppSecurityService getAppSecurityService() { } private View.OnClickListener nextButtonOnClick() { - return view -> getCompletionHandler(newKey).handle(); + return view -> handleKeyCompletion(getCompletionHandler(newKey)); } private KeySetupCompletionHandler getCompletionHandler(byte[] keyBytes) { - return new KeySetupCompletionHandler(this, appSecurityService, mode, keyBytes); + return new KeySetupCompletionHandler(this, appSecurityService, keyBytes); + } + + private void handleKeyCompletion(KeySetupCompletionHandler handler) { + if (mode == KeySetupMode.FIRST_RUN) { + handler.proceedFirstRun(getDataVersionManager()); + } else { + handler.proceedRegeneration(); + } } private void clearCache() { From aea23a1a2c96d97e5ce385c3aca47b900e252c3b Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:40:01 +0200 Subject: [PATCH 4/9] Update visibility of placeBannerFront to protected in StartActivity --- app/src/main/java/app/notesr/activity/StartActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/notesr/activity/StartActivity.java b/app/src/main/java/app/notesr/activity/StartActivity.java index 1e8b27be..43e314e5 100644 --- a/app/src/main/java/app/notesr/activity/StartActivity.java +++ b/app/src/main/java/app/notesr/activity/StartActivity.java @@ -45,7 +45,7 @@ protected boolean requiresSession() { return false; } - void placeBannerFront() { + protected void placeBannerFront() { ConstraintLayout layout = findViewById(R.id.bannerFrontLayout); DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); From 913938fa61110d465baadff95dba9c66a44f9fab Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:40:16 +0200 Subject: [PATCH 5/9] Update MainActivity to allow subclassing and method overrides --- app/src/main/java/app/notesr/activity/MainActivity.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/notesr/activity/MainActivity.java b/app/src/main/java/app/notesr/activity/MainActivity.java index 42f3b9c6..27bd2da8 100644 --- a/app/src/main/java/app/notesr/activity/MainActivity.java +++ b/app/src/main/java/app/notesr/activity/MainActivity.java @@ -22,7 +22,7 @@ import app.notesr.activity.security.KeyRecoveryActivity; import app.notesr.service.security.AppSecurityService; -public final class MainActivity extends ActivityBase { +public class MainActivity extends ActivityBase { @Override protected void onCreate(Bundle savedInstanceState) { @@ -55,7 +55,7 @@ protected boolean requiresSession() { return false; } - List> getIntentSuppliers( + protected List> getIntentSuppliers( Context context, AppSecurityService appSecurityService, FsaResolver fsaResolver @@ -88,7 +88,7 @@ List> getIntentSuppliers( ); } - void startAppCloseService(Context context, AndroidServiceRegistry serviceRegistry) { + protected void startAppCloseService(Context context, AndroidServiceRegistry serviceRegistry) { if (!serviceRegistry.isServiceRunning(AppCloseAndroidService.class)) { new AppCloseAndroidServiceStarter().start(context); } From 7e9154bcaf5454d639c0095f874f277c9cd3ce5f Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:40:41 +0200 Subject: [PATCH 6/9] Remove shell permission adoption in MainActivityTest --- .../java/app/notesr/activity/MainActivityTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/androidTest/java/app/notesr/activity/MainActivityTest.java b/app/src/androidTest/java/app/notesr/activity/MainActivityTest.java index 3d7465bc..72c95df7 100644 --- a/app/src/androidTest/java/app/notesr/activity/MainActivityTest.java +++ b/app/src/androidTest/java/app/notesr/activity/MainActivityTest.java @@ -44,10 +44,7 @@ public class MainActivityTest { @Before public void setUp() { - var instrumentationRegistry = InstrumentationRegistry.getInstrumentation(); - instrumentationRegistry.getUiAutomation().adoptShellPermissionIdentity(); - - context = instrumentationRegistry.getTargetContext(); + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); mockServiceRegistry = mock(AndroidServiceRegistry.class); mockAppSecurityService = mock(AppSecurityService.class); mockFsaResolver = mock(FsaResolver.class); From 530cd6dc512500da33bbee6b67b2df341546a9d6 Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:40:53 +0200 Subject: [PATCH 7/9] Remove shell permission adoption in ActivityBaseTest --- .../java/app/notesr/activity/ActivityBaseTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/androidTest/java/app/notesr/activity/ActivityBaseTest.java b/app/src/androidTest/java/app/notesr/activity/ActivityBaseTest.java index 2edbe6b4..2f867fcf 100644 --- a/app/src/androidTest/java/app/notesr/activity/ActivityBaseTest.java +++ b/app/src/androidTest/java/app/notesr/activity/ActivityBaseTest.java @@ -40,10 +40,7 @@ public class ActivityBaseTest { @Before public void setUp() { - var instrumentationRegistry = InstrumentationRegistry.getInstrumentation(); - instrumentationRegistry.getUiAutomation().adoptShellPermissionIdentity(); - - context = instrumentationRegistry.getTargetContext(); + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); mockAppSecurityService = mock(AppSecurityService.class); } From d58dd2af63f192348893394b826606a3b289ba8e Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:41:17 +0200 Subject: [PATCH 8/9] Add KeySetupCompletionHandlerTest --- .../KeySetupCompletionHandlerTest.java | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 app/src/androidTest/java/app/notesr/activity/security/KeySetupCompletionHandlerTest.java diff --git a/app/src/androidTest/java/app/notesr/activity/security/KeySetupCompletionHandlerTest.java b/app/src/androidTest/java/app/notesr/activity/security/KeySetupCompletionHandlerTest.java new file mode 100644 index 00000000..34769f19 --- /dev/null +++ b/app/src/androidTest/java/app/notesr/activity/security/KeySetupCompletionHandlerTest.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 zHd4 + * SPDX-License-Identifier: MIT + */ + +package app.notesr.activity.security; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.Intent; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; + +import app.notesr.BuildConfig; +import app.notesr.activity.ActivityBase; +import app.notesr.activity.migration.MigrationActivity; +import app.notesr.activity.note.list.NotesListActivity; +import app.notesr.core.security.SecretCache; +import app.notesr.core.security.dto.CryptoSecrets; +import app.notesr.service.migration.DataVersionManager; +import app.notesr.service.security.AppSecurityService; +import app.notesr.service.security.rotation.SecretsUpdateAndroidService; +import io.bloco.faker.Faker; + +@RunWith(AndroidJUnit4.class) +public class KeySetupCompletionHandlerTest { + + private static final int KEY_LENGTH = 48; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Faker FAKER = new Faker(); + + private Context context; + private ActivityBase activity; + private AppSecurityService appSecurityService; + + @Before + public void setUp() { + var instrumentationRegistry = InstrumentationRegistry.getInstrumentation(); + + context = instrumentationRegistry.getTargetContext(); + activity = mock(ActivityBase.class); + appSecurityService = mock(AppSecurityService.class); + + clearCache(); + } + + @After + public void tearDown() { + clearCache(); + } + + @Test + public void proceedFirstRunSetsSecretsAndStartsNotesListWhenFirstVersion() { + when(activity.getApplicationContext()).thenReturn(context); + when(appSecurityService.isAuthConfigured()).thenReturn(false); + + byte[] passwordBytes = FAKER.internet.password().getBytes(StandardCharsets.UTF_8); + byte[] passwordBytesCopy = passwordBytes.clone(); + SecretCache.put(SetupKeyActivity.CACHE_KEY_PASSWORD, passwordBytesCopy); + + DataVersionManager dataVersionManager = mock(DataVersionManager.class); + when(dataVersionManager.getCurrentVersion()) + .thenReturn(DataVersionManager.DEFAULT_FIRST_VERSION); + + byte[] keyBytes = getTestKey(); + + KeySetupCompletionHandler handler = new KeySetupCompletionHandler(activity, + appSecurityService, keyBytes); + handler.proceedFirstRun(dataVersionManager); + + ArgumentCaptor secretsCaptor = ArgumentCaptor.forClass(CryptoSecrets.class); + verify(appSecurityService).setSecrets(secretsCaptor.capture()); + CryptoSecrets passedSecrets = secretsCaptor.getValue(); + + assertArrayEquals("Key bytes should be passed to CryptoSecrets", + keyBytes, passedSecrets.getKey()); + assertArrayEquals("Password chars should be passed to CryptoSecrets", + new String(passwordBytes, StandardCharsets.UTF_8).toCharArray(), + passedSecrets.getPassword()); + assertArrayEquals("Password bytes should be zeroed after use", + new byte[passwordBytes.length], passwordBytesCopy); + + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + Intent startedIntent = intentCaptor.getValue(); + + assertNotNull("Intent should be created", startedIntent); + assertNotNull("Intent component should be set", startedIntent.getComponent()); + assertEquals("Intent should target NotesListActivity", + NotesListActivity.class.getName(), startedIntent.getComponent().getClassName()); + + verify(activity).finish(); + verify(dataVersionManager).setCurrentVersion(BuildConfig.DATA_SCHEMA_VERSION); + } + + @Test + public void proceedFirstRunStartsMigrationWhenSchemaOutdated() { + when(activity.getApplicationContext()).thenReturn(context); + when(appSecurityService.isAuthConfigured()).thenReturn(false); + + byte[] passwordBytes = FAKER.internet.password().getBytes(StandardCharsets.UTF_8); + SecretCache.put(SetupKeyActivity.CACHE_KEY_PASSWORD, passwordBytes); + + DataVersionManager dataVersionManager = mock(DataVersionManager.class); + when(dataVersionManager.getCurrentVersion()) + .thenReturn(BuildConfig.DATA_SCHEMA_VERSION - 1); + + byte[] keyBytes = getTestKey(); + + KeySetupCompletionHandler handler = + new KeySetupCompletionHandler(activity, appSecurityService, keyBytes); + handler.proceedFirstRun(dataVersionManager); + + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + Intent startedIntent = intentCaptor.getValue(); + + assertNotNull(startedIntent); + assertNotNull(startedIntent.getComponent()); + assertEquals("Intent should target MigrationActivity", + MigrationActivity.class.getName(), startedIntent.getComponent().getClassName()); + + verify(activity).finish(); + } + + @Test + public void onRegenerationConfirmedPutsSecretsInCacheAndStartsSecretsUpdate() { + when(activity.getApplicationContext()).thenReturn(context); + when(appSecurityService.isAuthConfigured()).thenReturn(true); + + byte[] actualKey = getTestKey(); + char[] actualPassword = FAKER.internet.password().toCharArray(); + + CryptoSecrets actual = new CryptoSecrets(actualKey, actualPassword.clone()); + when(appSecurityService.getActualSecrets()).thenReturn(actual); + + byte[] newKey = getTestKey(); + byte[] newKeyCopy = newKey.clone(); + + KeySetupCompletionHandler handler = new KeySetupCompletionHandler(activity, + appSecurityService, newKeyCopy); + handler.onRegenerationConfirmed(); + + byte[] storedNewKey = SecretCache.take(SecretsUpdateAndroidService.NEW_KEY); + assertArrayEquals("New key bytes should be stored", newKey, storedNewKey); + assertArrayEquals("Original new key bytes should be zeroed", new byte[newKey.length], newKeyCopy); + + byte[] storedPassword = SecretCache.take(SecretsUpdateAndroidService.PASSWORD); + assertArrayEquals("Password bytes should be stored", + new String(actualPassword).getBytes(StandardCharsets.UTF_8), storedPassword); + + ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); + verify(activity).startActivity(intentCaptor.capture()); + Intent startedIntent = intentCaptor.getValue(); + + assertNotNull(startedIntent); + assertNotNull(startedIntent.getComponent()); + assertEquals("Intent should target SecretsUpdateActivity", + SecretsUpdateActivity.class.getName(), startedIntent.getComponent().getClassName()); + + verify(activity).finish(); + } + + @Test + public void onRegenerationCanceledZeroesKeyBytes() { + byte[] keyBytes = getTestKey(); + + KeySetupCompletionHandler handler = new KeySetupCompletionHandler(activity, + appSecurityService, keyBytes); + handler.onRegenerationCanceled(); + + assertArrayEquals("Key bytes should be zeroed after cancellation", + new byte[keyBytes.length], keyBytes); + } + + @Test + public void getCurrentPasswordThrowsWhenNoPasswordAvailable() { + when(appSecurityService.isAuthConfigured()).thenReturn(false); + + KeySetupCompletionHandler handler = new KeySetupCompletionHandler(activity, + appSecurityService, new byte[]{1}); + assertThrows(IllegalStateException.class, handler::getCurrentPassword); + } + + private byte[] getTestKey() { + byte[] keyBytes = new byte[KEY_LENGTH]; + SECURE_RANDOM.nextBytes(keyBytes); + return keyBytes; + } + + private void clearCache() { + SecretCache.removeIfExists(SetupKeyActivity.CACHE_KEY_PASSWORD); + SecretCache.removeIfExists(SecretsUpdateAndroidService.NEW_KEY); + SecretCache.removeIfExists(SecretsUpdateAndroidService.PASSWORD); + } +} From 92208377e503ae649d527801ac69739f6cbe7e3d Mon Sep 17 00:00:00 2001 From: zHd4 <38856321+zHd4@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:19:20 +0200 Subject: [PATCH 9/9] Remove shell permission adoption in LockActionTest --- .../java/app/notesr/activity/security/LockActionTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/androidTest/java/app/notesr/activity/security/LockActionTest.java b/app/src/androidTest/java/app/notesr/activity/security/LockActionTest.java index 85f20002..2f8efe3e 100644 --- a/app/src/androidTest/java/app/notesr/activity/security/LockActionTest.java +++ b/app/src/androidTest/java/app/notesr/activity/security/LockActionTest.java @@ -37,10 +37,7 @@ public class LockActionTest { @Before public void setUp() { - var instrumentationRegistry = InstrumentationRegistry.getInstrumentation(); - instrumentationRegistry.getUiAutomation().adoptShellPermissionIdentity(); - - context = instrumentationRegistry.getTargetContext(); + context = InstrumentationRegistry.getInstrumentation().getTargetContext(); activity = mock(ActivityBase.class); appSecurityService = mock(AppSecurityService.class); }