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