diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java index 95c1e89..62d5ace 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java @@ -1,6 +1,7 @@ package com.cringe_studios.cringe_authenticator; import android.content.res.Configuration; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -15,6 +16,7 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Consumer; import androidx.fragment.app.Fragment; import com.cringe_studios.cringe_authenticator.databinding.ActivityMainBinding; @@ -47,6 +49,14 @@ public class MainActivity extends BaseActivity { private ActivityResultLauncher pickQRCodeImage; + private ActivityResultLauncher pickBackupFileSave; + + private Consumer pickBackupFileSaveCallback; + + private ActivityResultLauncher pickBackupFileLoad; + + private Consumer pickBackupFileLoadCallback; + private QRScanner qrScanner; private boolean fullyLaunched; @@ -116,6 +126,20 @@ public class MainActivity extends BaseActivity { } }); + pickBackupFileSave = registerForActivityResult(new ActivityResultContracts.CreateDocument("application/json"), doc -> { + if(pickBackupFileSaveCallback != null) { + pickBackupFileSaveCallback.accept(doc); + pickBackupFileSaveCallback = null; + } + }); + + pickBackupFileLoad = registerForActivityResult(new ActivityResultContracts.OpenDocument(), doc -> { + if(pickBackupFileLoadCallback != null) { + pickBackupFileLoadCallback.accept(doc); + pickBackupFileLoadCallback = null; + } + }); + OTPDatabase.promptLoadDatabase(this, this::launchApp, this::finishAffinity); } @@ -308,6 +332,24 @@ public class MainActivity extends BaseActivity { OTPDatabase.promptLoadDatabase(this, () -> {}, () -> {}); } + public void promptPickBackupFileSave(String name, Consumer callback) { + this.lockOnPause = false; + this.pickBackupFileSaveCallback = uri -> { + lockOnPause = true; + callback.accept(uri); + }; + pickBackupFileSave.launch(name); + } + + public void promptPickBackupFileLoad(Consumer callback) { + this.lockOnPause = false; + this.pickBackupFileLoadCallback = uri -> { + lockOnPause = true; + callback.accept(uri); + }; + pickBackupFileLoad.launch(new String[]{"application/json"}); + } + @Override public void recreate() { lockOnPause = false; diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/GroupFragment.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/GroupFragment.java index 400cf02..c537946 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/GroupFragment.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/GroupFragment.java @@ -111,16 +111,10 @@ public class GroupFragment extends NamedFragment { OTPListItem vh = (OTPListItem) binding.itemList.findViewHolderForAdapterPosition(i); if(vh == null) continue; try { - vh.getBinding().otpCode.setText(OTPListItem.formatCode(vh.getOTPData().getPin())); + vh.refresh(); } catch (Exception e) { DialogUtil.showErrorDialog(requireContext(), e.getMessage() == null ? "An error occurred while refreshing the code" : e.getMessage()); } - - if(vh.getOTPData().getType() == OTPType.TOTP) { - long timeDiff = vh.getOTPData().getNextDueTime() - System.currentTimeMillis() / 1000; - double progress = 1 - ((double) timeDiff / vh.getOTPData().getPeriod()); - vh.getBinding().progress.setImageLevel((int) (progress * 10_000)); - } } } diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/SettingsFragment.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/SettingsFragment.java index 40d78b6..735bc40 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/fragment/SettingsFragment.java @@ -1,5 +1,6 @@ package com.cringe_studios.cringe_authenticator.fragment; +import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -19,13 +20,19 @@ import com.cringe_studios.cringe_authenticator.crypto.CryptoException; import com.cringe_studios.cringe_authenticator.crypto.CryptoParameters; import com.cringe_studios.cringe_authenticator.databinding.FragmentSettingsBinding; import com.cringe_studios.cringe_authenticator.util.Appearance; +import com.cringe_studios.cringe_authenticator.util.BackupException; +import com.cringe_studios.cringe_authenticator.util.BackupUtil; import com.cringe_studios.cringe_authenticator.util.BiometricUtil; import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.OTPDatabase; import com.cringe_studios.cringe_authenticator.util.OTPDatabaseException; import com.cringe_studios.cringe_authenticator.util.SettingsUtil; +import com.cringe_studios.cringe_authenticator.util.StyledDialogBuilder; import com.cringe_studios.cringe_authenticator.util.Theme; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; import java.util.Arrays; import java.util.Locale; @@ -74,7 +81,7 @@ public class SettingsFragment extends NamedFragment { binding.settingsEnableEncryption.setChecked(SettingsUtil.isDatabaseEncrypted(requireContext())); binding.settingsEnableEncryption.setOnCheckedChangeListener((view, checked) -> { if(checked) { - DialogUtil.showInputPasswordDialog(requireContext(), password -> { + DialogUtil.showSetPasswordDialog(requireContext(), password -> { CryptoParameters params = CryptoParameters.createNew(); Log.d("Crypto", "Created new crypto params"); @@ -191,9 +198,83 @@ public class SettingsFragment extends NamedFragment { public void onNothingSelected(AdapterView parent) {} }); + binding.settingsCreateBackup.setOnClickListener(view -> { + new StyledDialogBuilder(requireContext()) + .setItems(new String[]{"Create with current password", "Create with custom password"}, (d, which) -> { + switch(which) { + case 0: + OTPDatabase.promptLoadDatabase(requireActivity(), () -> { + SecretKey key = OTPDatabase.getLoadedKey(); + CryptoParameters parameters = SettingsUtil.getCryptoParameters(requireContext()); + createBackup(key, parameters); + }, null); + break; + case 1: + DialogUtil.showSetPasswordDialog(requireContext(), password -> { + CryptoParameters parameters = CryptoParameters.createNew(); + try { + SecretKey key = Crypto.generateKey(parameters, password); + createBackup(key, parameters); + } catch (CryptoException e) { + DialogUtil.showErrorDialog(requireContext(), e.toString()); + } + }, null); + break; + } + }) + .show(); + }); + + binding.settingsLoadBackup.setOnClickListener(view -> { + ((MainActivity) requireActivity()).promptPickBackupFileLoad(uri -> { + if(uri == null || uri.getPath() == null) return; + + loadBackup(uri); + }); + }); + return binding.getRoot(); } + private void createBackup(SecretKey key, CryptoParameters parameters) { + ((MainActivity) requireActivity()).promptPickBackupFileSave(BackupUtil.getBackupName(), uri -> { + if(uri == null || uri.getPath() == null) return; + + try { + BackupUtil.saveBackup(requireContext(), uri, key, parameters); + } catch (BackupException | CryptoException e) { + DialogUtil.showErrorDialog(requireContext(), e.toString()); + } + }); + } + + private void loadBackup(Uri uri) { + OTPDatabase.promptLoadDatabase(requireActivity(), () -> { + try { + SecretKey key = OTPDatabase.getLoadedKey(); + CryptoParameters parameters = SettingsUtil.getCryptoParameters(requireContext()); + loadBackup(uri, key, parameters); + } catch (CryptoException e) { + DialogUtil.showInputPasswordDialog(requireContext(), password -> { + try { + CryptoParameters parameters = BackupUtil.loadParametersFromBackup(requireContext(), uri); + SecretKey key = Crypto.generateKey(parameters, password); + loadBackup(uri, key, parameters); + } catch (BackupException | OTPDatabaseException | CryptoException e2) { + DialogUtil.showErrorDialog(requireContext(), "Failed to load backup", e2); + } + }, null); + } catch(BackupException | OTPDatabaseException e) { + DialogUtil.showErrorDialog(requireContext(), "Failed to load backup", e); + } + }, null); + } + + private void loadBackup(Uri uri, SecretKey key, CryptoParameters parameters) throws BackupException, OTPDatabaseException, CryptoException { + OTPDatabase db = BackupUtil.loadBackup(requireContext(), uri, key, parameters); + DialogUtil.showErrorDialog(requireContext(), "Success: " + db); + } + @Override public void onDestroyView() { super.onDestroyView(); diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java index d7ea6e9..cae26a3 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java @@ -68,7 +68,7 @@ public class OTPListAdapter extends RecyclerView.Adapter { holder.setSelected(false); try { - holder.getBinding().otpCode.setText(OTPListItem.formatCode(data.getPin())); + holder.refresh(); } catch (OTPException e) { DialogUtil.showErrorDialog(context, context.getString(R.string.otp_add_error, e.getMessage() != null ? e.getMessage() : e.toString())); } @@ -87,7 +87,7 @@ public class OTPListAdapter extends RecyclerView.Adapter { data.incrementCounter(); try { - holder.getBinding().otpCode.setText(OTPListItem.formatCode(data.getPin())); + holder.refresh(); } catch (OTPException e) { DialogUtil.showErrorDialog(context, context.getString(R.string.otp_add_error, e.getMessage() != null ? e.getMessage() : e.toString())); return; diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListItem.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListItem.java index 5062641..0d5105d 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListItem.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListItem.java @@ -10,6 +10,8 @@ import androidx.recyclerview.widget.RecyclerView; import com.cringe_studios.cringe_authenticator.R; import com.cringe_studios.cringe_authenticator.databinding.OtpCodeBinding; import com.cringe_studios.cringe_authenticator.model.OTPData; +import com.cringe_studios.cringe_authenticator_library.OTPException; +import com.cringe_studios.cringe_authenticator_library.OTPType; public class OTPListItem extends RecyclerView.ViewHolder { @@ -36,6 +38,16 @@ public class OTPListItem extends RecyclerView.ViewHolder { return otpData; } + public void refresh() throws OTPException { + binding.otpCode.setText(OTPListItem.formatCode(otpData.getPin())); + + if(otpData.getType() == OTPType.TOTP) { + long timeDiff = otpData.getNextDueTime() - System.currentTimeMillis() / 1000; + double progress = 1 - ((double) timeDiff / otpData.getPeriod()); + binding.progress.setImageLevel((int) (progress * 10_000)); + } + } + public static String formatCode(String code) { // TODO: add setting for group size (and enable/disable grouping) StringBuilder b = new StringBuilder(); diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/BackupUtil.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/BackupUtil.java index da0bd1e..596cb4f 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/BackupUtil.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/BackupUtil.java @@ -1,66 +1,70 @@ package com.cringe_studios.cringe_authenticator.util; +import android.content.Context; +import android.net.Uri; import android.util.Base64; -import com.cringe_studios.cringe_authenticator.crypto.Crypto; import com.cringe_studios.cringe_authenticator.crypto.CryptoException; import com.cringe_studios.cringe_authenticator.crypto.CryptoParameters; -import com.cringe_studios.cringe_authenticator.model.OTPData; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; -import org.bouncycastle.jcajce.provider.symmetric.ARC4; -import org.json.JSONObject; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; import javax.crypto.SecretKey; public class BackupUtil { - public static void saveBackup(File backupFile, SecretKey key, CryptoParameters parameters) throws BackupException, CryptoException { - if(!OTPDatabase.isDatabaseLoaded()) throw new BackupException("Database is not loaded"); + private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH); - if(!backupFile.exists()) { - File parent = backupFile.getParentFile(); - if(parent != null && !parent.exists()) parent.mkdirs(); - try { - backupFile.createNewFile(); - } catch (IOException e) { - throw new BackupException(e); - } - } + public static String getBackupName() { + return "backup_" + FORMAT.format(new Date()); // TODO: indicate Cringe Authenticator + } + + public static void saveBackup(Context context, Uri backupFile, SecretKey key, CryptoParameters parameters) throws BackupException, CryptoException { + if(!OTPDatabase.isDatabaseLoaded()) throw new BackupException("Database is not loaded"); byte[] dbBytes = OTPDatabase.convertToEncryptedBytes(OTPDatabase.getLoadedDatabase(), key, parameters); JsonObject object = new JsonObject(); object.add("parameters", SettingsUtil.GSON.toJsonTree(parameters)); object.addProperty("database", Base64.encodeToString(dbBytes, Base64.DEFAULT)); - try(FileOutputStream fOut = new FileOutputStream(backupFile)) { - fOut.write(object.toString().getBytes(StandardCharsets.UTF_8)); + try(OutputStream out = context.getContentResolver().openOutputStream(backupFile)) { + if(out == null) throw new BackupException("Failed to write backup"); + out.write(object.toString().getBytes(StandardCharsets.UTF_8)); + }catch(IOException e) { + throw new BackupException(e); + } + } + + public static CryptoParameters loadParametersFromBackup(Context context, Uri backupFile) throws BackupException { + try(InputStream in = context.getContentResolver().openInputStream(backupFile)) { + if(in == null) throw new BackupException("Failed to read backup file"); + byte[] backupBytes = IOUtil.readBytes(in); + JsonObject object = SettingsUtil.GSON.fromJson(new String(backupBytes, StandardCharsets.UTF_8), JsonObject.class); + return SettingsUtil.GSON.fromJson(object.get("parameters"), CryptoParameters.class); // TODO: check if params are valid } catch (IOException e) { throw new BackupException(e); } } - public static CryptoParameters loadParametersFromBackup(File backupFile) throws BackupException { - try { - byte[] backupBytes = IOUtil.readBytes(backupFile); + public static OTPDatabase loadBackup(Context context, Uri backupFile, SecretKey key, CryptoParameters parameters) throws BackupException, OTPDatabaseException, CryptoException { + try(InputStream in = context.getContentResolver().openInputStream(backupFile)) { + if (in == null) throw new BackupException("Failed to read backup file"); + byte[] backupBytes = IOUtil.readBytes(in); JsonObject object = SettingsUtil.GSON.fromJson(new String(backupBytes, StandardCharsets.UTF_8), JsonObject.class); - return SettingsUtil.GSON.fromJson(object.get("parameters"), CryptoParameters.class); - } catch (IOException e) { - throw new BackupException(e); - } - } - - public static OTPDatabase loadBackup(File backupFile, SecretKey key, CryptoParameters parameters) throws BackupException, OTPDatabaseException, CryptoException { - try { - byte[] backupBytes = IOUtil.readBytes(backupFile); - JsonObject object = SettingsUtil.GSON.fromJson(new String(backupBytes, StandardCharsets.UTF_8), JsonObject.class); - return OTPDatabase.loadFromEncryptedBytes(Base64.decode(object.get("database").getAsString(), Base64.DEFAULT), key, parameters); + JsonElement db = object.get("database"); + if(db == null) throw new BackupException("Invalid backup file"); + return OTPDatabase.loadFromEncryptedBytes(Base64.decode(db.getAsString(), Base64.DEFAULT), key, parameters); + } catch(JsonSyntaxException e) { + throw new BackupException("Invalid JSON", e); } catch (IOException e) { throw new BackupException(e); } diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/DialogUtil.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/DialogUtil.java index 77f6a0a..2cb9aed 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/DialogUtil.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/DialogUtil.java @@ -16,6 +16,7 @@ import com.cringe_studios.cringe_authenticator.R; import com.cringe_studios.cringe_authenticator.databinding.DialogCreateGroupBinding; import com.cringe_studios.cringe_authenticator.databinding.DialogInputCodeHotpBinding; import com.cringe_studios.cringe_authenticator.databinding.DialogInputCodeTotpBinding; +import com.cringe_studios.cringe_authenticator.databinding.DialogSetPasswordBinding; import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator_library.OTPAlgorithm; import com.cringe_studios.cringe_authenticator_library.OTPType; @@ -56,7 +57,11 @@ public class DialogUtil { } public static void showErrorDialog(Context context, String errorMessage) { - showErrorDialog(context, errorMessage, null); + showErrorDialog(context, errorMessage, (Runnable) null); + } + + public static void showErrorDialog(Context context, String errorMessage, Exception exception) { + showErrorDialog(context, errorMessage + ": " + exception.toString(), (Runnable) null); // TODO: exception details button } public static void showTOTPDialog(LayoutInflater inflater, OTPData initialData, Consumer callback, boolean view) { @@ -266,6 +271,40 @@ public class DialogUtil { dialog.show(); } + public static void showSetPasswordDialog(Context context, Consumer callback, Runnable onCancel) { + DialogSetPasswordBinding binding = DialogSetPasswordBinding.inflate(LayoutInflater.from(context)); + + AlertDialog dialog = new StyledDialogBuilder(context) + .setTitle(R.string.set_password) + .setView(binding.getRoot()) + .setPositiveButton("Ok", (d, which) -> {}) + .setNegativeButton(R.string.cancel, (d, which) -> { if(onCancel != null) onCancel.run(); }) + .setOnCancelListener(d -> { if(onCancel != null) onCancel.run(); }) + .create(); + + dialog.setOnShowListener(d -> { + Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + okButton.setOnClickListener(v -> { + if(binding.setPassword.getText().length() == 0) { + DialogUtil.showErrorDialog(context, "You need to enter a password"); + return; + } + + String pass = binding.setPassword.getText().toString(); + String confirm = binding.confirmPassword.getText().toString(); + if(!pass.equals(confirm)) { + DialogUtil.showErrorDialog(context, "The passwords do not match"); + return; + } + + dialog.dismiss(); + callback.accept(pass); + }); + }); + + dialog.show(); + } + public static void showInputPasswordDialog(Context context, Consumer callback, Runnable onCancel) { EditText passwordField = new EditText(context); passwordField.setInputType(InputType.TYPE_TEXT_VARIATION_PASSWORD); diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/IOUtil.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/IOUtil.java index a74dc7d..883c39e 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/IOUtil.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/IOUtil.java @@ -1,13 +1,12 @@ package com.cringe_studios.cringe_authenticator.util; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; -import java.nio.file.Files; public class IOUtil { @@ -24,6 +23,17 @@ public class IOUtil { } } + public static byte[] readBytes(InputStream in) throws IOException { + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) > 0) { + bOut.write(buffer, 0, len); + } + + return bOut.toByteArray(); + } + public static void writeBytes(File file, byte[] bytes) throws IOException { try(FileOutputStream fOut = new FileOutputStream(file)) { fOut.write(bytes); diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/OTPDatabase.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/OTPDatabase.java index adf5725..d9372f0 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/OTPDatabase.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/OTPDatabase.java @@ -94,15 +94,10 @@ public class OTPDatabase { } } - try(FileInputStream fIn = new FileInputStream(file)) { - ByteBuffer fileBuffer = ByteBuffer.allocate((int) file.length()); - byte[] buffer = new byte[1024]; - int len; - while((len = fIn.read(buffer)) > 0) { - fileBuffer.put(buffer, 0, len); - } + try { + byte[] bytes = IOUtil.readBytes(file); - loadedDatabase = loadFromEncryptedBytes(fileBuffer.array(), key, SettingsUtil.getCryptoParameters(context)); + loadedDatabase = loadFromEncryptedBytes(bytes, key, SettingsUtil.getCryptoParameters(context)); loadedKey = key; return loadedDatabase; }catch(IOException e) { diff --git a/app/src/main/res/layout/dialog_set_password.xml b/app/src/main/res/layout/dialog_set_password.xml new file mode 100644 index 0000000..6fdd3dc --- /dev/null +++ b/app/src/main/res/layout/dialog_set_password.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_group.xml b/app/src/main/res/layout/fragment_group.xml index 5a17a0d..da919fe 100644 --- a/app/src/main/res/layout/fragment_group.xml +++ b/app/src/main/res/layout/fragment_group.xml @@ -8,7 +8,9 @@ + android:layout_height="match_parent" + android:paddingTop="5dp" + android:paddingBottom="5dp"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index 7557c21..02950bc 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -84,4 +84,10 @@ Systemstandard Hell Dunkel + Backup erstellen + + Mit aktuellem Passwort erstellen + Mit neuem Passwort erstellen + + Backup laden \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a79d6fc..3d7f57b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,7 +33,7 @@ Do you want to delete the selected OTP(s)? Edit Group Enable intro video - Require biometric unlock + Use biometric unlock Code added Add Code Create New Group @@ -53,8 +53,8 @@ Hide Codes Enable encryption Password - set Password - confirm Password + Set Password + Confirm Password Unlock Authenticator Unlock Unlock using biometrics @@ -85,4 +85,10 @@ Dark Light System default + Create backup + + Create with current password + Create with new password + + Load backup \ No newline at end of file