diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..71f13b3
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 3235457..bffb10c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,7 +4,7 @@ plugins {
android {
namespace 'com.example.onetap_ssh'
- compileSdk 33
+ compileSdk 34
defaultConfig {
applicationId "com.example.onetap_ssh"
@@ -40,6 +40,11 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.navigation:navigation-fragment:2.7.5'
implementation 'androidx.navigation:navigation-ui:2.7.5'
+ implementation "androidx.biometric:biometric:1.1.0"
+ implementation 'androidx.activity:activity:1.8.0'
+ implementation 'com.google.code.gson:gson:2.10.1'
+ implementation 'org.bouncycastle:bcprov-jdk15on:1.70'
+ implementation 'com.caverock:androidsvg-aar:1.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7f7d5b3..b1cdb1d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,19 +10,36 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
- android:theme="@style/Theme.OneTapSSH"
+ android:theme="@style/Theme.OneTap_SSH"
tools:targetApi="31">
+ android:theme="@style/Theme.OneTap_SSH"
+ android:exported="true">
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/onetap_ssh/BaseActivity.java b/app/src/main/java/com/example/onetap_ssh/BaseActivity.java
new file mode 100644
index 0000000..b067f9a
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/BaseActivity.java
@@ -0,0 +1,56 @@
+package com.example.onetap_ssh;
+
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.view.WindowManager;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.example.onetap_ssh.unlock.UnlockContract;
+import com.example.onetap_ssh.util.AppLocale;
+import com.example.onetap_ssh.util.SettingsUtil;
+import com.example.onetap_ssh.util.ThemeUtil;
+
+import java.util.Locale;
+
+public class BaseActivity extends AppCompatActivity {
+
+ private ActivityResultLauncher startUnlockActivity;
+
+ private Runnable unlockSuccess, unlockFailure;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ registerCallbacks();
+
+ if(SettingsUtil.isScreenSecurity(this)) {
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+
+ ThemeUtil.loadTheme(this);
+ setLocale(SettingsUtil.getLocale(this));
+ }
+
+ private void registerCallbacks() {
+ startUnlockActivity = registerForActivityResult(new UnlockContract(), success -> {
+ if(success && unlockSuccess != null) unlockSuccess.run();
+ if(!success && unlockFailure != null) unlockFailure.run();
+ });
+ }
+
+ public void promptUnlock(Runnable success, Runnable failure) {
+ unlockSuccess = success;
+ unlockFailure = failure;
+ startUnlockActivity.launch(null);
+ }
+
+ public void setLocale(AppLocale locale) {
+ Configuration config = new Configuration();
+ config.setLocale(locale == AppLocale.SYSTEM_DEFAULT ? Locale.getDefault() : locale.getLocale());
+ getResources().updateConfiguration(config, getResources().getDisplayMetrics());
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/IntroActivity.java b/app/src/main/java/com/example/onetap_ssh/IntroActivity.java
new file mode 100644
index 0000000..43d30ed
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/IntroActivity.java
@@ -0,0 +1,89 @@
+package com.example.onetap_ssh;
+
+import android.content.Intent;
+import android.media.MediaPlayer;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import com.example.onetap_ssh.databinding.ActivityIntroBinding;
+import com.example.onetap_ssh.unlock.UnlockActivity;
+import com.example.onetap_ssh.util.SettingsUtil;
+
+public class IntroActivity extends BaseActivity {
+
+ private ActivityIntroBinding binding;
+
+ private MediaPlayer mMediaPlayer;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (!SettingsUtil.isIntroVideoEnabled(this)) {
+ openMainActivity();
+ return;
+ }
+
+ binding = ActivityIntroBinding.inflate(getLayoutInflater());
+
+ Uri uri = Uri.parse(String.format("android.resource://%s/%s", getPackageName(), R.raw.intro_vp9));
+ binding.videoView.setVideoURI(uri);
+ binding.videoView.start();
+
+ binding.videoView.setOnPreparedListener(mediaPlayer -> {
+ mMediaPlayer = mediaPlayer;
+ setDimension();
+ });
+
+ binding.videoView.setOnCompletionListener(mp -> openMainActivity());
+
+ binding.videoView.setOnErrorListener((MediaPlayer mp, int what, int extra) -> {
+ Toast.makeText(this, R.string.intro_video_failed, Toast.LENGTH_LONG).show();
+ openMainActivity();
+ return true;
+ });
+
+ setContentView(binding.getRoot());
+ }
+
+ public void openMainActivity() {
+ Intent m = new Intent(getApplicationContext(), UnlockActivity.class);
+ m.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(m);
+ finish();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if(mMediaPlayer != null) mMediaPlayer.release();
+ mMediaPlayer = null;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ binding.videoView.start();
+ }
+
+ private void setDimension() {
+ float videoProportion = (float) mMediaPlayer.getVideoHeight() / mMediaPlayer.getVideoWidth();
+ int screenWidth = getResources().getDisplayMetrics().widthPixels;
+ int screenHeight = getResources().getDisplayMetrics().heightPixels;
+ float screenProportion = (float) screenHeight / (float) screenWidth;
+ ViewGroup.LayoutParams lp = binding.videoView.getLayoutParams();
+
+ if (videoProportion < screenProportion) {
+ lp.height= screenHeight;
+ lp.width = (int) ((float) screenHeight / videoProportion);
+ } else {
+ lp.width = screenWidth;
+ lp.height = (int) ((float) screenWidth * videoProportion);
+ }
+ binding.videoView.setLayoutParams(lp);
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/onetap_ssh/MainActivity.java b/app/src/main/java/com/example/onetap_ssh/MainActivity.java
index bfe4814..00d0eaa 100644
--- a/app/src/main/java/com/example/onetap_ssh/MainActivity.java
+++ b/app/src/main/java/com/example/onetap_ssh/MainActivity.java
@@ -36,7 +36,7 @@ public class MainActivity extends AppCompatActivity {
.setAction("Action", null).show();
}
});
- DrawerLayout drawer = binding.drawerLayout;
+ DrawerLayout drawer = binding.appBackground;
NavigationView navigationView = binding.navView;
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
diff --git a/app/src/main/java/com/example/onetap_ssh/backup/BackupData.java b/app/src/main/java/com/example/onetap_ssh/backup/BackupData.java
new file mode 100644
index 0000000..371ef38
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/backup/BackupData.java
@@ -0,0 +1,53 @@
+package com.example.onetap_ssh.backup;
+
+import android.util.Base64;
+
+import com.example.onetap_ssh.crypto.CryptoException;
+import com.example.onetap_ssh.crypto.CryptoParameters;
+import com.example.onetap_ssh.util.BackupException;
+
+import javax.crypto.SecretKey;
+
+public class BackupData {
+
+ private CryptoParameters parameters;
+
+ private String database;
+
+ private BackupGroup[] groups;
+
+ private BackupData() {}
+
+ public BackupData(CryptoParameters parameters, String database, BackupGroup[] groups) {
+ this.parameters = parameters;
+ this.database = database;
+ this.groups = groups;
+ }
+
+ public CryptoParameters getParameters() {
+ return parameters;
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+
+
+ public BackupGroup[] getGroups() {
+ return groups;
+ }
+
+ public boolean isValid() {
+ if(parameters == null || database == null || groups == null) return false;
+
+ if(!parameters.isValid()) return false;
+
+ for(BackupGroup group : groups) {
+ if(!group.isValid()) return false;
+ }
+
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/backup/BackupGroup.java b/app/src/main/java/com/example/onetap_ssh/backup/BackupGroup.java
new file mode 100644
index 0000000..a09c265
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/backup/BackupGroup.java
@@ -0,0 +1,27 @@
+package com.example.onetap_ssh.backup;
+
+public class BackupGroup {
+
+ public String id;
+ public String name;
+
+ private BackupGroup() {}
+
+ public BackupGroup(String id, String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean isValid() {
+ return id != null && name != null;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/backup/BackupUtil.java b/app/src/main/java/com/example/onetap_ssh/backup/BackupUtil.java
new file mode 100644
index 0000000..2439fdf
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/backup/BackupUtil.java
@@ -0,0 +1,66 @@
+package com.example.onetap_ssh.backup;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Base64;
+
+import com.example.onetap_ssh.crypto.CryptoException;
+import com.example.onetap_ssh.crypto.CryptoParameters;
+import com.example.onetap_ssh.util.BackupException;
+import com.example.onetap_ssh.util.IOUtil;
+
+import com.example.onetap_ssh.util.SettingsUtil;
+import com.google.gson.JsonSyntaxException;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+import javax.crypto.SecretKey;
+
+public class BackupUtil {
+
+ private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH);
+
+ public static String getBackupName() {
+ return "code_guard_backup_" + FORMAT.format(new Date());
+ }
+
+ public static void saveBackup(Context context, Uri backupFile, SecretKey key, CryptoParameters parameters) throws BackupException, CryptoException {
+
+ }
+
+ 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);
+ BackupData data = SettingsUtil.GSON.fromJson(new String(backupBytes, StandardCharsets.UTF_8), BackupData.class);
+ if(!data.getParameters().isValid()) throw new BackupException("Invalid crypto parameters");
+ return data.getParameters();
+ } catch (JsonSyntaxException e) {
+ throw new BackupException("Invalid JSON", e);
+ } catch (IOException e) {
+ throw new BackupException(e);
+ }
+ }
+
+ public static BackupData loadBackup(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);
+ BackupData data = SettingsUtil.GSON.fromJson(new String(backupBytes, StandardCharsets.UTF_8), BackupData.class);
+ if(!data.isValid()) throw new BackupException("Invalid backup data"); // TODO: more info on backup errors
+ return data;
+ } catch (JsonSyntaxException e) {
+ throw new BackupException("Invalid JSON", e);
+ } catch (IOException e) {
+ throw new BackupException(e);
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/crypto/BiometricKey.java b/app/src/main/java/com/example/onetap_ssh/crypto/BiometricKey.java
new file mode 100644
index 0000000..21dbbf1
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/crypto/BiometricKey.java
@@ -0,0 +1,26 @@
+package com.example.onetap_ssh.crypto;
+
+public class BiometricKey {
+
+ private final String id;
+ private final byte[] encryptedKey;
+ private final byte[] iv;
+
+ public BiometricKey(String id, byte[] encryptedKey, byte[] iv) {
+ this.id = id;
+ this.encryptedKey = encryptedKey;
+ this.iv = iv;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public byte[] getEncryptedKey() {
+ return encryptedKey;
+ }
+
+ public byte[] getIV() {
+ return iv;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/crypto/Crypto.java b/app/src/main/java/com/example/onetap_ssh/crypto/Crypto.java
new file mode 100644
index 0000000..b591e4d
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/crypto/Crypto.java
@@ -0,0 +1,134 @@
+package com.example.onetap_ssh.crypto;
+
+import android.os.Build;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+
+import androidx.annotation.RequiresApi;
+
+import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
+import org.bouncycastle.crypto.params.Argon2Parameters;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.SecureRandom;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+import java.util.UUID;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+public class Crypto {
+
+ private static final String KEY_STORE = "AndroidKeyStore";
+
+ public static byte[] generateHash(CryptoParameters parameters, String password) {
+ Argon2Parameters params = new Argon2Parameters.Builder()
+ .withVersion(parameters.getArgon2Version())
+ .withIterations(parameters.getArgon2Iterations())
+ .withMemoryAsKB(parameters.getArgon2Memory())
+ .withParallelism(parameters.getArgon2Parallelism())
+ .withSalt(parameters.getSalt())
+ .build();
+
+ Argon2BytesGenerator generator = new Argon2BytesGenerator();
+ generator.init(params);
+ byte[] result = new byte[parameters.getEncryptionAESKeyLength()];
+ generator.generateBytes(password.getBytes(StandardCharsets.UTF_8), result);
+ return result;
+ }
+
+ public static SecretKey generateKey(CryptoParameters parameters, String password) throws CryptoException {
+ byte[] hash = generateHash(parameters, password);
+ return new SecretKeySpec(hash, "AES");
+ }
+
+ private static byte[] generateBytes(int length) {
+ SecureRandom r = new SecureRandom();
+ byte[] bytes = new byte[length];
+ r.nextBytes(bytes);
+ return bytes;
+ }
+
+ public static byte[] generateSalt(CryptoParameters parameters) {
+ return generateBytes(parameters.getEncryptionSaltLength());
+ }
+
+ public static byte[] generateIV(CryptoParameters parameters) {
+ return generateBytes(parameters.getEncryptionIVLength());
+ }
+
+ public static CryptoResult encryptWithResult(CryptoParameters parameters, byte[] bytes, SecretKey key, boolean useIV) throws CryptoException {
+ try {
+ Cipher cipher = Cipher.getInstance(parameters.getEncryptionAlgorithm());
+ GCMParameterSpec spec = new GCMParameterSpec(parameters.getEncryptionGCMTagLength() * 8, parameters.getIV());
+ if(useIV) {
+ cipher.init(Cipher.ENCRYPT_MODE, key, spec);
+ }else {
+ cipher.init(Cipher.ENCRYPT_MODE, key);
+ }
+ return new CryptoResult(cipher.doFinal(bytes), cipher.getIV());
+ }catch(NoSuchAlgorithmException | NoSuchPaddingException |
+ InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException |
+ IllegalBlockSizeException e) {
+ throw new CryptoException(e);
+ }
+ }
+
+ public static byte[] encrypt(CryptoParameters parameters, byte[] bytes, SecretKey key) throws CryptoException {
+ return encryptWithResult(parameters, bytes, key, true).getEncrypted();
+ }
+
+ public static byte[] decrypt(CryptoParameters parameters, byte[] bytes, SecretKey key, byte[] overrideIV) throws CryptoException {
+ try {
+ Cipher cipher = Cipher.getInstance(parameters.getEncryptionAlgorithm());
+ GCMParameterSpec spec = new GCMParameterSpec(parameters.getEncryptionGCMTagLength() * 8, overrideIV != null ? overrideIV : parameters.getIV());
+ cipher.init(Cipher.DECRYPT_MODE, key, spec);
+ return cipher.doFinal(bytes);
+ }catch(NoSuchAlgorithmException | NoSuchPaddingException |
+ InvalidAlgorithmParameterException | InvalidKeyException | BadPaddingException |
+ IllegalBlockSizeException e) {
+ throw new CryptoException(e);
+ }
+ }
+
+ public static byte[] decrypt(CryptoParameters parameters, byte[] bytes, SecretKey key) throws CryptoException {
+ return decrypt(parameters, bytes, key, null);
+ }
+
+
+
+ public static SecretKey getBiometricKey(BiometricKey key) throws CryptoException {
+ try {
+ KeyStore store = KeyStore.getInstance(KEY_STORE);
+ store.load(null);
+ return (SecretKey) store.getKey(key.getId(), null);
+ } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException | CertificateException | IOException e) {
+ throw new CryptoException(e);
+ }
+ }
+
+ public static void deleteBiometricKey(BiometricKey key) throws CryptoException {
+ try {
+ KeyStore ks = KeyStore.getInstance(KEY_STORE);
+ ks.load(null);
+ ks.deleteEntry(key.getId());
+ } catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
+ throw new CryptoException(e);
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/crypto/CryptoException.java b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoException.java
new file mode 100644
index 0000000..ef660cc
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoException.java
@@ -0,0 +1,18 @@
+package com.example.onetap_ssh.crypto;
+
+public class CryptoException extends Exception {
+ public CryptoException() {
+ }
+
+ public CryptoException(String message) {
+ super(message);
+ }
+
+ public CryptoException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public CryptoException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/crypto/CryptoParameters.java b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoParameters.java
new file mode 100644
index 0000000..0d0f814
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoParameters.java
@@ -0,0 +1,99 @@
+package com.example.onetap_ssh.crypto;
+
+import org.bouncycastle.crypto.params.Argon2Parameters;
+
+public class CryptoParameters {
+
+ private final String hashType = "argon2";
+
+ private final int argon2Version = Argon2Parameters.ARGON2_VERSION_13;
+ private final int argon2Iterations = 2;
+ private final int argon2Memory = 16384;
+ private final int argon2Parallelism = 1;
+
+ private final String encryptionAlgorithm = "AES/GCM/NoPadding";
+ private final int encryptionGCMTagLength = 16;
+ private final int encryptionIVLength = 12;
+ private final int encryptionAESKeyLength = 32;
+ private final int encryptionSaltLength = 16;
+
+ private byte[] salt;
+ private byte[] iv;
+
+ private CryptoParameters() {}
+
+ private void init(byte[] salt, byte[] iv) {
+ this.salt = salt;
+ this.iv = iv;
+ }
+
+ public byte[] getSalt() {
+ return salt;
+ }
+
+ public byte[] getIV() {
+ return iv;
+ }
+
+ public String getHashType() {
+ return hashType;
+ }
+
+ public int getArgon2Version() {
+ return argon2Version;
+ }
+
+ public int getArgon2Iterations() {
+ return argon2Iterations;
+ }
+
+ public int getArgon2Memory() {
+ return argon2Memory;
+ }
+
+ public int getArgon2Parallelism() {
+ return argon2Parallelism;
+ }
+
+ public String getEncryptionAlgorithm() {
+ return encryptionAlgorithm;
+ }
+
+ public int getEncryptionGCMTagLength() {
+ return encryptionGCMTagLength;
+ }
+
+ public int getEncryptionIVLength() {
+ return encryptionIVLength;
+ }
+
+ public int getEncryptionAESKeyLength() {
+ return encryptionAESKeyLength;
+ }
+
+ public int getEncryptionSaltLength() {
+ return encryptionSaltLength;
+ }
+
+ public boolean isValid() {
+ return hashType != null
+ && argon2Version > 0
+ && argon2Iterations > 0
+ && argon2Memory > 0
+ && argon2Parallelism > 0
+ && encryptionAlgorithm != null
+ && encryptionGCMTagLength > 0
+ && encryptionIVLength > 0
+ && encryptionAESKeyLength > 0
+ && encryptionSaltLength > 0;
+ }
+
+ public static CryptoParameters createNew() {
+ CryptoParameters params = new CryptoParameters();
+ byte[] salt = Crypto.generateSalt(params);
+ byte[] iv = Crypto.generateIV(params);
+ params.init(salt, iv);
+ return params;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/crypto/CryptoResult.java b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoResult.java
new file mode 100644
index 0000000..7baac2a
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/crypto/CryptoResult.java
@@ -0,0 +1,20 @@
+package com.example.onetap_ssh.crypto;
+
+public class CryptoResult {
+
+ private final byte[] encrypted;
+ private final byte[] iv;
+
+ public CryptoResult(byte[] encrypted, byte[] iv) {
+ this.encrypted = encrypted;
+ this.iv = iv;
+ }
+
+ public byte[] getEncrypted() {
+ return encrypted;
+ }
+
+ public byte[] getIV() {
+ return iv;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/fragment/NamedFragment.java b/app/src/main/java/com/example/onetap_ssh/fragment/NamedFragment.java
new file mode 100644
index 0000000..499de0a
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/fragment/NamedFragment.java
@@ -0,0 +1,9 @@
+package com.example.onetap_ssh.fragment;
+
+import androidx.fragment.app.Fragment;
+
+public abstract class NamedFragment extends Fragment {
+
+ public abstract String getName();
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/fragment/SettingsFragment.java b/app/src/main/java/com/example/onetap_ssh/fragment/SettingsFragment.java
new file mode 100644
index 0000000..4783e90
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/fragment/SettingsFragment.java
@@ -0,0 +1,254 @@
+package com.example.onetap_ssh.fragment;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.example.onetap_ssh.MainActivity;
+import com.example.onetap_ssh.R;
+import com.example.onetap_ssh.backup.BackupData;
+import com.example.onetap_ssh.backup.BackupUtil;
+import com.example.onetap_ssh.crypto.BiometricKey;
+import com.example.onetap_ssh.crypto.Crypto;
+import com.example.onetap_ssh.crypto.CryptoException;
+import com.example.onetap_ssh.crypto.CryptoParameters;
+import com.example.onetap_ssh.databinding.DialogManageIconPacksBinding;
+import com.example.onetap_ssh.databinding.FragmentSettingsBinding;
+import com.example.onetap_ssh.icon.IconPack;
+import com.example.onetap_ssh.icon.IconPackListAdapter;
+import com.example.onetap_ssh.icon.IconUtil;
+import com.example.onetap_ssh.util.AppLocale;
+import com.example.onetap_ssh.util.Appearance;
+import com.example.onetap_ssh.util.BackupException;
+import com.example.onetap_ssh.util.BiometricUtil;
+import com.example.onetap_ssh.util.DialogUtil;
+
+import com.example.onetap_ssh.util.SettingsUtil;
+import com.example.onetap_ssh.util.StyledDialogBuilder;
+import com.example.onetap_ssh.util.Theme;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.crypto.SecretKey;
+
+public class SettingsFragment extends NamedFragment {
+
+ private FragmentSettingsBinding binding;
+
+ @Override
+ public String getName() {
+ return requireActivity().getString(R.string.fragment_settings);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ binding = FragmentSettingsBinding.inflate(inflater);
+
+
+ String[] localeNames = new String[AppLocale.values().length];
+ for(int i = 0; i < localeNames.length; i++) {
+ localeNames[i] = AppLocale.values()[i].getName(requireContext());
+ }
+
+ binding.settingsLanguage.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, localeNames));
+ binding.settingsLanguage.setSelection(Arrays.asList(AppLocale.values()).indexOf(SettingsUtil.getLocale(requireContext())));
+ binding.settingsLanguage.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ AppLocale locale = AppLocale.values()[position];
+ if(locale.equals(SettingsUtil.getLocale(requireContext()))) return;
+
+ SettingsUtil.setLocale(requireContext(), locale);
+ requireActivity().recreate();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {
+
+ }
+ });
+
+ binding.settingsEnableEncryption.setChecked(SettingsUtil.isDatabaseEncrypted(requireContext()));
+ binding.settingsEnableEncryption.setOnCheckedChangeListener((view, checked) -> {
+ if(checked) {
+ if(SettingsUtil.isDatabaseEncrypted(requireContext())) return;
+
+ DialogUtil.showSetPasswordDialog(requireContext(), password -> {
+ CryptoParameters params = CryptoParameters.createNew();
+ Log.d("Crypto", "Created new crypto params");
+
+
+ }, () -> view.setChecked(false));
+ }else {
+ if(!SettingsUtil.isDatabaseEncrypted(requireContext())) return;
+
+ DialogUtil.showYesNo(requireContext(), R.string.disable_encryption_title, R.string.disable_encryption_message, () -> {
+
+ }, () -> view.setChecked(true));
+ }
+ });
+
+ boolean biometricSupported = BiometricUtil.isSupported(requireContext());
+ binding.settingsBiometricLock.setEnabled(SettingsUtil.isDatabaseEncrypted(requireContext()) && biometricSupported);
+ binding.settingsBiometricLock.setChecked(SettingsUtil.isBiometricEncryption(requireContext()));
+
+ if(!biometricSupported) {
+ binding.settingsBiometricLockInfo.setVisibility(View.VISIBLE);
+ binding.settingsBiometricLockInfo.setText(R.string.biometric_encryption_unavailable);
+ }
+
+ if(biometricSupported) {
+ binding.settingsBiometricLock.setOnCheckedChangeListener((view, checked) -> {
+ if(checked) {
+
+ }else {
+
+ SettingsUtil.disableBiometricEncryption(requireContext());
+ }
+ });
+ }
+
+ binding.settingsScreenSecurity.setChecked(SettingsUtil.isScreenSecurity(requireContext()));
+ binding.settingsScreenSecurity.setOnCheckedChangeListener((view, checked) -> {
+ SettingsUtil.setScreenSecurity(requireContext(), checked);
+ requireActivity().recreate();
+ });
+
+ binding.settingsHideCodes.setChecked(SettingsUtil.isHideCodes(requireContext()));
+ binding.settingsHideCodes.setOnCheckedChangeListener((view, checked) -> SettingsUtil.setHideCodes(requireContext(), checked));
+
+ binding.settingsShowImages.setChecked(SettingsUtil.isShowImages(requireContext()));
+ binding.settingsShowImages.setOnCheckedChangeListener((view, checked) -> SettingsUtil.setShowImages(requireContext(), checked));
+
+ String[] themeNames = new String[Theme.values().length];
+ for(int i = 0; i < Theme.values().length; i++) {
+ themeNames[i] = getResources().getString(Theme.values()[i].getName());
+ }
+
+ binding.settingsEnableIntroVideo.setChecked(SettingsUtil.isIntroVideoEnabled(requireContext()));
+ binding.settingsEnableIntroVideo.setOnCheckedChangeListener((view, checked) -> SettingsUtil.setEnableIntroVideo(requireContext(), checked));
+
+ binding.settingsEnableThemedBackground.setChecked(SettingsUtil.isThemedBackgroundEnabled(requireContext()));
+ binding.settingsEnableThemedBackground.setOnCheckedChangeListener((view, checked) -> {
+ SettingsUtil.setEnableThemedBackground(requireContext(), checked);
+ requireActivity().recreate();
+ });
+
+ binding.settingsTheme.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, themeNames));
+ binding.settingsTheme.setSelection(SettingsUtil.getTheme(requireContext()).ordinal());
+ binding.settingsTheme.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ Theme theme = Theme.values()[position];
+ if(theme == SettingsUtil.getTheme(requireContext())) return;
+
+ SettingsUtil.setTheme(requireContext(), theme);
+ requireActivity().recreate();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+
+ binding.settingsEnableMinimalistTheme.setChecked(SettingsUtil.isMinimalistThemeEnabled(requireContext()));
+ binding.settingsEnableMinimalistTheme.setOnCheckedChangeListener((view, checked) -> {
+ SettingsUtil.setEnableMinimalistTheme(requireContext(), checked);
+ requireActivity().recreate();
+ });
+
+ String[] appearanceNames = new String[Appearance.values().length];
+ for(int i = 0; i < Appearance.values().length; i++) {
+ appearanceNames[i] = getResources().getString(Appearance.values()[i].getName());
+ }
+
+ binding.settingsAppearance.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, appearanceNames));
+ binding.settingsAppearance.setSelection(SettingsUtil.getAppearance(requireContext()).ordinal());
+ binding.settingsAppearance.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView> parent, View view, int position, long id) {
+ Appearance appearance = Appearance.values()[position];
+ if(appearance == SettingsUtil.getAppearance(requireContext())) return;
+
+ SettingsUtil.setAppearance(requireContext(), appearance);
+ requireActivity().recreate();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView> parent) {}
+ });
+
+ binding.settingsCreateBackup.setOnClickListener(view -> {
+ new StyledDialogBuilder(requireContext())
+ .setTitle(R.string.create_backup)
+ .setItems(R.array.backup_create, (d, which) -> {
+ switch(which) {
+ case 0:
+ break;
+ case 1:
+ break;
+ }
+ })
+ .setNegativeButton(R.string.cancel, (d, which) -> {})
+ .show();
+ });
+
+ binding.settingsManageIconPacks.setOnClickListener(v -> {
+ List brokenPacks = new ArrayList<>();
+ List packs = IconUtil.loadAllIconPacks(requireContext(), brokenPacks::add);
+
+ if(!brokenPacks.isEmpty()) {
+ DialogUtil.showYesNo(requireContext(), R.string.broken_icon_packs_title, R.string.broken_icon_packs_message, () -> {
+ for(String pack : brokenPacks) {
+ IconUtil.removeIconPack(requireContext(), pack);
+ }
+ }, null);
+ }
+
+ if(packs.isEmpty()) {
+ Toast.makeText(requireContext(), R.string.no_icon_packs_installed, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ DialogManageIconPacksBinding binding = DialogManageIconPacksBinding.inflate(getLayoutInflater());
+
+ binding.manageIconPacksList.setLayoutManager(new LinearLayoutManager(requireContext()));
+ binding.manageIconPacksList.setAdapter(new IconPackListAdapter(requireContext(), IconUtil.loadAllIconPacks(requireContext())));
+
+ new StyledDialogBuilder(requireContext())
+ .setTitle(R.string.manage_icon_packs_title)
+ .setView(binding.getRoot())
+ .setPositiveButton(R.string.ok, (d, which) -> {})
+ .show();
+ });
+
+ return binding.getRoot();
+ }
+
+ private void createBackup(SecretKey key, CryptoParameters parameters) {}
+
+ private void loadBackup(Uri uri) {
+
+ }
+
+ private void loadBackup(Uri uri, SecretKey key, CryptoParameters parameters) throws BackupException, CryptoException {}
+
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ this.binding = null;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/Icon.java b/app/src/main/java/com/example/onetap_ssh/icon/Icon.java
new file mode 100644
index 0000000..6c0dc5c
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/Icon.java
@@ -0,0 +1,21 @@
+package com.example.onetap_ssh.icon;
+
+public class Icon {
+
+ private final IconMetadata metadata;
+ private final byte[] bytes;
+
+ public Icon(IconMetadata metadata, byte[] bytes) {
+ this.metadata = metadata;
+ this.bytes = bytes;
+ }
+
+ public IconMetadata getMetadata() {
+ return metadata;
+ }
+
+ public byte[] getBytes() {
+ return bytes;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconListAdapter.java b/app/src/main/java/com/example/onetap_ssh/icon/IconListAdapter.java
new file mode 100644
index 0000000..53897b8
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconListAdapter.java
@@ -0,0 +1,121 @@
+package com.example.onetap_ssh.icon;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListView;
+
+import androidx.core.util.Consumer;
+
+import com.example.onetap_ssh.R;
+import com.example.onetap_ssh.databinding.IconListCategoryBinding;
+import com.example.onetap_ssh.databinding.IconListIconBinding;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class IconListAdapter extends BaseExpandableListAdapter implements ExpandableListView.OnChildClickListener {
+
+ private final Context context;
+
+ private final Map> icons;
+ private final List categories;
+
+ private Map> filteredIcons;
+
+ private final Consumer selected;
+
+ public IconListAdapter(Context context, Map> icons, Consumer selected) {
+ this.context = context;
+ this.icons = icons;
+ this.categories = new ArrayList<>(icons.keySet());
+ this.filteredIcons = new TreeMap<>(icons);
+ this.selected = selected;
+ }
+
+ public void filter(String query) {
+ Map> filtered = new TreeMap<>();
+ for(String cat : categories) {
+ List f = new ArrayList<>();
+ for(Icon i : icons.get(cat)) {
+ if(i.getMetadata().getName().toLowerCase().contains(query.toLowerCase())) {
+ f.add(i);
+ }
+ }
+ filtered.put(cat, f);
+ }
+
+ filteredIcons = filtered;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public int getGroupCount() {
+ return categories.size();
+ }
+
+ @Override
+ public int getChildrenCount(int groupPosition) {
+ return filteredIcons.get(categories.get(groupPosition)).size();
+ }
+
+ @Override
+ public String getGroup(int groupPosition) {
+ return categories.get(groupPosition);
+ }
+
+ @Override
+ public Icon getChild(int groupPosition, int childPosition) {
+ return filteredIcons.get(categories.get(groupPosition)).get(childPosition);
+ }
+
+ @Override
+ public long getGroupId(int groupPosition) {
+ return groupPosition;
+ }
+
+ @Override
+ public long getChildId(int groupPosition, int childPosition) {
+ return childPosition;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return false;
+ }
+
+ @Override
+ public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
+ IconListCategoryBinding binding = IconListCategoryBinding.inflate(LayoutInflater.from(context));
+ binding.getRoot().setText(getGroup(groupPosition));
+ return binding.getRoot();
+ }
+
+ @Override
+ public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
+ IconListIconBinding binding = IconListIconBinding.inflate(LayoutInflater.from(context));
+
+ Icon icon = getChild(groupPosition, childPosition);
+ binding.iconListIconImage.setImageResource(R.drawable.codeguard_white);
+ IconUtil.loadImage(binding.iconListIconImage, icon.getBytes(), v -> v.setImageDrawable(new ColorDrawable(Color.TRANSPARENT)));
+ binding.iconListIconText.setText(icon.getMetadata().getName());
+ return binding.getRoot();
+ }
+
+ @Override
+ public boolean isChildSelectable(int groupPosition, int childPosition) {
+ return true;
+ }
+
+ @Override
+ public boolean onChildClick(ExpandableListView parent, View v, int groupPosition, int childPosition, long id) {
+ selected.accept(getChild(groupPosition, childPosition));
+ return true;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconMetadata.java b/app/src/main/java/com/example/onetap_ssh/icon/IconMetadata.java
new file mode 100644
index 0000000..2773d08
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconMetadata.java
@@ -0,0 +1,38 @@
+package com.example.onetap_ssh.icon;
+
+import java.io.File;
+
+public class IconMetadata {
+
+ private String name;
+ private String filename;
+ private String category;
+ private String[] issuer;
+
+ private IconMetadata() {}
+
+ public String getName() {
+ if(name != null) return name;
+
+ String fileName = new File(filename).getName();
+ int i = fileName.lastIndexOf('.');
+ return i == -1 ? fileName : fileName.substring(0, i);
+ }
+
+ public String getFilename() {
+ return filename;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public String[] getIssuer() {
+ return issuer;
+ }
+
+ public boolean validate() {
+ return filename != null && category != null && issuer != null;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconPack.java b/app/src/main/java/com/example/onetap_ssh/icon/IconPack.java
new file mode 100644
index 0000000..61fe053
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconPack.java
@@ -0,0 +1,33 @@
+package com.example.onetap_ssh.icon;
+
+public class IconPack {
+
+ private final IconPackMetadata metadata;
+ private final Icon[] icons;
+
+ public IconPack(IconPackMetadata metadata, Icon[] icons) {
+ this.metadata = metadata;
+ this.icons = icons;
+ }
+
+ public IconPackMetadata getMetadata() {
+ return metadata;
+ }
+
+ public Icon[] getIcons() {
+ return icons;
+ }
+
+ public Icon findIconForIssuer(String issuer) {
+ for(Icon icon : icons) {
+ for(String i : icon.getMetadata().getIssuer()) {
+ if(issuer.equalsIgnoreCase(i)) {
+ return icon;
+ }
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconPackException.java b/app/src/main/java/com/example/onetap_ssh/icon/IconPackException.java
new file mode 100644
index 0000000..24232bc
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconPackException.java
@@ -0,0 +1,18 @@
+package com.example.onetap_ssh.icon;
+
+public class IconPackException extends Exception {
+ public IconPackException() {
+ }
+
+ public IconPackException(String message) {
+ super(message);
+ }
+
+ public IconPackException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public IconPackException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconPackItem.java b/app/src/main/java/com/example/onetap_ssh/icon/IconPackItem.java
new file mode 100644
index 0000000..9775bb4
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconPackItem.java
@@ -0,0 +1,31 @@
+package com.example.onetap_ssh.icon;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.onetap_ssh.databinding.DialogManageIconPacksItemBinding;
+
+public class IconPackItem extends RecyclerView.ViewHolder {
+
+ private final DialogManageIconPacksItemBinding binding;
+
+ private IconPack pack;
+
+ public IconPackItem(@NonNull DialogManageIconPacksItemBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ }
+
+ public DialogManageIconPacksItemBinding getBinding() {
+ return binding;
+ }
+
+ public void setPack(IconPack pack) {
+ this.pack = pack;
+ }
+
+ public IconPack getPack() {
+ return pack;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconPackListAdapter.java b/app/src/main/java/com/example/onetap_ssh/icon/IconPackListAdapter.java
new file mode 100644
index 0000000..36b31e1
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconPackListAdapter.java
@@ -0,0 +1,58 @@
+package com.example.onetap_ssh.icon;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.onetap_ssh.R;
+import com.example.onetap_ssh.databinding.DialogManageIconPacksItemBinding;
+import com.example.onetap_ssh.util.DialogUtil;
+
+import java.util.List;
+
+public class IconPackListAdapter extends RecyclerView.Adapter {
+
+ private final Context context;
+
+ private final LayoutInflater inflater;
+
+ private final List packs;
+
+ public IconPackListAdapter(Context context, List packs) {
+ this.context = context;
+ this.inflater = LayoutInflater.from(context);
+ this.packs = packs;
+ }
+
+ @NonNull
+ @Override
+ public IconPackItem onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new IconPackItem(DialogManageIconPacksItemBinding.inflate(inflater, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull IconPackItem holder, int position) {
+ IconPack pack = packs.get(position);
+ holder.setPack(pack);
+
+ holder.getBinding().iconPackName.setText(pack.getMetadata().getName());
+
+ holder.getBinding().iconPackDelete.setOnClickListener(view -> {
+ DialogUtil.showYesNo(context, R.string.delete_pack_title, R.string.delete_pack_message, () -> {
+ IconUtil.removeIconPack(context, pack.getMetadata().getUuid());
+
+ int idx = packs.indexOf(pack);
+ packs.remove(idx);
+ notifyItemRemoved(idx);
+ }, null);
+ });
+ }
+
+ @Override
+ public int getItemCount() {
+ return packs.size();
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconPackMetadata.java b/app/src/main/java/com/example/onetap_ssh/icon/IconPackMetadata.java
new file mode 100644
index 0000000..78e617e
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconPackMetadata.java
@@ -0,0 +1,46 @@
+package com.example.onetap_ssh.icon;
+
+public class IconPackMetadata {
+
+ private String uuid;
+ private String name;
+ private int version;
+ private IconMetadata[] icons;
+
+ private IconPackMetadata() {}
+
+ public void setUuid(String uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public IconMetadata[] getIcons() {
+ return icons;
+ }
+
+ public boolean validate() {
+ if(uuid == null || name == null || icons == null) return false;
+
+ for(IconMetadata i : icons) {
+ if(!i.validate()) return false;
+ }
+
+ return true;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/icon/IconUtil.java b/app/src/main/java/com/example/onetap_ssh/icon/IconUtil.java
new file mode 100644
index 0000000..5f95a37
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/icon/IconUtil.java
@@ -0,0 +1,382 @@
+package com.example.onetap_ssh.icon;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.util.Base64;
+
+import androidx.core.util.Consumer;
+
+import com.caverock.androidsvg.SVG;
+import com.caverock.androidsvg.SVGImageView;
+import com.caverock.androidsvg.SVGParseException;
+import com.example.onetap_ssh.util.IOUtil;
+import com.example.onetap_ssh.util.SettingsUtil;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.ByteArrayInputStream;
+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.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+public class IconUtil {
+
+ private static final int ICON_SIZE = 128;
+
+ // Source: https://sashamaps.net/docs/resources/20-colors/
+ private static final List DISTINCT_COLORS = Collections.unmodifiableList(Arrays.asList(
+ Color.parseColor("#e6194B"), // Red
+ Color.parseColor("#f58231"), // Orange
+// Color.parseColor("#ffe119"), // Yellow
+// Color.parseColor("#bfef45"), // Lime
+ Color.parseColor("#3cb44b"), // Green
+// Color.parseColor("#42d4f4"), // Cyan
+ Color.parseColor("#4363d8"), // Blue
+ Color.parseColor("#911eb4"), // Purple
+ Color.parseColor("#f032e6"), // Magenta
+// Color.parseColor("#a9a9a9"), // Grey
+ Color.parseColor("#800000"), // Maroon
+ Color.parseColor("#9A6324"), // Brown
+ Color.parseColor("#808000"), // Olive
+ Color.parseColor("#469990"), // Teal
+ Color.parseColor("#000075") // Navy
+// Color.parseColor("#000000"), // Black
+// Color.parseColor("#fabed4"), // Pink
+// Color.parseColor("#ffd8b1"), // Apricot
+// Color.parseColor("#fffac8"), // Beige
+// Color.parseColor("#aaffc3"), // Mint
+// Color.parseColor("#dcbeff"), // Lavender
+// Color.parseColor("#ffffff") // White
+ ));
+
+ private static final Map loadedPacks = new HashMap<>();
+
+ private static File getIconPacksDir(Context context) {
+ File iconPacksDir = new File(context.getFilesDir(), "iconpacks");
+ if(!iconPacksDir.exists()) {
+ iconPacksDir.mkdirs();
+ }
+ return iconPacksDir;
+ }
+
+ public static void importIconPack(Context context, Uri uri) throws IconPackException {
+ IconPackMetadata meta = loadPackMetadata(context, uri);
+
+ File iconPackFile = new File(getIconPacksDir(context), meta.getUuid());
+
+ try {
+ if (!iconPackFile.exists()) {
+ iconPackFile.createNewFile();
+ }
+
+ try (OutputStream out = new BufferedOutputStream(new FileOutputStream(iconPackFile));
+ InputStream in = context.getContentResolver().openInputStream(uri)) {
+ if(in == null) throw new IconPackException("Failed to read icon pack");
+ byte[] bytes = IOUtil.readBytes(in);
+ out.write(bytes);
+ }
+ }catch(IOException e) {
+ throw new IconPackException("Failed to import icon pack", e);
+ }
+ }
+
+ public static void importIconPack(Context context, Uri uri, String newName, String newUUID) throws IconPackException {
+ IconPackMetadata meta = loadPackMetadata(context, uri);
+ meta.setName(newName);
+ meta.setUuid(newUUID);
+
+ File iconPackFile = new File(getIconPacksDir(context), meta.getUuid());
+
+ try {
+ if (!iconPackFile.exists()) {
+ iconPackFile.createNewFile();
+ }
+
+ try (InputStream in = context.getContentResolver().openInputStream(uri)) {
+ if(in == null) throw new IconPackException("Failed to read icon pack");
+ writeRenamedPack(in, iconPackFile, meta);
+ }
+ }catch(IOException e) {
+ throw new IconPackException("Failed to import icon pack", e);
+ }
+ }
+
+ public static void renameIconPack(Context context, IconPack pack, String newName, String newUUID) throws IconPackException {
+ File packFile = new File(getIconPacksDir(context), pack.getMetadata().getUuid());
+ if(!packFile.exists()) return;
+
+ File newPackFile = new File(getIconPacksDir(context), newUUID);
+
+ String oldName = pack.getMetadata().getName();
+ String oldUUID = pack.getMetadata().getUuid();
+
+
+ loadedPacks.remove(oldUUID);
+
+ pack.getMetadata().setName(newName);
+ pack.getMetadata().setUuid(newUUID);
+
+ try {
+ writeRenamedPack(new BufferedInputStream(new FileInputStream(packFile)), newPackFile, pack.getMetadata());
+ packFile.delete();
+ }catch(IconPackException e) {
+ pack.getMetadata().setName(oldName);
+ pack.getMetadata().setUuid(oldUUID);
+ throw e;
+ } catch (FileNotFoundException e) {
+ throw new IconPackException(e);
+ }
+ }
+
+ public static void removeIconPack(Context context, String uuid) {
+ File packFile = new File(getIconPacksDir(context), uuid);
+ packFile.delete();
+ loadedPacks.remove(uuid);
+ }
+
+ public static IconPackMetadata loadPackMetadata(Context context, Uri uri) throws IconPackException {
+ try(InputStream in = context.getContentResolver().openInputStream(uri)) {
+ if(in == null) throw new IconPackException("Failed to read icon pack");
+ try(ZipInputStream zIn = new ZipInputStream(in)) {
+ ZipEntry en;
+ while((en = zIn.getNextEntry()) != null) {
+ if(en.isDirectory()) continue;
+
+ if(en.getName().equals("pack.json")) {
+ byte[] entryBytes = readEntry(zIn, en);
+ return SettingsUtil.GSON.fromJson(new String(entryBytes, StandardCharsets.UTF_8), IconPackMetadata.class); // TODO: validate metadata
+ }
+ }
+ }
+ }catch(IOException e) {
+ throw new IconPackException("Failed to read icon pack", e);
+ }
+
+ throw new IconPackException("No pack.json");
+ }
+
+ private static void writeRenamedPack(InputStream oldFile, File newFile, IconPackMetadata meta) throws IconPackException {
+ try(ZipInputStream in = new ZipInputStream(oldFile);
+ ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(newFile)))) {
+ ZipEntry en;
+ while((en = in.getNextEntry()) != null) {
+ if(en.isDirectory()) continue;
+
+ byte[] entryBytes = readEntry(in, en);
+ if(en.getName().equals("pack.json")) {
+ out.putNextEntry(new ZipEntry("pack.json"));
+ out.write(SettingsUtil.GSON.toJson(meta).getBytes(StandardCharsets.UTF_8));
+ continue;
+ }
+
+ out.putNextEntry(new ZipEntry(en.getName()));
+ out.write(entryBytes);
+ }
+ }catch(IOException e) {
+ throw new IconPackException(e);
+ }
+ }
+
+ public static Map> loadAllIcons(Context context) {
+ List packs = loadAllIconPacks(context);
+
+ Map> icons = new TreeMap<>();
+ for(IconPack pack : packs) {
+ for(Icon i : pack.getIcons()) {
+ String category = i.getMetadata().getCategory();
+ List is = icons.get(category);
+ if(is != null) {
+ is.add(i);
+ }else {
+ is = new ArrayList<>();
+ is.add(i);
+ icons.put(category, is);
+ }
+ }
+ }
+
+ return icons;
+ }
+
+ public static List loadAllIconPacks(Context context, Consumer brokenPack) {
+ File iconPacksDir = getIconPacksDir(context);
+
+ String[] packIDs = iconPacksDir.list();
+ if(packIDs == null) return Collections.emptyList();
+
+ List packs = new ArrayList<>();
+ for(String pack : packIDs) {
+ try {
+ IconPack p = loadIconPack(context, pack);
+ if(p == null) continue;
+ if(!p.getMetadata().getUuid().equals(pack)) throw new IconPackException("Invalid metadata");
+ packs.add(p);
+ }catch(IconPackException e) {
+ e.printStackTrace();
+ if(brokenPack != null) brokenPack.accept(pack);
+ //DialogUtil.showErrorDialog(context, "An icon pack failed to load", e);
+ }
+ }
+
+ return packs;
+ }
+
+ public static List loadAllIconPacks(Context context) {
+ return loadAllIconPacks(context, null);
+ }
+
+ public static IconPack loadIconPack(Context context, String uuid) throws IconPackException {
+ if(loadedPacks.containsKey(uuid)) return loadedPacks.get(uuid);
+
+ IconPack p = loadIconPack(new File(getIconPacksDir(context), uuid));
+ if(p == null) return null;
+
+ loadedPacks.put(uuid, p);
+ return p;
+ }
+
+ private static IconPack loadIconPack(File file) throws IconPackException {
+ if(!file.exists()) return null;
+
+ try(ZipInputStream in = new ZipInputStream(new BufferedInputStream(new FileInputStream(file)))) {
+ IconPackMetadata metadata = null;
+ Map files = new HashMap<>();
+
+ ZipEntry en;
+ while((en = in.getNextEntry()) != null) {
+ if(en.isDirectory()) continue;
+
+ byte[] entryBytes = readEntry(in, en);
+
+ if(en.getName().equals("pack.json")) {
+ metadata = SettingsUtil.GSON.fromJson(new String(entryBytes, StandardCharsets.UTF_8), IconPackMetadata.class); // TODO: validate metadata
+ }else {
+ files.put(en.getName(), entryBytes);
+ }
+ }
+
+ if(metadata == null) throw new IconPackException("Missing icon pack metadata");
+
+ Icon[] icons = new Icon[metadata.getIcons().length];
+ int iconCount = 0;
+ for(IconMetadata m : metadata.getIcons()) {
+ byte[] bytes = files.get(m.getFilename());
+ if(bytes == null) continue;
+ icons[iconCount++] = new Icon(m, bytes);
+ }
+
+ Icon[] workingIcons = new Icon[iconCount];
+ System.arraycopy(icons, 0, workingIcons, 0, iconCount);
+
+ return new IconPack(metadata, workingIcons);
+ }catch(IOException e) {
+ throw new IconPackException("Failed to read icon pack", e);
+ }
+ }
+
+ private static byte[] readEntry(ZipInputStream in, ZipEntry en) throws IOException {
+ if (en.getSize() > Integer.MAX_VALUE) {
+ throw new IOException("Invalid ZIP entry");
+ }
+
+ return IOUtil.readBytes(in);
+ }
+
+ public static Bitmap generateCodeImage(String issuer, String name) {
+ if(issuer == null || issuer.isEmpty()) issuer = name;
+ if(issuer == null || issuer.isEmpty()) issuer = "?";
+
+ Bitmap b = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888);
+ Canvas c = new Canvas(b);
+
+ Paint p = new Paint();
+ p.setColor(DISTINCT_COLORS.get(Math.abs(issuer.hashCode()) % DISTINCT_COLORS.size()));
+ p.setStyle(Paint.Style.FILL);
+ c.drawCircle(ICON_SIZE / 2, ICON_SIZE / 2, ICON_SIZE / 2, p);
+
+ p.setColor(Color.WHITE);
+ p.setAntiAlias(true);
+ p.setTextSize(64);
+
+ String text = issuer.substring(0, 1);
+ Rect r = new Rect();
+ p.getTextBounds(text, 0, text.length(), r);
+ c.drawText(text, ICON_SIZE / 2 - r.exactCenterX(), ICON_SIZE / 2 - r.exactCenterY(), p);
+
+ return b;
+ }
+
+ public static void loadEffectiveImage(Context context, String imageData, String issuer, String name, SVGImageView view, Consumer setOTPImage) {
+
+ }
+
+ public static void loadImage(SVGImageView view, byte[] imageBytes, Consumer fallback) {
+ if(imageBytes == null) {
+ if(fallback != null) fallback.accept(view);
+ return;
+ }
+
+ Bitmap bm = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length);
+ if(bm != null) {
+ view.setImageBitmap(bm);
+ }else {
+ try {
+ SVG svg = SVG.getFromInputStream(new ByteArrayInputStream(imageBytes));
+ view.setSVG(svg);
+ }catch(SVGParseException e) {
+ if(fallback != null) fallback.accept(view);
+ }
+ }
+ }
+
+
+ public static Bitmap cutToIcon(Bitmap bitmap) {
+ Bitmap b = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888);
+ Canvas c = new Canvas(b);
+
+ double sourceRatio = bitmap.getWidth() / (double) bitmap.getHeight();
+ int newWidth, newHeight, offsetX, offsetY;
+ if(sourceRatio < 1) {
+ newWidth = ICON_SIZE;
+ newHeight = (int) (newWidth / sourceRatio);
+ offsetX = 0;
+ offsetY = (ICON_SIZE - newHeight) / 2;
+ }else {
+ newHeight = ICON_SIZE;
+ newWidth = (int) (newHeight * sourceRatio);
+ offsetX = (ICON_SIZE - newWidth) / 2;
+ offsetY = 0;
+ }
+
+ Paint p = new Paint();
+ Path path = new Path();
+ path.addCircle(ICON_SIZE / 2, ICON_SIZE / 2, ICON_SIZE / 2, Path.Direction.CW);
+ c.clipPath(path);
+ c.drawBitmap(bitmap, null, new Rect(offsetX, offsetY, offsetX + newWidth, offsetY + newHeight), p);
+
+ return b;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/unlock/UnlockActivity.java b/app/src/main/java/com/example/onetap_ssh/unlock/UnlockActivity.java
new file mode 100644
index 0000000..afff6b7
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/unlock/UnlockActivity.java
@@ -0,0 +1,81 @@
+package com.example.onetap_ssh.unlock;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import com.example.onetap_ssh.BaseActivity;
+import com.example.onetap_ssh.MainActivity;
+import com.example.onetap_ssh.R;
+import com.example.onetap_ssh.crypto.BiometricKey;
+import com.example.onetap_ssh.crypto.Crypto;
+import com.example.onetap_ssh.crypto.CryptoException;
+import com.example.onetap_ssh.databinding.ActivityUnlockBinding;
+import com.example.onetap_ssh.util.BiometricUtil;
+import com.example.onetap_ssh.util.DialogUtil;
+import com.example.onetap_ssh.util.SettingsUtil;
+import com.example.onetap_ssh.util.ThemeUtil;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+public class UnlockActivity extends BaseActivity {
+
+ private ActivityUnlockBinding binding;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if(!SettingsUtil.isDatabaseEncrypted(this)) {
+ success();
+ return;
+ }
+
+ binding = ActivityUnlockBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+ ThemeUtil.loadBackground(this);
+
+ if(SettingsUtil.isBiometricEncryption(this) && BiometricUtil.isSupported(this)) {
+ Runnable onSuccess = () -> {
+
+ };
+
+ binding.unlockBiometrics.setOnClickListener(view -> BiometricUtil.promptBiometricAuth(this, onSuccess, () -> {}));
+ BiometricUtil.promptBiometricAuth(this, onSuccess, () -> {});
+ }else {
+ binding.unlockBiometrics.setVisibility(View.GONE);
+ }
+
+ binding.unlockButton.setOnClickListener(view -> {
+ if(binding.unlockPassword.getText().length() == 0) {
+ DialogUtil.showErrorDialog(this, getString(R.string.error_unlock_no_password));
+ return;
+ }
+
+ String password = binding.unlockPassword.getText().toString();
+
+ });
+ }
+
+ private void success() {
+ if(getIntent() != null && getIntent().hasExtra("contract")) {
+ setResult(RESULT_OK);
+ finish();
+ return;
+ }
+
+ Intent m = new Intent(getApplicationContext(), MainActivity.class);
+ m.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ startActivity(m);
+ finish();
+ }
+
+ private void failure() {
+ setResult(RESULT_CANCELED);
+ finish();
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/unlock/UnlockContract.java b/app/src/main/java/com/example/onetap_ssh/unlock/UnlockContract.java
new file mode 100644
index 0000000..3308e2f
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/unlock/UnlockContract.java
@@ -0,0 +1,22 @@
+package com.example.onetap_ssh.unlock;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.activity.result.contract.ActivityResultContract;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class UnlockContract extends ActivityResultContract {
+ @NonNull
+ @Override
+ public Intent createIntent(@NonNull Context context, Void unused) {
+ return new Intent(context, UnlockActivity.class).putExtra("contract", true);
+ }
+
+ @Override
+ public Boolean parseResult(int i, @Nullable Intent intent) {
+ return i == Activity.RESULT_OK;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/AppLocale.java b/app/src/main/java/com/example/onetap_ssh/util/AppLocale.java
new file mode 100644
index 0000000..410a1ac
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/AppLocale.java
@@ -0,0 +1,45 @@
+package com.example.onetap_ssh.util;
+
+import android.content.Context;
+
+import androidx.annotation.StringRes;
+
+import com.example.onetap_ssh.R;
+
+import java.util.Locale;
+
+public enum AppLocale {
+
+ SYSTEM_DEFAULT(R.string.locale_system_default),
+ ENGLISH(Locale.ENGLISH),
+ GERMAN(Locale.GERMAN),
+ FRENCH(Locale.FRENCH),
+ POLISH(new Locale("pl")),
+
+ UKRAINIAN(new Locale("uk")),
+ ;
+
+ @StringRes
+ private final int name;
+
+ private final Locale locale;
+
+ AppLocale(@StringRes int name) {
+ this.name = name;
+ this.locale = null;
+ }
+
+ AppLocale(Locale locale) {
+ this.name = 0;
+ this.locale = locale;
+ }
+
+ public String getName(Context context) {
+ return locale == null ? context.getString(name) : locale.getDisplayName(locale);
+ }
+
+ public Locale getLocale() {
+ return locale;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/Appearance.java b/app/src/main/java/com/example/onetap_ssh/util/Appearance.java
new file mode 100644
index 0000000..efabcc6
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/Appearance.java
@@ -0,0 +1,35 @@
+package com.example.onetap_ssh.util;
+
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatDelegate;
+
+import com.example.onetap_ssh.R;
+
+public enum Appearance {
+
+ FOLLOW_SYSTEM(R.string.appearance_follow_system, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
+ LIGHT(R.string.appearance_light, AppCompatDelegate.MODE_NIGHT_NO),
+ DARK(R.string.appearance_dark, AppCompatDelegate.MODE_NIGHT_YES),
+ ;
+
+ @StringRes
+ private final int name;
+
+ @AppCompatDelegate.NightMode
+ private final int value;
+
+ Appearance(@StringRes int name, @AppCompatDelegate.NightMode int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @StringRes
+ public int getName() {
+ return name;
+ }
+
+ @AppCompatDelegate.NightMode
+ public int getValue() {
+ return value;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/BackupException.java b/app/src/main/java/com/example/onetap_ssh/util/BackupException.java
new file mode 100644
index 0000000..661460d
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/BackupException.java
@@ -0,0 +1,18 @@
+package com.example.onetap_ssh.util;
+
+public class BackupException extends Exception {
+ public BackupException() {
+ }
+
+ public BackupException(String message) {
+ super(message);
+ }
+
+ public BackupException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public BackupException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/BiometricUtil.java b/app/src/main/java/com/example/onetap_ssh/util/BiometricUtil.java
new file mode 100644
index 0000000..cef2db7
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/BiometricUtil.java
@@ -0,0 +1,53 @@
+package com.example.onetap_ssh.util;
+
+import static androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG;
+import static androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
+
+import android.content.Context;
+import android.os.Build;
+
+import androidx.annotation.NonNull;
+import androidx.biometric.BiometricManager;
+import androidx.biometric.BiometricPrompt;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.FragmentActivity;
+
+import com.example.onetap_ssh.R;
+
+import java.util.concurrent.Executor;
+
+public class BiometricUtil {
+
+ public static boolean isSupported(Context context) {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG | DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS;
+ }
+
+ public static void promptBiometricAuth(FragmentActivity context, Runnable success, Runnable failure) {
+ if(!isSupported(context)) {
+ failure.run();
+ return;
+ }
+
+ Executor executor = ContextCompat.getMainExecutor(context);
+ BiometricPrompt prompt = new BiometricPrompt(context, executor, new BiometricPrompt.AuthenticationCallback() {
+ @Override
+ public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
+ failure.run();
+ }
+
+ @Override
+ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
+ success.run();
+ }
+ });
+
+ BiometricPrompt.PromptInfo info = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle(context.getString(R.string.app_name))
+ .setSubtitle(context.getString(R.string.biometric_lock_subtitle))
+ .setAllowedAuthenticators(BIOMETRIC_STRONG | DEVICE_CREDENTIAL)
+ .build();
+
+ prompt.authenticate(info);
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/DialogCallback.java b/app/src/main/java/com/example/onetap_ssh/util/DialogCallback.java
new file mode 100644
index 0000000..01f0d01
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/DialogCallback.java
@@ -0,0 +1,7 @@
+package com.example.onetap_ssh.util;
+
+public interface DialogCallback {
+
+ boolean callback();
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/DialogUtil.java b/app/src/main/java/com/example/onetap_ssh/util/DialogUtil.java
new file mode 100644
index 0000000..178ff6a
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/DialogUtil.java
@@ -0,0 +1,275 @@
+package com.example.onetap_ssh.util;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.util.Consumer;
+
+import com.example.onetap_ssh.R;
+import com.example.onetap_ssh.backup.BackupData;
+import com.example.onetap_ssh.databinding.DialogCreateGroupBinding;
+import com.example.onetap_ssh.databinding.DialogErrorBinding;
+import com.example.onetap_ssh.databinding.DialogInputPasswordBinding;
+import com.example.onetap_ssh.databinding.DialogSetPasswordBinding;
+
+import java.util.List;
+import java.util.UUID;
+
+public class DialogUtil {
+
+ private static final Integer[] DIGITS = new Integer[]{6, 7, 8, 9, 10, 11, 12};
+
+ private static void showCodeDialog(Context context, View view, DialogCallback ok) {
+ AlertDialog dialog = new StyledDialogBuilder(context)
+ .setTitle(R.string.code_input_title)
+ .setView(view)
+ .setPositiveButton(R.string.ok, (btnView, which) -> {})
+ .setNegativeButton(R.string.cancel, (btnView, which) -> {})
+ .create();
+
+ dialog.setOnShowListener(d -> {
+ Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ okButton.setOnClickListener(v -> {
+ if(ok.callback()) dialog.dismiss();
+ });
+ });
+
+ dialog.show();
+ }
+
+ public static void showErrorDialog(Context context, String errorMessage, String details, Runnable closed) {
+ DialogErrorBinding binding = DialogErrorBinding.inflate(LayoutInflater.from(context));
+
+ binding.errorMessage.setText(errorMessage);
+
+ AlertDialog.Builder b = new StyledDialogBuilder(context)
+ .setTitle(R.string.failed_title)
+ .setView(binding.getRoot())
+ .setPositiveButton(R.string.ok, (d, which) -> {})
+ .setOnDismissListener(d -> { if(closed != null) closed.run(); });
+
+ if(details != null) {
+ binding.errorDetailsText.setText(details);
+ b.setNeutralButton("Details", (d, which) -> {});
+ }
+
+ AlertDialog dialog = b.create();
+
+ if(details != null) {
+ dialog.setOnShowListener(d -> {
+ Button detailsButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL);
+ detailsButton.setOnClickListener(v -> binding.errorDetails.setVisibility(binding.errorDetails.getVisibility() == View.VISIBLE ? View.GONE : View.VISIBLE));
+ });
+ }
+
+ dialog.show();
+ }
+
+ public static void showErrorDialog(Context context, String errorMessage, Runnable closed) {
+ showErrorDialog(context, errorMessage, (String) null, closed);
+ }
+
+ public static void showErrorDialog(Context context, String errorMessage) {
+ showErrorDialog(context, errorMessage, (Runnable) null);
+ }
+
+ public static void showErrorDialog(Context context, String errorMessage, Exception exception, Runnable closed) {
+ showErrorDialog(context, errorMessage, stackTraceToString(exception), closed);
+ }
+
+ public static void showErrorDialog(Context context, String errorMessage, Exception exception) {
+ showErrorDialog(context, errorMessage, stackTraceToString(exception), null);
+ }
+
+ private static String stackTraceToString(Throwable t) {
+ StringBuilder b = new StringBuilder();
+
+ b.append(t.toString()).append('\n');
+ for(StackTraceElement e : t.getStackTrace()) {
+ b.append(" ").append(e.toString()).append('\n');
+ }
+
+ if(t.getCause() != null) {
+ b.append("Caused by: ").append(stackTraceToString(t.getCause()));
+ }
+
+ return b.toString().trim();
+ }
+
+ public static void showCreateGroupDialog(LayoutInflater inflater, String initialName, Consumer callback, Runnable onDismiss) {
+ Context context = inflater.getContext();
+
+ DialogCreateGroupBinding binding = DialogCreateGroupBinding.inflate(inflater);
+ binding.createGroupName.setText(initialName);
+
+ AlertDialog dialog = new StyledDialogBuilder(context)
+ .setTitle(R.string.action_new_group)
+ .setView(binding.getRoot())
+ .setPositiveButton(R.string.add, (view, which) -> {})
+ .setNegativeButton(R.string.cancel, (view, which) -> { if(onDismiss != null) onDismiss.run(); })
+ .create();
+
+ dialog.setOnShowListener(d -> {
+ Button okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ okButton.setOnClickListener(v -> {
+ if(binding.createGroupName.getText().length() == 0) {
+ DialogUtil.showErrorDialog(context, context.getString(R.string.new_group_missing_title));
+ return;
+ }
+
+ dialog.dismiss();
+ callback.accept(binding.createGroupName.getText().toString());
+ if(onDismiss != null) onDismiss.run();
+ });
+ });
+
+ dialog.setOnCancelListener(d -> { if(onDismiss != null) onDismiss.run(); });
+ dialog.show();
+ }
+
+ public static void showChooseGroupDialog(Context context, Consumer callback, Runnable onDismiss) {
+ List groups = SettingsUtil.getGroups(context);
+ String[] groupNames = new String[groups.size() + 1];
+
+ groupNames[0] = context.getString(R.string.uri_handler_create_group);
+ for(int i = 0; i < groups.size(); i++) {
+ groupNames[i + 1] = SettingsUtil.getGroupName(context, groups.get(i));
+ }
+
+ AlertDialog dialog = new StyledDialogBuilder(context)
+ .setTitle(R.string.uri_handler_add_code_title)
+ .setItems(groupNames, (d, which) -> {
+ if(which == 0) { // Create New Group
+ DialogUtil.showCreateGroupDialog(LayoutInflater.from(context), null, group -> {
+ String id = UUID.randomUUID().toString();
+ SettingsUtil.addGroup(context, id, group);
+ callback.accept(id);
+ }, onDismiss);
+ return;
+ }
+
+ callback.accept(groups.get(which - 1));
+ if(onDismiss != null) onDismiss.run();
+ })
+ .setNegativeButton(R.string.cancel, (d, which) -> { if(onDismiss != null) onDismiss.run(); })
+ .setOnCancelListener(d -> { if(onDismiss != null) onDismiss.run(); })
+ .create();
+
+ 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(R.string.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) {
+ DialogInputPasswordBinding binding = DialogInputPasswordBinding.inflate(LayoutInflater.from(context));
+
+ AlertDialog dialog = new StyledDialogBuilder(context)
+ .setTitle("Input Password")
+ .setView(binding.getRoot())
+ .setPositiveButton(R.string.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.inputPassword.getText().length() == 0) {
+ DialogUtil.showErrorDialog(context, "You need to enter a password");
+ return;
+ }
+
+ dialog.dismiss();
+ callback.accept(binding.inputPassword.getText().toString());
+ });
+ });
+
+ dialog.show();
+ }
+
+ public static void showYesNo(Context context, @StringRes int title, @StringRes int message, @StringRes int yesText, @StringRes int noText, Runnable yes, Runnable no) {
+ new StyledDialogBuilder(context)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(yesText, (d, w) -> {
+ if(yes != null) yes.run();
+ })
+ .setNegativeButton(noText, (d, w) -> {
+ if(no != null) no.run();
+ })
+ .show()
+ .setCanceledOnTouchOutside(false);
+ }
+
+ public static void showYesNo(Context context, @StringRes int title, @StringRes int message, Runnable yes, Runnable no) {
+ showYesNo(context, title, message, R.string.yes, R.string.no, yes, no);
+ }
+
+ public static void showYesNoCancel(Context context, @StringRes int title, @StringRes int message, @StringRes int yesText, @StringRes int noText, @StringRes int cancelText, Runnable yes, Runnable no, Runnable cancel) {
+ new StyledDialogBuilder(context)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(yesText, (d, w) -> {
+ if(yes != null) yes.run();
+ })
+ .setNegativeButton(noText, (d, w) -> {
+ if(no != null) no.run();
+ })
+ .setNeutralButton(cancelText, (d, w) -> d.cancel())
+ .setOnCancelListener(d -> {
+ if(cancel != null) cancel.run();
+ })
+ .show();
+ }
+
+ public static void showYesNoCancel(Context context, @StringRes int title, @StringRes int message, Runnable yes, Runnable no, Runnable cancel) {
+ showYesNoCancel(context, title, message, R.string.yes, R.string.no, R.string.cancel, yes, no, cancel);
+ }
+
+ public static void showBackupLoadedDialog(Context context, BackupData data) {
+ AlertDialog dialog = new StyledDialogBuilder(context)
+ .setTitle("Backup loaded")
+ .setMessage(String.format("Successfully loaded %s group(s) from the backup", data.getGroups().length))
+ .setPositiveButton(R.string.ok, (d, which) -> {})
+ .create();
+
+ dialog.show();
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/IOUtil.java b/app/src/main/java/com/example/onetap_ssh/util/IOUtil.java
new file mode 100644
index 0000000..bdd65e9
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/IOUtil.java
@@ -0,0 +1,37 @@
+package com.example.onetap_ssh.util;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+public class IOUtil {
+
+ public static byte[] readBytes(File file) throws IOException {
+ try(InputStream fIn = new BufferedInputStream(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);
+ }
+
+ return fileBuffer.array();
+ }
+ }
+
+ 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();
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/SettingsUtil.java b/app/src/main/java/com/example/onetap_ssh/util/SettingsUtil.java
new file mode 100644
index 0000000..2cfb146
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/SettingsUtil.java
@@ -0,0 +1,246 @@
+package com.example.onetap_ssh.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.example.onetap_ssh.backup.BackupGroup;
+import com.example.onetap_ssh.crypto.BiometricKey;
+import com.example.onetap_ssh.crypto.CryptoParameters;
+import com.google.gson.Gson;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class SettingsUtil {
+
+ public static final String
+ GROUPS_PREFS_NAME = "groups",
+ GENERAL_PREFS_NAME = "general";
+
+ public static final Gson GSON = new Gson();
+
+ public static List getGroups(Context ctx) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE);
+ return Arrays.asList(GSON.fromJson(prefs.getString("groups", "[]"), String[].class));
+ }
+
+ /**
+ * Only for reordering groups. Don't add/delete groups with this!
+ * @param groups Groups
+ */
+ public static void setGroups(Context ctx, List groups) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("groups", GSON.toJson(groups)).apply();
+ }
+
+ public static void restoreGroups(Context ctx, BackupGroup[] groups) {
+ List oldGroups = getGroups(ctx);
+ for(String group : oldGroups) removeGroup(ctx, group);
+
+ List newGroups = new ArrayList<>();
+ for(BackupGroup group : groups) {
+ newGroups.add(group.getId());
+ setGroupName(ctx, group.getId(), group.getName());
+ }
+
+ setGroups(ctx, newGroups);
+ }
+
+ public static void addGroup(Context ctx, String group, String groupName) {
+ List groups = new ArrayList<>(getGroups(ctx));
+ groups.add(group);
+
+ SharedPreferences prefs = ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("groups", GSON.toJson(groups)).apply();
+
+ setGroupName(ctx, group, groupName);
+ }
+
+ public static void removeGroup(Context ctx, String group) {
+ List groups = new ArrayList<>(getGroups(ctx));
+ groups.remove(group);
+
+ SharedPreferences prefs = ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("groups", GSON.toJson(groups)).apply();
+
+ deleteGroupData(ctx, group);
+ }
+
+ public static String getGroupName(Context ctx, String group) {
+ return ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE).getString("group." + group + ".name", group);
+ }
+
+ public static void setGroupName(Context ctx, String group, String name) {
+ ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putString("group." + group + ".name", name)
+ .apply();
+ }
+
+ private static void deleteGroupData(Context ctx, String group) {
+ ctx.getSharedPreferences(GROUPS_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .remove("group." + group + ".otps")
+ .remove("group." + group + ".name")
+ .apply();
+ }
+
+ public static boolean isDatabaseEncrypted(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("encryption", false);
+ }
+
+ public static void enableEncryption(Context ctx, CryptoParameters parameters) {
+ ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putBoolean("encryption", true)
+ .putString("encryption.parameters", GSON.toJson(parameters))
+ .apply();
+ }
+
+ public static void disableEncryption(Context ctx) {
+ ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putBoolean("encryption", false)
+ .remove("encryption.parameters")
+ .apply();
+ }
+
+ public static CryptoParameters getCryptoParameters(Context ctx) {
+ return GSON.fromJson(ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getString("encryption.parameters", "{}"), CryptoParameters.class);
+ }
+
+ public static void enableBiometricEncryption(Context ctx, BiometricKey biometricKey) {
+ ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putBoolean("encryption.biometric", true)
+ .putString("encryption.biometric.key", GSON.toJson(biometricKey))
+ .apply();
+ }
+
+ public static void disableBiometricEncryption(Context ctx) {
+ ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).edit()
+ .putBoolean("encryption.biometric", false)
+ .remove("encryption.biometric.key")
+ .apply();
+ }
+
+ public static boolean isBiometricEncryption(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("encryption.biometric", false);
+ }
+
+ public static BiometricKey getBiometricKey(Context ctx) {
+ String encoded = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getString("encryption.biometric.key", null);
+ if(encoded == null) return null;
+ return GSON.fromJson(encoded, BiometricKey.class);
+ }
+
+ public static void setEnableIntroVideo(Context ctx, boolean enableIntroVideo) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("enableIntroVideo", enableIntroVideo).apply();
+ }
+
+ public static boolean isIntroVideoEnabled(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("enableIntroVideo", true);
+ }
+
+ public static void setEnableThemedBackground(Context ctx, boolean enableThemedBackground) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("enableThemedBackground", enableThemedBackground).apply();
+ }
+
+ public static boolean isThemedBackgroundEnabled(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("enableThemedBackground", true);
+ }
+
+ public static void setEnableMinimalistTheme(Context ctx, boolean enableMinimalistTheme) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("enableMinimalistTheme", enableMinimalistTheme).apply();
+ }
+
+ public static boolean isMinimalistThemeEnabled(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("enableMinimalistTheme", false);
+ }
+
+ public static void setScreenSecurity(Context ctx, boolean screenSecurity) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("screenSecurity", screenSecurity).apply();
+ }
+
+ public static boolean isScreenSecurity(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("screenSecurity", true);
+ }
+
+ public static void setHideCodes(Context ctx, boolean hideCodes) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("hideCodes", hideCodes).apply();
+ }
+
+ public static boolean isHideCodes(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("hideCodes", false);
+ }
+
+ public static void setFirstLaunch(Context ctx, boolean firstLaunch) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("firstLaunch", firstLaunch).apply();
+ }
+
+ public static boolean isFirstLaunch(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("firstLaunch", true);
+ }
+
+ public static void setShowImages(Context ctx, boolean showImages) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putBoolean("showImages", showImages).apply();
+ }
+
+ public static boolean isShowImages(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("showImages", true);
+ }
+
+ public static void setTheme(Context ctx, Theme theme) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("theme", theme.name()).apply();
+ }
+
+ public static Theme getTheme(Context ctx) {
+ String themeId = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getString("theme", Theme.BLUE_GREEN.name());
+ try {
+ return Theme.valueOf(themeId);
+ }catch(IllegalArgumentException e) {
+ return Theme.BLUE_GREEN;
+ }
+ }
+
+ public static void setAppearance(Context ctx, Appearance appearance) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("appearance", appearance.name()).apply();
+ }
+
+ public static Appearance getAppearance(Context ctx) {
+ String themeId = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getString("appearance", Appearance.FOLLOW_SYSTEM.name());
+ try {
+ return Appearance.valueOf(themeId);
+ }catch(IllegalArgumentException e) {
+ return Appearance.FOLLOW_SYSTEM;
+ }
+ }
+
+ public static void setLocale(Context ctx, AppLocale locale) {
+ SharedPreferences prefs = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE);
+ prefs.edit().putString("locale", locale.name()).apply();
+ }
+
+ public static AppLocale getLocale(Context ctx) {
+ String lang = ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getString("locale", AppLocale.ENGLISH.name());
+ try {
+ return AppLocale.valueOf(lang);
+ }catch(IllegalArgumentException e) {
+ return AppLocale.SYSTEM_DEFAULT;
+ }
+ }
+
+ public static void enableSuperSecretHamburgers(Context ctx) {
+ ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).edit().putBoolean("iLikeHamburgers", true).apply();
+ }
+
+ public static boolean isSuperSecretHamburgersEnabled(Context ctx) {
+ return ctx.getSharedPreferences(GENERAL_PREFS_NAME, Context.MODE_PRIVATE).getBoolean("iLikeHamburgers", false);
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/StyledDialogBuilder.java b/app/src/main/java/com/example/onetap_ssh/util/StyledDialogBuilder.java
new file mode 100644
index 0000000..75e3b55
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/StyledDialogBuilder.java
@@ -0,0 +1,42 @@
+package com.example.onetap_ssh.util;
+
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.view.WindowManager;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+
+import com.example.onetap_ssh.R;
+
+public class StyledDialogBuilder extends AlertDialog.Builder {
+
+ public StyledDialogBuilder(Context context) {
+ super(context);
+ }
+
+ public StyledDialogBuilder(Context context, int themeResId) {
+ super(context, themeResId);
+ }
+
+ @NonNull
+ @Override
+ public AlertDialog create() {
+ AlertDialog dialog = super.create();
+
+ TypedArray arr = dialog.getContext().obtainStyledAttributes(new int[] {R.attr.dialogBackground});
+ try {
+ dialog.getWindow().setBackgroundDrawable(arr.getDrawable(0));
+
+ if(SettingsUtil.isScreenSecurity(getContext())) {
+ dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
+ }
+ }finally {
+ arr.close();
+ }
+
+ return dialog;
+ }
+
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/Theme.java b/app/src/main/java/com/example/onetap_ssh/util/Theme.java
new file mode 100644
index 0000000..1777fce
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/Theme.java
@@ -0,0 +1,57 @@
+package com.example.onetap_ssh.util;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.StringRes;
+import androidx.annotation.StyleRes;
+
+import com.example.onetap_ssh.R;
+
+public enum Theme {
+
+ BLUE_GREEN(R.string.theme_blue_green, R.style.Theme_OneTap_SSH_Blue_Green, R.drawable.background_blue_green_light, R.drawable.background_blue_green),
+ RED_BLUE(R.string.theme_red_blue, R.style.Theme_OneTap_SSH_Red_Blue, R.drawable.background_red_blue_light, R.drawable.background_red_blue),
+ PINK_GREEN(R.string.theme_pink_green, R.style.Theme_OneTap_SSH_Pink_Green, R.drawable.background_pink_green_light, R.drawable.background_pink_green),
+ BLUE_YELLOW(R.string.theme_blue_yellow, R.style.Theme_OneTap_SSH_Blue_Yellow, R.drawable.background_blue_yellow_light, R.drawable.background_blue_yellow),
+ GREEN_YELLOW(R.string.theme_green_yellow, R.style.Theme_OneTap_SSH_Green_Yellow, R.drawable.background_green_yellow_light, R.drawable.background_green_yellow),
+ ORANGE_TURQUOISE(R.string.theme_orange_turquoise, R.style.Theme_OneTap_SSH_Orange_Turquoise, R.drawable.background_orange_turquoise_light, R.drawable.background_orange_turquoise),
+ ;
+
+ @StringRes
+ private final int name;
+
+ @StyleRes
+ private final int style;
+
+ @DrawableRes
+ private final int lightBackground;
+
+ @DrawableRes
+ private final int darkBackground;
+
+ Theme(@StringRes int name, @StyleRes int style, @DrawableRes int lightBackground, @DrawableRes int darkBackground) {
+ this.name = name;
+ this.style = style;
+ this.lightBackground = lightBackground;
+ this.darkBackground = darkBackground;
+ }
+
+ @StringRes
+ public int getName() {
+ return name;
+ }
+
+ @StyleRes
+ public int getStyle() {
+ return style;
+ }
+
+ @DrawableRes
+ public int getLightBackground() {
+ return lightBackground;
+ }
+
+ @DrawableRes
+ public int getDarkBackground() {
+ return darkBackground;
+ }
+}
diff --git a/app/src/main/java/com/example/onetap_ssh/util/ThemeUtil.java b/app/src/main/java/com/example/onetap_ssh/util/ThemeUtil.java
new file mode 100644
index 0000000..8eb00c3
--- /dev/null
+++ b/app/src/main/java/com/example/onetap_ssh/util/ThemeUtil.java
@@ -0,0 +1,57 @@
+package com.example.onetap_ssh.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.view.View;
+
+import androidx.annotation.DrawableRes;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+
+import com.example.onetap_ssh.R;
+
+public class ThemeUtil {
+
+ public static void loadTheme(AppCompatActivity activity) {
+ Theme theme = SettingsUtil.getTheme(activity);
+ activity.setTheme(theme.getStyle());
+
+ if(SettingsUtil.isMinimalistThemeEnabled(activity)) {
+ activity.getTheme().applyStyle(R.style.Theme_CringeAuthenticator_Minimalist, true);
+ }
+
+ AppCompatDelegate.setDefaultNightMode(SettingsUtil.getAppearance(activity).getValue());
+ }
+
+ @DrawableRes
+ public static int getBackground(Context context) {
+ if(!SettingsUtil.isThemedBackgroundEnabled(context)) return 0;
+
+ Theme theme = SettingsUtil.getTheme(context);
+
+ int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
+ boolean isNightMode;
+ switch(nightMode) {
+ case Configuration.UI_MODE_NIGHT_NO:
+ default:
+ isNightMode = false;
+ break;
+ case Configuration.UI_MODE_NIGHT_YES:
+ isNightMode = true;
+ break;
+ }
+
+ return !isNightMode ? theme.getLightBackground() : theme.getDarkBackground();
+ }
+
+ public static void loadBackground(AppCompatActivity activity) {
+ if(!SettingsUtil.isThemedBackgroundEnabled(activity)) return;
+
+ int background = getBackground(activity);
+ View v = activity.findViewById(R.id.app_background);
+ if(v != null) {
+ v.setBackgroundResource(background);
+ }
+ }
+
+}
diff --git a/app/src/main/res/drawable/background_blue_green.jpg b/app/src/main/res/drawable/background_blue_green.jpg
new file mode 100644
index 0000000..004c14e
Binary files /dev/null and b/app/src/main/res/drawable/background_blue_green.jpg differ
diff --git a/app/src/main/res/drawable/background_blue_green_light.jpg b/app/src/main/res/drawable/background_blue_green_light.jpg
new file mode 100644
index 0000000..f827d71
Binary files /dev/null and b/app/src/main/res/drawable/background_blue_green_light.jpg differ
diff --git a/app/src/main/res/drawable/background_blue_yellow.jpg b/app/src/main/res/drawable/background_blue_yellow.jpg
new file mode 100644
index 0000000..87a3619
Binary files /dev/null and b/app/src/main/res/drawable/background_blue_yellow.jpg differ
diff --git a/app/src/main/res/drawable/background_blue_yellow_light.jpg b/app/src/main/res/drawable/background_blue_yellow_light.jpg
new file mode 100644
index 0000000..9345322
Binary files /dev/null and b/app/src/main/res/drawable/background_blue_yellow_light.jpg differ
diff --git a/app/src/main/res/drawable/background_green_yellow.jpg b/app/src/main/res/drawable/background_green_yellow.jpg
new file mode 100644
index 0000000..8f4f201
Binary files /dev/null and b/app/src/main/res/drawable/background_green_yellow.jpg differ
diff --git a/app/src/main/res/drawable/background_green_yellow_light.jpg b/app/src/main/res/drawable/background_green_yellow_light.jpg
new file mode 100644
index 0000000..0b87de5
Binary files /dev/null and b/app/src/main/res/drawable/background_green_yellow_light.jpg differ
diff --git a/app/src/main/res/drawable/background_orange_turquoise.jpg b/app/src/main/res/drawable/background_orange_turquoise.jpg
new file mode 100644
index 0000000..7c78d90
Binary files /dev/null and b/app/src/main/res/drawable/background_orange_turquoise.jpg differ
diff --git a/app/src/main/res/drawable/background_orange_turquoise_light.jpg b/app/src/main/res/drawable/background_orange_turquoise_light.jpg
new file mode 100644
index 0000000..4aa36e3
Binary files /dev/null and b/app/src/main/res/drawable/background_orange_turquoise_light.jpg differ
diff --git a/app/src/main/res/drawable/background_pink_green.jpg b/app/src/main/res/drawable/background_pink_green.jpg
new file mode 100644
index 0000000..7c5d591
Binary files /dev/null and b/app/src/main/res/drawable/background_pink_green.jpg differ
diff --git a/app/src/main/res/drawable/background_pink_green_light.jpg b/app/src/main/res/drawable/background_pink_green_light.jpg
new file mode 100644
index 0000000..0f1c83f
Binary files /dev/null and b/app/src/main/res/drawable/background_pink_green_light.jpg differ
diff --git a/app/src/main/res/drawable/background_red_blue.jpg b/app/src/main/res/drawable/background_red_blue.jpg
new file mode 100644
index 0000000..f1d4909
Binary files /dev/null and b/app/src/main/res/drawable/background_red_blue.jpg differ
diff --git a/app/src/main/res/drawable/background_red_blue_light.jpg b/app/src/main/res/drawable/background_red_blue_light.jpg
new file mode 100644
index 0000000..c4c5bb8
Binary files /dev/null and b/app/src/main/res/drawable/background_red_blue_light.jpg differ
diff --git a/app/src/main/res/drawable/baseline_delete_24.xml b/app/src/main/res/drawable/baseline_delete_24.xml
new file mode 100644
index 0000000..eba82ef
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_delete_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/baseline_edit_24.xml b/app/src/main/res/drawable/baseline_edit_24.xml
new file mode 100644
index 0000000..1074059
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_edit_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/baseline_lock_open_24.xml b/app/src/main/res/drawable/baseline_lock_open_24.xml
new file mode 100644
index 0000000..920d7fc
--- /dev/null
+++ b/app/src/main/res/drawable/baseline_lock_open_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/button_simple.xml b/app/src/main/res/drawable/button_simple.xml
new file mode 100644
index 0000000..2e7f716
--- /dev/null
+++ b/app/src/main/res/drawable/button_simple.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/button_themed.xml b/app/src/main/res/drawable/button_themed.xml
new file mode 100644
index 0000000..a6e970e
--- /dev/null
+++ b/app/src/main/res/drawable/button_themed.xml
@@ -0,0 +1,36 @@
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/codeguard_white.xml b/app/src/main/res/drawable/codeguard_white.xml
new file mode 100644
index 0000000..41afa43
--- /dev/null
+++ b/app/src/main/res/drawable/codeguard_white.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/cringestudios.png b/app/src/main/res/drawable/cringestudios.png
new file mode 100644
index 0000000..4ee6cc6
Binary files /dev/null and b/app/src/main/res/drawable/cringestudios.png differ
diff --git a/app/src/main/res/drawable/dialog_themed.xml b/app/src/main/res/drawable/dialog_themed.xml
new file mode 100644
index 0000000..c8357cf
--- /dev/null
+++ b/app/src/main/res/drawable/dialog_themed.xml
@@ -0,0 +1,31 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/jgcody.png b/app/src/main/res/drawable/jgcody.png
new file mode 100644
index 0000000..dd0e777
Binary files /dev/null and b/app/src/main/res/drawable/jgcody.png differ
diff --git a/app/src/main/res/drawable/menu_themed.xml b/app/src/main/res/drawable/menu_themed.xml
new file mode 100644
index 0000000..5c46138
--- /dev/null
+++ b/app/src/main/res/drawable/menu_themed.xml
@@ -0,0 +1,31 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/theme_background.xml b/app/src/main/res/drawable/theme_background.xml
new file mode 100644
index 0000000..966798d
--- /dev/null
+++ b/app/src/main/res/drawable/theme_background.xml
@@ -0,0 +1,21 @@
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/theme_gradient.xml b/app/src/main/res/drawable/theme_gradient.xml
new file mode 100644
index 0000000..9f15938
--- /dev/null
+++ b/app/src/main/res/drawable/theme_gradient.xml
@@ -0,0 +1,19 @@
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_intro.xml b/app/src/main/res/layout/activity_intro.xml
new file mode 100644
index 0000000..b4f7419
--- /dev/null
+++ b/app/src/main/res/layout/activity_intro.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 6c7dd7c..6564700 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -2,10 +2,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml
index 442096e..f9421c6 100644
--- a/app/src/main/res/layout/app_bar_main.xml
+++ b/app/src/main/res/layout/app_bar_main.xml
@@ -9,14 +9,14 @@
+ android:theme="@style/Theme.OneTap_SSH.AppBarOverlay">
+ app:popupTheme="@style/Theme.OneTap_SSH.PopupOverlay" />
diff --git a/app/src/main/res/layout/dialog_create_group.xml b/app/src/main/res/layout/dialog_create_group.xml
new file mode 100644
index 0000000..3eac635
--- /dev/null
+++ b/app/src/main/res/layout/dialog_create_group.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_error.xml b/app/src/main/res/layout/dialog_error.xml
new file mode 100644
index 0000000..0fd5531
--- /dev/null
+++ b/app/src/main/res/layout/dialog_error.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_icon_pack_exists.xml b/app/src/main/res/layout/dialog_icon_pack_exists.xml
new file mode 100644
index 0000000..d3246f0
--- /dev/null
+++ b/app/src/main/res/layout/dialog_icon_pack_exists.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_input_password.xml b/app/src/main/res/layout/dialog_input_password.xml
new file mode 100644
index 0000000..5aef541
--- /dev/null
+++ b/app/src/main/res/layout/dialog_input_password.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_manage_icon_packs.xml b/app/src/main/res/layout/dialog_manage_icon_packs.xml
new file mode 100644
index 0000000..543c755
--- /dev/null
+++ b/app/src/main/res/layout/dialog_manage_icon_packs.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_manage_icon_packs_item.xml b/app/src/main/res/layout/dialog_manage_icon_packs_item.xml
new file mode 100644
index 0000000..db3e0d3
--- /dev/null
+++ b/app/src/main/res/layout/dialog_manage_icon_packs_item.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
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..243a010
--- /dev/null
+++ b/app/src/main/res/layout/dialog_set_password.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
new file mode 100644
index 0000000..e6f49b0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_about.xml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 0000000..da919fe
--- /dev/null
+++ b/app/src/main/res/layout/fragment_group.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_no_groups.xml b/app/src/main/res/layout/fragment_no_groups.xml
new file mode 100644
index 0000000..d84b304
--- /dev/null
+++ b/app/src/main/res/layout/fragment_no_groups.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_pick_icon.xml b/app/src/main/res/layout/fragment_pick_icon.xml
new file mode 100644
index 0000000..8da9dca
--- /dev/null
+++ b/app/src/main/res/layout/fragment_pick_icon.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 0000000..cdd6af0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/icon_list_category.xml b/app/src/main/res/layout/icon_list_category.xml
new file mode 100644
index 0000000..24995c8
--- /dev/null
+++ b/app/src/main/res/layout/icon_list_category.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/icon_list_icon.xml b/app/src/main/res/layout/icon_list_icon.xml
new file mode 100644
index 0000000..00d7ad5
--- /dev/null
+++ b/app/src/main/res/layout/icon_list_icon.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/raw/intro_vp9.webm b/app/src/main/res/raw/intro_vp9.webm
new file mode 100644
index 0000000..15f5371
Binary files /dev/null and b/app/src/main/res/raw/intro_vp9.webm differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index ee3a69a..775c16c 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,14 @@
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..a60897b
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..a6816c1 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,10 +1,19 @@
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
#FF000000
+ #222222
+ #DCDCDC
#FFFFFFFF
+ #008BFF
+ #90D14C
+
+
+ #008BFF
+ #90D14C
+ #D900FF
+ #FF7700
+ #DA0303
+ #FFE500
+ #00FFF7
+ #00FF0A
\ 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 366ec1a..5a6b6a3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,13 +1,196 @@
- OneTap-SSH
- Open navigation drawer
- Close navigation drawer
- Android Studio
- android.studio@android.com
- Navigation header
+ Code Guard
Settings
+ About
+ Edit
+ Choose Language
+ Cancel
+ Add
+ OK
+ No
+ Yes
+ Invalid Input
+ Haptic Feedback
+ Reset App
+ Import / Export
+ Unlock the authenticator
+ Select Code Type
+ New Group
+ You need to input a name
+ Scan failed: %s
+ Failed to play video
+ Edit OTP
+ Delete Groups
+ Do you want to delete the groups? Note: This will delete all of the contained OTPs!
+ Generated new code
+ Failed to add code
+ Input Code
+ Action failed
+ Invalid number entered
+ Back
+ Delete OTP(s)
+ Do you want to delete the selected OTP(s)?
+ Edit Group
+ Enable intro video
+ Use biometric unlock
+ Code added
+ Add Code
+ Create New Group
+ Theme
+ Counter
+ Add Checksum
+ Secret
+ Name
+ Period
+ Failed to update OTP: %s
+ Issuer (optional)
+ Missing name
+ OTP Migration
+ It seems like you\'re trying to import OTP codes from another app. Do you want to import all codes into this group?
+ Code %d of %d scanned
+ Screen security
+ Hide codes
+ Enable encryption
+ Password
+ Set Password
+ Confirm Password
+ Unlock Authenticator
+ Unlock
+ Unlock using biometrics
+ Name
+ Add Code
+ Settings
+ Appearance
+ Developed by Cringe Studios and JG-Cody
+ Settings
+ About
+ Edit OTP
+ No Groups
+
+ - View
+ - Edit
+ - Move to other group
+ - Delete
+
+
+ - Rename
+ - Delete
+
+ Blue/Green
+ Red/Blue
+ Pink/Green
+ Blue/Yellow
+ Green/Yellow
+ Orange/Turquoise
+ Dark
+ Light
+ System default
+ System default
+ Create backup
+
+ - Create with current password
+ - Create with new password
+
+ Load backup
+ Backups
+ Create backup
+ Load backup
+ Enter Password
+ Disable encryption
+ Do you really want to disable encryption?
+ Load backup
+ Do you want to load this backup? This will delete ALL of the current data in the app and replace it with the data from the backup!
+ Type
+ Algorithm
+ Digits
+ Show images
+ Delete pack
+ Do you want to delete the icon pack?
+ No icon packs are currently installed
+ License
+ Contact
+ Website
+ Development
+ Appcode
+ Support
+ Changelog
+ Documentation
+ GNU General Public License, version 3.0
+ Mail: info@code-guard.com
+ https://git.cringe-studios.com/CringeStudios/Code-Guard
+ https://code-guard.com/#4
+ https://git.cringe-studios.com/CringeStudios/Code-Guard
+ https://git.cringe-studios.com/CringeStudios/Code-Guard
+ The icon pack doesn\'t contain any icons
+ Pack contains invalid metadata. Make sure you selected the correct file
+ The icon pack you\'re trying to import already exists. Imported: %s (version %d) Existing: %s (version %d) What do you want to do?
+ Broken icon packs
+ Some icon packs failed to load. Do you want to delete the broken icon packs?
+ Icon pack with %d icon(s) imported
+ It is recommended to enable encryption to improve the security of the application. Do you want to go to the settings now to enable encryption?
+ Enable encryption
+ Press back again to exit
+ Database must be encrypted for this option
+ Manage icon packs
+ Failed to load backup. Make sure the password is valid
+ Failed to load backup
+ Failed to enable biometric encryption
+ Failed to disable biometric encryption
+ Choose Image
+ Failed to open image
+ Failed to save database
+ An error occurred while refreshing the code
+ Failed to enable encryption
+ Failed to disable encryption
+ You need to enter a password
+ Failed to load database: Invalid password or database corrupted
+ Failed to load database
+ No codes were detected in the provided image
+ Failed to detect code
+ Failed to read image
+ Icon pack already exists
+ Failed to import icon pack
+ No camera permission
+ Details
+ Search
+ Use themed background
+ Use minimalist theme
+ Icon packs
+ Import icon pack
+ Manage icon packs
+ Save
+ Lock
+ Input manually
+ Scan QR code
+ Scan image
+ View OTP
+ Edit OTP
+ Move OTP
+ Delete OTP
+ Close
+ Security
+ Localization
+ An OTP with the name of the OTP you\'re trying to add already exists. Do you want to automatically rename the new OTP to distinguish them from each other?
+ Duplicate OTP
+ Biometric authentication is disabled because it is not set up or not available on your device
+ No groups exist yet. Open the menu and press the \'+\' button to create one
+
+ - Image from icon pack
+ - Image from gallery
+ - No image
+ - Reset to default image
+
+
+ - Override
+ - Rename existing
+ - Rename imported
+
+ Language
+ 1
+ 1
+ 1
+ 1
+ 1
+ 1
- Home
- Gallery
- Slideshow
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index c8a5d25..c9a87ca 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,25 +1,85 @@
-
-
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 3daed1d..fe9e4bf 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,4 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
-id 'com.android.application' version '8.1.1' apply false
+ id 'com.android.application' version '8.1.2' apply false
+ id 'com.android.library' version '8.1.2' apply false
+ id 'com.google.protobuf' version '0.9.3' apply false
}
\ No newline at end of file