Add an import button to the intro

This commit is contained in:
Alexander Bakker 2022-06-04 18:34:52 +02:00
parent dcda668671
commit 79022be3b6
13 changed files with 277 additions and 24 deletions

View file

@ -74,7 +74,7 @@ public abstract class AegisTest {
private VaultRepository initVault(@Nullable VaultFileCredentials creds, @Nullable List<VaultEntry> entries) {
VaultRepository vault;
try {
vault = _vaultManager.init(creds);
vault = _vaultManager.initNew(creds);
} catch (VaultRepositoryException e) {
throw new RuntimeException(e);
}

View file

@ -6,16 +6,25 @@ import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.intent.Intents.intending;
import static androidx.test.espresso.intent.matcher.IntentMatchers.isInternal;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import static junit.framework.TestCase.assertFalse;
import static junit.framework.TestCase.assertNull;
import static junit.framework.TestCase.assertTrue;
import static org.hamcrest.Matchers.not;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.net.Uri;
import androidx.test.espresso.IdlingRegistry;
import androidx.test.espresso.IdlingResource;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.intent.Intents;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
@ -23,6 +32,7 @@ import androidx.viewpager2.widget.ViewPager2;
import com.beemdevelopment.aegis.rules.ScreenshotTestRule;
import com.beemdevelopment.aegis.ui.IntroActivity;
import com.beemdevelopment.aegis.util.IOUtils;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.slots.BiometricSlot;
import com.beemdevelopment.aegis.vault.slots.PasswordSlot;
@ -36,6 +46,11 @@ import org.junit.rules.RuleChain;
import org.junit.rules.TestRule;
import org.junit.runner.RunWith;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import dagger.hilt.android.testing.HiltAndroidTest;
@RunWith(AndroidJUnit4.class)
@ -51,6 +66,8 @@ public class IntroTest extends AegisTest {
@Before
public void setUp() {
Intents.init();
_activityRule.getScenario().onActivity(activity -> {
_viewPager2IdlingResource = new ViewPager2IdlingResource(activity.findViewById(R.id.pager), "viewPagerIdlingResource");
IdlingRegistry.getInstance().register(_viewPager2IdlingResource);
@ -59,6 +76,7 @@ public class IntroTest extends AegisTest {
@After
public void tearDown() {
Intents.release();
IdlingRegistry.getInstance().unregister(_viewPager2IdlingResource);
}
@ -113,6 +131,58 @@ public class IntroTest extends AegisTest {
assertFalse(slots.has(BiometricSlot.class));
}
@Test
public void doIntro_Import_Plain() {
Uri uri = getResourceUri("aegis_plain.json");
Intent resultData = new Intent();
resultData.setData(uri);
Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
intending(not(isInternal())).respondWith(result);
ViewInteraction next = onView(withId(R.id.btnNext));
onView(withId(R.id.btnImport)).perform(click());
next.perform(click());
VaultRepository vault = _vaultManager.getVault();
assertFalse(vault.isEncryptionEnabled());
assertNull(vault.getCredentials());
}
@Test
public void doIntro_Import_Encrypted() {
Uri uri = getResourceUri("aegis_encrypted.json");
Intent resultData = new Intent();
resultData.setData(uri);
Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData);
intending(not(isInternal())).respondWith(result);
ViewInteraction next = onView(withId(R.id.btnNext));
onView(withId(R.id.btnImport)).perform(click());
onView(withId(R.id.text_input)).perform(typeText(VAULT_PASSWORD), closeSoftKeyboard());
onView(withId(android.R.id.button1)).perform(click());
next.perform(click());
VaultRepository vault = _vaultManager.getVault();
SlotList slots = vault.getCredentials().getSlots();
assertTrue(vault.isEncryptionEnabled());
assertTrue(slots.has(PasswordSlot.class));
assertFalse(slots.has(BiometricSlot.class));
}
private Uri getResourceUri(String resourceName) {
File targetFile = new File(getInstrumentation().getTargetContext().getExternalCacheDir(), resourceName);
try (InputStream inStream = getClass().getResourceAsStream(resourceName);
FileOutputStream outStream = new FileOutputStream(targetFile)) {
IOUtils.copy(inStream, outStream);
} catch (IOException e) {
throw new RuntimeException(e);
}
return Uri.fromFile(targetFile);
}
// Source: https://stackoverflow.com/a/32763454/12972657
private static class ViewPager2IdlingResource implements IdlingResource {
private final String _resName;

View file

@ -0,0 +1 @@
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_encrypted.json

View file

@ -0,0 +1 @@
../../../../../test/resources/com/beemdevelopment/aegis/importers/aegis_plain.json

View file

@ -3,6 +3,7 @@ package com.beemdevelopment.aegis.importers;
import android.content.Context;
import android.content.DialogInterface;
import androidx.annotation.Nullable;
import androidx.lifecycle.Lifecycle;
import com.beemdevelopment.aegis.R;
@ -72,7 +73,7 @@ public class AegisImporter extends DatabaseImporter {
throw new DatabaseImporterException(e);
}
return new DecryptedState(obj);
return new DecryptedState(obj, creds);
}
public State decrypt(char[] password) throws DatabaseImporterException {
@ -109,10 +110,21 @@ public class AegisImporter extends DatabaseImporter {
public static class DecryptedState extends State {
private JSONObject _obj;
private VaultFileCredentials _creds;
private DecryptedState(JSONObject obj) {
this(obj, null);
}
private DecryptedState(JSONObject obj, VaultFileCredentials creds) {
super(false);
_obj = obj;
_creds = creds;
}
@Nullable
public VaultFileCredentials getCredentials() {
return _creds;
}
@Override

View file

@ -6,6 +6,7 @@ import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE
import static com.beemdevelopment.aegis.ui.slides.SecurityPickerSlide.CRYPT_TYPE_PASS;
import android.os.Bundle;
import android.view.WindowManager;
import android.view.inputmethod.InputMethodManager;
import com.beemdevelopment.aegis.R;
@ -51,6 +52,18 @@ public class IntroActivity extends IntroBaseActivity {
return true;
}
if (oldSlide == WelcomeSlide.class
&& newSlide == SecurityPickerSlide.class
&& getState().getBoolean("imported")) {
skipToSlide(DoneSlide.class);
return true;
}
// on the welcome page, we don't want the keyboard to push any views up
getWindow().setSoftInputMode(newSlide == WelcomeSlide.class
? WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
return false;
}
@ -58,21 +71,31 @@ public class IntroActivity extends IntroBaseActivity {
protected void onDonePressed() {
Bundle state = getState();
int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID);
VaultFileCredentials creds = (VaultFileCredentials) state.getSerializable("creds");
if (cryptType == CRYPT_TYPE_INVALID
|| (cryptType == CRYPT_TYPE_NONE && creds != null)
|| (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class)))
|| (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) {
throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds));
}
if (!state.getBoolean("imported")) {
int cryptType = state.getInt("cryptType", CRYPT_TYPE_INVALID);
if (cryptType == CRYPT_TYPE_INVALID
|| (cryptType == CRYPT_TYPE_NONE && creds != null)
|| (cryptType == CRYPT_TYPE_PASS && (creds == null || !creds.getSlots().has(PasswordSlot.class)))
|| (cryptType == CRYPT_TYPE_BIOMETRIC && (creds == null || !creds.getSlots().has(PasswordSlot.class) || !creds.getSlots().has(BiometricSlot.class)))) {
throw new RuntimeException(String.format("State of SecuritySetupSlide not properly propagated, cryptType: %d, creds: %s", cryptType, creds));
}
try {
_vaultManager.init(creds);
} catch (VaultRepositoryException e) {
e.printStackTrace();
Dialogs.showErrorDialog(this, R.string.vault_init_error, e);
return;
try {
_vaultManager.initNew(creds);
} catch (VaultRepositoryException e) {
e.printStackTrace();
Dialogs.showErrorDialog(this, R.string.vault_init_error, e);
return;
}
} else {
try {
_vaultManager.load(creds);
} catch (VaultRepositoryException e) {
e.printStackTrace();
Dialogs.showErrorDialog(this, R.string.vault_load_error, e);
return;
}
}
// skip the intro from now on

View file

@ -1,11 +1,11 @@
package com.beemdevelopment.aegis.ui.intro;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.RecyclerView;
@ -13,7 +13,6 @@ import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.Theme;
import com.beemdevelopment.aegis.ui.AegisActivity;
import java.lang.ref.WeakReference;
@ -116,7 +115,7 @@ public abstract class IntroBaseActivity extends AegisActivity implements IntroAc
* @param newSlide the next slide that will be shown.
* @return whether to block the transition.
*/
protected boolean onBeforeSlideChanged(Class<? extends SlideFragment> oldSlide, Class<? extends SlideFragment> newSlide) {
protected boolean onBeforeSlideChanged(@Nullable Class<? extends SlideFragment> oldSlide, @NonNull Class<? extends SlideFragment> newSlide) {
return false;
}
@ -125,7 +124,7 @@ public abstract class IntroBaseActivity extends AegisActivity implements IntroAc
* @param oldSlide the slide that was previously shown.
* @param newSlide the slide that is now shown.
*/
protected void onAfterSlideChanged(Class<? extends SlideFragment> oldSlide, Class<? extends SlideFragment> newSlide) {
protected void onAfterSlideChanged(@Nullable Class<? extends SlideFragment> oldSlide, @NonNull Class<? extends SlideFragment> newSlide) {
}
@ -178,6 +177,13 @@ public abstract class IntroBaseActivity extends AegisActivity implements IntroAc
_slides.add(type);
_slideIndicator.setSlideCount(_slides.size());
// send 'slide changed' events for the first slide
if (_slides.size() == 1) {
Class<? extends SlideFragment> slide = _slides.get(0);
onBeforeSlideChanged(null, slide);
onAfterSlideChanged(null, slide);
}
}
private class ScreenSlidePagerAdapter extends FragmentStateAdapter {

View file

@ -1,16 +1,109 @@
package com.beemdevelopment.aegis.ui.slides;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.importers.AegisImporter;
import com.beemdevelopment.aegis.importers.DatabaseImporter;
import com.beemdevelopment.aegis.importers.DatabaseImporterException;
import com.beemdevelopment.aegis.ui.dialogs.Dialogs;
import com.beemdevelopment.aegis.ui.intro.SlideFragment;
import com.beemdevelopment.aegis.ui.tasks.ImportFileTask;
import com.beemdevelopment.aegis.vault.VaultFileCredentials;
import com.beemdevelopment.aegis.vault.VaultRepository;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class WelcomeSlide extends SlideFragment {
public static final int CODE_IMPORT_VAULT = 0;
private boolean _imported;
private VaultFileCredentials _creds;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_welcome_slide, container, false);
View view = inflater.inflate(R.layout.fragment_welcome_slide, container, false);
view.findViewById(R.id.btnImport).setOnClickListener(v -> {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
startActivityForResult(intent, CODE_IMPORT_VAULT);
});
return view;
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == CODE_IMPORT_VAULT && data != null && data.getData() != null) {
startImportVault(data.getData());
}
}
@Override
public void onSaveIntroState(@NonNull Bundle introState) {
introState.putBoolean("imported", _imported);
introState.putSerializable("creds", _creds);
}
private void startImportVault(Uri uri) {
ImportFileTask.Params params = new ImportFileTask.Params(uri, "intro-import", null);
ImportFileTask task = new ImportFileTask(requireContext(), result -> {
if (result.getException() != null) {
Dialogs.showErrorDialog(requireContext(), R.string.reading_file_error, result.getException());
return;
}
try (FileInputStream inStream = new FileInputStream(result.getFile())) {
AegisImporter importer = new AegisImporter(requireContext());
DatabaseImporter.State state = importer.read(inStream, false);
if (state.isEncrypted()) {
state.decrypt(requireContext(), new DatabaseImporter.DecryptListener() {
@Override
protected void onStateDecrypted(DatabaseImporter.State state) {
_creds = ((AegisImporter.DecryptedState) state).getCredentials();
importVault(result.getFile());
}
@Override
protected void onError(Exception e) {
e.printStackTrace();
Dialogs.showErrorDialog(requireContext(), R.string.decryption_error, e);
}
@Override
protected void onCanceled() {
}
});
} else {
importVault(result.getFile());
}
} catch (DatabaseImporterException | IOException e) {
e.printStackTrace();
Dialogs.showErrorDialog(requireContext(), R.string.intro_import_error_title, e);
}
});
task.execute(getLifecycle(), params);
}
private void importVault(File file) {
try (FileInputStream inStream = new FileInputStream(file)) {
VaultRepository.writeToFile(requireContext(), inStream);
} catch (IOException e) {
e.printStackTrace();
Dialogs.showErrorDialog(requireContext(), R.string.intro_import_error_title, e);
return;
}
_imported = true;
goToNextSlide();
}
}

View file

@ -60,7 +60,7 @@ public class VaultManager {
if (_vaultFile != null && !_vaultFile.isEncrypted()) {
try {
load(_vaultFile, null);
loadFrom(_vaultFile, null);
} catch (VaultRepositoryException e) {
e.printStackTrace();
_vaultFile = null;
@ -76,7 +76,7 @@ public class VaultManager {
* Calling this method removes the manager's internal reference to the raw vault file (if it had one).
*/
@NonNull
public VaultRepository init(@Nullable VaultFileCredentials creds) throws VaultRepositoryException {
public VaultRepository initNew(@Nullable VaultFileCredentials creds) throws VaultRepositoryException {
if (isVaultLoaded()) {
throw new IllegalStateException("Vault manager is already initialized");
}
@ -100,7 +100,7 @@ public class VaultManager {
* Calling this method removes the manager's internal reference to the raw vault file (if it had one).
*/
@NonNull
public VaultRepository load(@NonNull VaultFile vaultFile, @Nullable VaultFileCredentials creds) throws VaultRepositoryException {
public VaultRepository loadFrom(@NonNull VaultFile vaultFile, @Nullable VaultFileCredentials creds) throws VaultRepositoryException {
if (isVaultLoaded()) {
throw new IllegalStateException("Vault manager is already initialized");
}
@ -116,9 +116,30 @@ public class VaultManager {
return getVault();
}
/**
* Initializes the vault repository by loading and decrypting the vault file stored in
* internal storage, with the given creds. It can only be called if isVaultLoaded()
* returns false.
*
* Calling this method removes the manager's internal reference to the raw vault file (if it had one).
*/
@NonNull
public VaultRepository load(@Nullable VaultFileCredentials creds) throws VaultRepositoryException {
if (isVaultLoaded()) {
throw new IllegalStateException("Vault manager is already initialized");
}
loadVaultFile();
if (isVaultLoaded()) {
return _repo;
}
return loadFrom(getVaultFile(), creds);
}
@NonNull
public VaultRepository unlock(@NonNull VaultFileCredentials creds) throws VaultRepositoryException {
VaultRepository repo = load(getVaultFile(), creds);
VaultRepository repo = loadFrom(getVaultFile(), creds);
startNotificationService();
return repo;
}

View file

@ -28,13 +28,27 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleText"/>
<Button
android:id="@+id/btnImport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
android:text="@string/import_vault"
android:textSize="11sp"
style="?attr/introButtonStyle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/descriptionText" />
<TextView
android:id="@+id/descriptionText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_description"
android:textAlignment="center"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -11,6 +11,7 @@
<attr name="iconColorInverted" format="color" />
<attr name="dropdownStyle" format="reference" />
<attr name="colorAppBar" format="color" />
<attr name="introButtonStyle" format="reference" />
<declare-styleable name="SlideIndicator">
<attr name="dot_radius" format="dimension" />

View file

@ -224,6 +224,7 @@
<item quantity="one">Imported %d entry</item>
<item quantity="other">Imported %d entries</item>
</plurals>
<string name="intro_import_error_title">An error occurred while importing the vault</string>
<string name="import_error_title">One or more errors occurred during the import</string>
<string name="exporting_vault_error">An error occurred while trying to export the vault</string>
<string name="exported_vault">The vault has been exported</string>
@ -392,6 +393,7 @@
<string name="pref_panic_trigger_title">Delete vault on panic trigger</string>
<string name="pref_panic_trigger_summary">Delete vault when a panic trigger is received from Ripple</string>
<string name="import_vault">Import vault</string>
<string name="importer_help_2fas">Supply a 2FAS Authenticator backup file.</string>
<string name="importer_help_aegis">Supply an Aegis export/backup file.</string>
<string name="importer_help_authenticator_plus">Supply an Authenticator Plus export file obtained through <b>Settings -> Backup &amp; Restore -> Export as Text and HTML</b>.</string>

View file

@ -31,6 +31,7 @@
<item name="alertDialogTheme">@style/Theme.Aegis.Dialog.Light</item>
<item name="preferenceTheme">@style/Theme.Aegis.Preference.Light</item>
<item name="textInputStyle">@style/Widget.Aegis.TextInput.Light</item>
<item name="introButtonStyle">@style/Widget.Aegis.OutlinedButton.Light</item>
<item name="dropdownStyle">@style/Widget.Aegis.Dropdown.Light</item>
<item name="bottomSheetDialogTheme">@style/ThemeOverLay.Aegis.BottomSheetDialog.Rounded</item>
@ -74,6 +75,7 @@
<item name="alertDialogTheme">@style/Theme.Aegis.Dialog.Dark</item>
<item name="preferenceTheme">@style/Theme.Aegis.Preference.Dark</item>
<item name="textInputStyle">@style/Widget.Aegis.TextInput.Dark</item>
<item name="introButtonStyle">@style/Widget.Aegis.OutlinedButton.Dark</item>
<item name="dropdownStyle">@style/Widget.Aegis.Dropdown.Dark</item>
<item name="bottomSheetDialogTheme">@style/ThemeOverLay.Aegis.BottomSheetDialog.Rounded</item>
@ -108,6 +110,7 @@
<item name="alertDialogTheme">@style/Theme.Aegis.Dialog.TrueDark</item>
<item name="preferenceTheme">@style/Theme.Aegis.Preference.Dark</item>
<item name="textInputStyle">@style/Widget.Aegis.TextInput.Dark</item>
<item name="introButtonStyle">@style/Widget.Aegis.OutlinedButton.Dark</item>
<item name="dropdownStyle">@style/Widget.Aegis.Dropdown.Dark</item>
<item name="bottomSheetDialogTheme">@style/ThemeOverLay.Aegis.BottomSheetDialog.Rounded</item>
@ -224,6 +227,12 @@
<item name="android:textColor">@color/dialog_button_color</item>
</style>
<style name="Widget.Aegis.OutlinedButton.Light" parent="Widget.MaterialComponents.Button.OutlinedButton"/>
<style name="Widget.Aegis.OutlinedButton.Dark" parent="Widget.MaterialComponents.Button.OutlinedButton">
<item name="android:textColor">@color/secondary_text</item>
<item name="rippleColor">@color/secondary_text</item>
</style>
<style name="Widget.Aegis.BottomSheet.Rounded" parent="Widget.MaterialComponents.BottomSheet">
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.Aegis.BottomSheetDialog.Rounded</item>
<item name="android:background">@android:color/transparent</item>