From 63dab3f4b0c84cc9bf25efe7820264307e8b767e Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 22 Feb 2022 12:27:12 -0400 Subject: [PATCH] Add support for specific toasts when backup restoration cannot proceed. Fixes #10918 --- .../fragments/RestoreBackupFragment.java | 30 ++++++- .../securesms/util/BackupUtil.java | 79 +++++++++++++---- app/src/main/res/values/strings.xml | 6 ++ .../securesms/util/BackupUtilTest.kt | 84 +++++++++++++++++++ 4 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/BackupUtilTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java index e65488fff..928dbd557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java @@ -26,6 +26,7 @@ import androidx.annotation.MainThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.Navigation; @@ -37,6 +38,7 @@ import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.AppInitialization; import org.thoughtcrime.securesms.LoggingFragment; @@ -216,10 +218,36 @@ public final class RestoreBackupFragment extends LoggingFragment { @NonNull Uri backupUri, @NonNull OnBackupSearchResultListener listener) { - SimpleTask.run(() -> BackupUtil.getBackupInfoFromSingleUri(context, backupUri), + SimpleTask.run(() -> { + try { + return BackupUtil.getBackupInfoFromSingleUri(context, backupUri); + } catch (BackupUtil.BackupFileException e) { + Log.w(TAG, "Could not restore backup.", e); + postToastForBackupRestorationFailure(context, e); + return null; + } + }, listener::run); } + private static void postToastForBackupRestorationFailure(@NonNull Context context, @NonNull BackupUtil.BackupFileException exception) { + final @StringRes int errorResId; + switch (exception.getState()) { + case READABLE: + throw new AssertionError("Unexpected error state."); + case NOT_FOUND: + errorResId = R.string.RestoreBackupFragment__backup_not_found; + break; + case UNSUPPORTED_FILE_EXTENSION: + errorResId = R.string.RestoreBackupFragment__backup_has_a_bad_extension; + break; + default: + errorResId = R.string.RestoreBackupFragment__backup_could_not_be_read; + } + + ThreadUtil.postToMain(() -> Toast.makeText(context, errorResId, Toast.LENGTH_LONG).show()); + } + private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) { View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null); EditText prompt = view.findViewById(R.id.restore_passphrase_input); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index 147d009f4..9b78f6f95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -6,11 +6,11 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; -import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; import androidx.documentfile.provider.DocumentFile; import org.signal.core.util.logging.Log; @@ -166,14 +166,22 @@ public class BackupUtil { return backups; } - public static @Nullable BackupInfo getBackupInfoFromSingleUri(@NonNull Context context, @NonNull Uri singleUri) { - DocumentFile documentFile = DocumentFile.fromSingleUri(context, singleUri); + public static @Nullable BackupInfo getBackupInfoFromSingleUri(@NonNull Context context, @NonNull Uri singleUri) throws BackupFileException { + DocumentFile documentFile = Objects.requireNonNull(DocumentFile.fromSingleUri(context, singleUri)); - if (isBackupFileReadable(documentFile)) { + return getBackupInfoFromSingleDocumentFile(documentFile); + } + + @VisibleForTesting + static @Nullable BackupInfo getBackupInfoFromSingleDocumentFile(@NonNull DocumentFile documentFile) throws BackupFileException { + BackupFileState backupFileState = getBackupFileState(documentFile); + + if (backupFileState.isSuccess()) { long backupTimestamp = getBackupTimestamp(Objects.requireNonNull(documentFile.getName())); return new BackupInfo(backupTimestamp, documentFile.length(), documentFile.getUri()); } else { Log.w(TAG, "Could not load backup info."); + backupFileState.throwIfError(); return null; } } @@ -258,21 +266,58 @@ public class BackupUtil { return -1; } - private static boolean isBackupFileReadable(@Nullable DocumentFile documentFile) { - if (documentFile == null) { - throw new AssertionError("We do not support platforms prior to KitKat."); - } else if (!documentFile.exists()) { - Log.w(TAG, "isBackupFileReadable: The document at the specified Uri cannot be found."); - return false; + private static BackupFileState getBackupFileState(@NonNull DocumentFile documentFile) { + if (!documentFile.exists()) { + return BackupFileState.NOT_FOUND; } else if (!documentFile.canRead()) { - Log.w(TAG, "isBackupFileReadable: The document at the specified Uri cannot be read."); - return false; - } else if (TextUtils.isEmpty(documentFile.getName()) || !documentFile.getName().endsWith(".backup")) { - Log.w(TAG, "isBackupFileReadable: The document at the specified Uri has an unsupported file extension."); - return false; + return BackupFileState.NOT_READABLE; + } else if (Util.isEmpty(documentFile.getName()) || !documentFile.getName().endsWith(".backup")) { + return BackupFileState.UNSUPPORTED_FILE_EXTENSION; } else { - Log.i(TAG, "isBackupFileReadable: The document at the specified Uri looks like a readable backup"); - return true; + return BackupFileState.READABLE; + } + } + + /** + * Describes the validity of a backup file. + */ + public enum BackupFileState { + READABLE("The document at the specified Uri looks like a readable backup."), + NOT_FOUND("The document at the specified Uri cannot be found."), + NOT_READABLE("The document at the specified Uri cannot be read."), + UNSUPPORTED_FILE_EXTENSION("The document at the specified Uri has an unsupported file extension."); + + private final String message; + + BackupFileState(String message) { + this.message = message; + } + + public boolean isSuccess() { + return this == READABLE; + } + + public void throwIfError() throws BackupFileException { + if (!isSuccess()) { + throw new BackupFileException(this); + } + } + } + + /** + * Wrapping exception for a non-successful BackupFileState. + */ + public static class BackupFileException extends Exception { + + private final BackupFileState state; + + BackupFileException(BackupFileState backupFileState) { + super(backupFileState.message); + this.state = backupFileState; + } + + public @NonNull BackupFileState getState() { + return state; } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a6f6408d..6dc3905f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -471,6 +471,12 @@ To continue using backups, please choose a folder. New backups will be saved to this location. Choose folder Not now + + Backup not found. + + Backup could not be read. + + Backup has a bad extension. Chat backups diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/BackupUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/BackupUtilTest.kt new file mode 100644 index 000000000..2a344afc3 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/BackupUtilTest.kt @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.util + +import androidx.documentfile.provider.DocumentFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.BeforeClass +import org.junit.Test +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.testutil.EmptyLogger + +class BackupUtilTest { + + companion object { + private const val TEST_NAME = "1920837192.backup" + + @BeforeClass + @JvmStatic + fun setUpClass() { + Log.initialize(EmptyLogger()) + } + } + + private val documentFile = mock(DocumentFile::class.java) + + @Test + fun `Given a non-existent uri, when I getBackupInfoFromSingleDocumentFile, then I expect NOT_FOUND`() { + try { + BackupUtil.getBackupInfoFromSingleDocumentFile(documentFile) + fail("Expected a BackupFileException") + } catch (e: BackupUtil.BackupFileException) { + assertEquals(BackupUtil.BackupFileState.NOT_FOUND, e.state) + } + } + + @Test + fun `Given an existent but unreadable uri, when I getBackupInfoFromSingleDocumentFile, then I expect NOT_READABLE`() { + givenFileExists() + + try { + BackupUtil.getBackupInfoFromSingleDocumentFile(documentFile) + fail("Expected a BackupFileException") + } catch (e: BackupUtil.BackupFileException) { + assertEquals(BackupUtil.BackupFileState.NOT_READABLE, e.state) + } + } + + @Test + fun `Given an existent readable uri with a bad extension, when I getBackupInfoFromSingleDocumentFile, then I expect UNSUPPORTED_FILE_EXTENSION`() { + givenFileExists() + givenFileIsReadable() + + try { + BackupUtil.getBackupInfoFromSingleDocumentFile(documentFile) + fail("Expected a BackupFileException") + } catch (e: BackupUtil.BackupFileException) { + assertEquals(BackupUtil.BackupFileState.UNSUPPORTED_FILE_EXTENSION, e.state) + } + } + + @Test + fun `Given an existent readable uri, when I getBackupInfoFromSingleDocumentFile, then I expect an info`() { + givenFileExists() + givenFileIsReadable() + givenFileHasCorrectExtension() + + val info = BackupUtil.getBackupInfoFromSingleDocumentFile(documentFile) + assertNotNull(info) + } + + private fun givenFileExists() { + doReturn(true).`when`(documentFile).exists() + } + + private fun givenFileIsReadable() { + doReturn(true).`when`(documentFile).canRead() + } + + private fun givenFileHasCorrectExtension() { + doReturn(TEST_NAME).`when`(documentFile).name + } +}