From cb84b59e89f123331204139fc68a49ef0fe6bc14 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:27:42 +0200 Subject: [PATCH] fix(sync): keep typed recovery words out of saved state --- ...1-07-fix-sync2-words-out-of-saved-state.md | 2 + .../sync/linked/LinkedRecoveryWords.kt | 3 +- .../ui/screens/sync/linked/LinkedSetup.kt | 4 +- .../sync/linked/LinkedRecoveryWordsTest.kt | 46 +++++++++++ .../ui/screens/sync/linked/LinkedSetupTest.kt | 77 ++++++++++++++----- 5 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 .github/nightly-notes/2026-11-07-fix-sync2-words-out-of-saved-state.md create mode 100644 app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWordsTest.kt diff --git a/.github/nightly-notes/2026-11-07-fix-sync2-words-out-of-saved-state.md b/.github/nightly-notes/2026-11-07-fix-sync2-words-out-of-saved-state.md new file mode 100644 index 000000000..19742af0d --- /dev/null +++ b/.github/nightly-notes/2026-11-07-fix-sync2-words-out-of-saved-state.md @@ -0,0 +1,2 @@ +## Fixed +- Linked devices: recovery words you type are kept in memory only, so anything typed is cleared if Android closes the app in the background diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWords.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWords.kt index 673b59f6a..e209c2788 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWords.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWords.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -74,7 +75,7 @@ internal fun LinkedRecoveryWords( .sorted(), ) } - var answers by rememberSaveable { mutableStateOf(List(CHECKED_WORDS) { "" }) } + var answers by remember { mutableStateOf(List(CHECKED_WORDS) { "" }) } val matches = positions.zip(answers).all { (i, answer) -> answer.trim().equals(list[i], ignoreCase = true) } val typed = answers.all { it.isNotBlank() } SyncStepHeader(icon = Icons.Outlined.Spellcheck, title = stringResource(R.string.linked_check_title)) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetup.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetup.kt index 10220682c..007f92b13 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetup.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetup.kt @@ -233,7 +233,7 @@ internal fun LinkedSetup( Setup.RESTORE_SERVER -> { var url by rememberSaveable { mutableStateOf("") } - var words by rememberSaveable { mutableStateOf("") } + var words by remember { mutableStateOf("") } SyncStepHeader(icon = Icons.Outlined.Key, title = stringResource(R.string.linked_restore_title)) ServerField(url) { url = it } WordsField(words) { words = it } @@ -247,7 +247,7 @@ internal fun LinkedSetup( } Setup.RESTORE_WEBDAV -> { - var words by rememberSaveable { mutableStateOf("") } + var words by remember { mutableStateOf("") } WebDavForm( busy, stringResource( diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWordsTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWordsTest.kt new file mode 100644 index 000000000..32eeac595 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedRecoveryWordsTest.kt @@ -0,0 +1,46 @@ +package io.github.aedev.flow.ui.screens.sync.linked + +import android.app.Application +import android.content.Context +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.aedev.flow.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Asking for some of the recovery words back. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w360dp-h1600dp") +class LinkedRecoveryWordsTest { + @get:Rule + val rule = createComposeRule() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private fun text(id: Int) = context.getString(id) + + @Test + fun the_words_typed_back_are_not_kept_in_saved_state() { + val restoration = StateRestorationTester(rule) + restoration.setContent { LinkedRecoveryWords(List(24) { "word" }.joinToString(" "), busy = false, onSaved = {}) } + rule.onNodeWithText(text(R.string.linked_words_saved)).performClick() + val fields = rule.onAllNodes(hasSetTextAction()) + fields.fetchSemanticsNodes().indices.forEach { fields[it].performTextInput("word") } + rule.onNodeWithText(text(R.string.linked_confirm)).assertIsEnabled() + + restoration.emulateSavedInstanceStateRestore() + + rule.onNodeWithText("word").assertDoesNotExist() + rule.onNodeWithText(text(R.string.linked_confirm)).assertIsNotEnabled() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetupTest.kt b/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetupTest.kt index 55c1ec0d6..683604d75 100644 --- a/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetupTest.kt +++ b/app/src/test/java/io/github/aedev/flow/ui/screens/sync/linked/LinkedSetupTest.kt @@ -2,12 +2,14 @@ package io.github.aedev.flow.ui.screens.sync.linked import android.app.Application import android.content.Context +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.StateRestorationTester import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -36,24 +38,26 @@ class LinkedSetupTest { private val created = ArrayList>() private val reset = ArrayList>() - private fun show() = - rule.setContent { - var error by remember { mutableStateOf(null) } - LinkedSetup( - hostOnly = false, - busy = false, - error = error, - onCreate = { _, _ -> error = LinkedError.SERVER_HAS_GROUP }, - onResetAndCreate = { url, token -> reset += listOf(url, token) }, - onCreateOnWebDav = { url, user, password -> created += listOf(url, user, password) }, - onCreateOnNetwork = {}, - onEnterCode = { _, _ -> }, - onJoinWithAdmin = { _, _ -> }, - onShowCode = {}, - onRestore = { _, _ -> }, - onRestoreFromWebDav = { _, _, _, _ -> }, - ) - } + private fun show() = rule.setContent { Content() } + + @Composable + private fun Content() { + var error by remember { mutableStateOf(null) } + LinkedSetup( + hostOnly = false, + busy = false, + error = error, + onCreate = { _, _ -> error = LinkedError.SERVER_HAS_GROUP }, + onResetAndCreate = { url, token -> reset += listOf(url, token) }, + onCreateOnWebDav = { url, user, password -> created += listOf(url, user, password) }, + onCreateOnNetwork = {}, + onEnterCode = { _, _ -> }, + onJoinWithAdmin = { _, _ -> }, + onShowCode = {}, + onRestore = { _, _ -> }, + onRestoreFromWebDav = { _, _, _, _ -> }, + ) + } @Test fun a_new_group_can_live_on_a_server_in_a_webdav_folder_or_on_this_network() { @@ -104,4 +108,41 @@ class LinkedSetupTest { rule.onNodeWithText(text(R.string.linked_reset_server_confirm)).performClick() assertEquals(listOf(listOf("https://sync.example.org", "admin")), reset) } + + @Test + fun recovery_words_for_a_server_are_not_kept_in_saved_state_but_the_address_is() { + val restoration = StateRestorationTester(rule) + restoration.setContent { Content() } + rule.onNodeWithText(text(R.string.linked_restore_title)).performClick() + rule.onNodeWithText(text(R.string.linked_where_server)).performClick() + rule.onNodeWithText(text(R.string.linked_server_url)).performTextInput("https://sync.example.org") + rule.onNodeWithText(text(R.string.linked_recovery_words_field)).performTextInput("typed words") + rule.onNodeWithText(text(R.string.linked_restore)).assertIsEnabled() + + restoration.emulateSavedInstanceStateRestore() + + rule.onNodeWithText("https://sync.example.org").assertExists() + rule.onNodeWithText("typed words").assertDoesNotExist() + rule.onNodeWithText(text(R.string.linked_restore)).assertIsNotEnabled() + } + + @Test + fun recovery_words_for_a_webdav_folder_are_not_kept_in_saved_state_but_the_address_is() { + val restoration = StateRestorationTester(rule) + restoration.setContent { Content() } + rule.onNodeWithText(text(R.string.linked_restore_title)).performClick() + rule.onNodeWithText(text(R.string.linked_where_webdav)).performClick() + rule.onNodeWithText(text(R.string.linked_webdav_url)).performTextInput("https://cloud.example.org/dav/flow/") + rule.onNodeWithText(text(R.string.linked_webdav_user)).performTextInput("me") + rule.onNodeWithText(text(R.string.linked_webdav_password)).performTextInput("secret") + rule.onNodeWithText(text(R.string.linked_recovery_words_field)).performTextInput("typed words") + rule.onNodeWithText(text(R.string.linked_restore)).assertIsEnabled() + + restoration.emulateSavedInstanceStateRestore() + + rule.onNodeWithText("https://cloud.example.org/dav/flow/").assertExists() + rule.onNodeWithText("typed words").assertDoesNotExist() + rule.onNodeWithText(text(R.string.linked_webdav_password)).performTextInput("secret") + rule.onNodeWithText(text(R.string.linked_restore)).assertIsNotEnabled() + } }