From 1ee9a6fabeef68027a915fbd4b707dd1bee4042c Mon Sep 17 00:00:00 2001 From: MrLetsplay Date: Sat, 30 Sep 2023 18:38:39 +0200 Subject: [PATCH] Implement icon packs (WIP), Add FLAG_SECURE to dialogs --- app/build.gradle | 2 + .../cringe_authenticator/MainActivity.java | 20 ++ .../cringe_authenticator/icon/Icon.java | 21 ++ .../icon/IconMetadata.java | 22 ++ .../cringe_authenticator/icon/IconPack.java | 33 +++ .../icon/IconPackException.java | 18 ++ .../icon/IconPackMetadata.java | 27 +++ .../cringe_authenticator/icon/IconUtil.java | 215 ++++++++++++++++++ .../cringe_authenticator/model/OTPData.java | 11 + .../otplist/OTPListAdapter.java | 49 ++++ .../util/StyledDialogBuilder.java | 9 +- app/src/main/res/layout/fragment_about.xml | 8 + app/src/main/res/layout/fragment_menu.xml | 46 ---- app/src/main/res/layout/fragment_settings.xml | 34 +++ app/src/main/res/layout/otp_code.xml | 6 +- 15 files changed, 468 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/Icon.java create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconMetadata.java create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPack.java create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackException.java create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackMetadata.java create mode 100644 app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconUtil.java delete mode 100644 app/src/main/res/layout/fragment_menu.xml diff --git a/app/build.gradle b/app/build.gradle index 6f767bd..ce58ab2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -96,4 +96,6 @@ dependencies { implementation 'com.google.protobuf:protobuf-javalite:3.24.3' implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + + implementation 'com.caverock:androidsvg-aar:1.4' } diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java index 75ff044..d5c7eee 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java @@ -25,6 +25,9 @@ import com.cringe_studios.cringe_authenticator.fragment.GroupFragment; import com.cringe_studios.cringe_authenticator.fragment.HomeFragment; import com.cringe_studios.cringe_authenticator.fragment.NamedFragment; import com.cringe_studios.cringe_authenticator.fragment.SettingsFragment; +import com.cringe_studios.cringe_authenticator.icon.IconPackException; +import com.cringe_studios.cringe_authenticator.icon.IconPackMetadata; +import com.cringe_studios.cringe_authenticator.icon.IconUtil; import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator.scanner.QRScanner; import com.cringe_studios.cringe_authenticator.scanner.QRScannerContract; @@ -56,6 +59,8 @@ public class MainActivity extends BaseActivity { private Consumer pickBackupFileLoadCallback; + private ActivityResultLauncher pickIconPackFileLoad; + private QRScanner qrScanner; private boolean fullyLaunched; @@ -131,6 +136,16 @@ public class MainActivity extends BaseActivity { } }); + pickIconPackFileLoad = registerForActivityResult(new ActivityResultContracts.OpenDocument(), doc -> { + try { + if(doc == null) return; + IconPackMetadata meta = IconUtil.importIconPack(this, doc); + DialogUtil.showErrorDialog(this, "Icon pack contains " + meta.getIcons().length + " icons"); + } catch (IconPackException e) { + DialogUtil.showErrorDialog(this, "Failed to import icon pack", e); + } + }); + if(SettingsUtil.isFirstLaunch(this) && SettingsUtil.getGroups(this).isEmpty()) { SettingsUtil.addGroup(this, UUID.randomUUID().toString(), "My Codes"); SettingsUtil.setFirstLaunch(this, false); @@ -339,6 +354,11 @@ public class MainActivity extends BaseActivity { pickBackupFileLoad.launch(new String[]{"application/json", "*/*"}); } + public void promptPickIconPackLoad() { + this.lockOnStop = false; + pickIconPackFileLoad.launch(new String[]{"application/zip", "*/*"}); + } + @Override public void recreate() { lockOnStop = false; diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/Icon.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/Icon.java new file mode 100644 index 0000000..7116f88 --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/Icon.java @@ -0,0 +1,21 @@ +package com.cringe_studios.cringe_authenticator.icon; + +public class Icon { + + private IconMetadata metadata; + private 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/cringe_studios/cringe_authenticator/icon/IconMetadata.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconMetadata.java new file mode 100644 index 0000000..6511bc1 --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconMetadata.java @@ -0,0 +1,22 @@ +package com.cringe_studios.cringe_authenticator.icon; + +public class IconMetadata { + + private String filename; + private String category; + private String[] issuer; + + private IconMetadata() {} + + public String getFilename() { + return filename; + } + + public String getCategory() { + return category; + } + + public String[] getIssuer() { + return issuer; + } +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPack.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPack.java new file mode 100644 index 0000000..0dd691e --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPack.java @@ -0,0 +1,33 @@ +package com.cringe_studios.cringe_authenticator.icon; + +public class IconPack { + + private IconPackMetadata metadata; + private 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.equals(i)) { + return icon; + } + } + } + + return null; + } + +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackException.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackException.java new file mode 100644 index 0000000..57c3738 --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackException.java @@ -0,0 +1,18 @@ +package com.cringe_studios.cringe_authenticator.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/cringe_studios/cringe_authenticator/icon/IconPackMetadata.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackMetadata.java new file mode 100644 index 0000000..72e222a --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconPackMetadata.java @@ -0,0 +1,27 @@ +package com.cringe_studios.cringe_authenticator.icon; + +public class IconPackMetadata { + + private String uuid; + private String name; + private int version; + private IconMetadata[] icons; + + private IconPackMetadata() {} + + public String getUuid() { + return uuid; + } + + public String getName() { + return name; + } + + public int getVersion() { + return version; + } + + public IconMetadata[] getIcons() { + return icons; + } +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconUtil.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconUtil.java new file mode 100644 index 0000000..5a3b650 --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/icon/IconUtil.java @@ -0,0 +1,215 @@ +package com.cringe_studios.cringe_authenticator.icon; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.net.Uri; + +import com.cringe_studios.cringe_authenticator.util.DialogUtil; +import com.cringe_studios.cringe_authenticator.util.IOUtil; +import com.cringe_studios.cringe_authenticator.util.SettingsUtil; + +import java.io.File; +import java.io.FileInputStream; +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.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +public class IconUtil { + + // 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 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 IconPackMetadata importIconPack(Context context, Uri uri) throws IconPackException { + IconPackMetadata meta = loadPackMetadata(context, uri); + + // TODO: check for existing icon pack + File iconPackFile = new File(getIconPacksDir(context), meta.getUuid()); + + try { + if (!iconPackFile.exists()) { + iconPackFile.createNewFile(); + } + + try (OutputStream out = 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); + } + + return meta; + } + + private 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.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"); + } + + public static List loadAllIconPacks(Context context) { + File iconPacksDir = getIconPacksDir(context); + + String[] packIDs = iconPacksDir.list(); + if(packIDs == null) return Collections.emptyList(); + + List packs = new ArrayList<>(); + for(String pack : packIDs) { + try { + packs.add(loadIconPack(context, pack)); + }catch(IconPackException e) { + DialogUtil.showErrorDialog(context, "An icon pack failed to load", e); + } + } + + return packs; + } + + 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 FileInputStream(file))) { + IconPackMetadata metadata = null; + Map files = new HashMap<>(); + + ZipEntry en; + while((en = in.getNextEntry()) != null) { + 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() < 0 || en.getSize() > Integer.MAX_VALUE) { + throw new IOException("Invalid ZIP entry"); + } + + byte[] entryBytes = new byte[(int) en.getSize()]; + + int totalRead = 0; + while (totalRead < entryBytes.length) { + totalRead += in.read(entryBytes, totalRead, entryBytes.length - totalRead); + } + + return entryBytes; + } + + public static Bitmap generateCodeImage(String issuer) { + int imageSize = 128; + + Bitmap b = Bitmap.createBitmap(imageSize, imageSize, 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(imageSize / 2, imageSize / 2, imageSize / 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, imageSize / 2 - r.exactCenterX(), imageSize / 2 - r.exactCenterY(), p); + + return b; + } + +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/model/OTPData.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/model/OTPData.java index 77f920c..0aa5244 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/model/OTPData.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/model/OTPData.java @@ -9,6 +9,8 @@ import java.io.Serializable; public class OTPData implements Serializable { + public static final String IMAGE_DATA_NONE = "none"; + private String name; private String issuer; private OTPType type; @@ -18,6 +20,7 @@ public class OTPData implements Serializable { private int period; private long counter; private boolean checksum; + private String imageData; // Cached private transient OTP otp; @@ -70,6 +73,14 @@ public class OTPData implements Serializable { return checksum; } + public void setImageData(String imageData) { + this.imageData = imageData; + } + + public String getImageData() { + return imageData; + } + public String getPin() throws OTPException { return getOTP().getPin(); } diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java index bc044b9..6490088 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/otplist/OTPListAdapter.java @@ -1,8 +1,11 @@ package com.cringe_studios.cringe_authenticator.otplist; import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.os.Handler; import android.os.Looper; +import android.util.Base64; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -12,14 +15,20 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; +import com.caverock.androidsvg.SVG; +import com.caverock.androidsvg.SVGParseException; import com.cringe_studios.cringe_authenticator.BaseActivity; import com.cringe_studios.cringe_authenticator.R; import com.cringe_studios.cringe_authenticator.databinding.OtpCodeBinding; +import com.cringe_studios.cringe_authenticator.icon.Icon; +import com.cringe_studios.cringe_authenticator.icon.IconPack; +import com.cringe_studios.cringe_authenticator.icon.IconUtil; import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator_library.OTPException; import com.cringe_studios.cringe_authenticator_library.OTPType; +import java.io.ByteArrayInputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -75,6 +84,46 @@ public class OTPListAdapter extends RecyclerView.Adapter { holder.getBinding().progress.setVisibility(data.getType() == OTPType.TOTP ? View.VISIBLE : View.GONE); holder.getBinding().refresh.setVisibility(data.getType() == OTPType.HOTP ? View.VISIBLE : View.GONE); + List packs = IconUtil.loadAllIconPacks(context); + + byte[] imageBytes = null; + String imageData = holder.getOTPData().getImageData(); + if(!OTPData.IMAGE_DATA_NONE.equals(imageData)) { + if (imageData == null) { + for (IconPack pack : packs) { + Icon ic = pack.findIconForIssuer(holder.getOTPData().getIssuer()); + if (ic != null) { + imageBytes = ic.getBytes(); + // TODO: save new icon + break; + } + } + } else { + try { + imageBytes = Base64.decode(imageData, Base64.DEFAULT); + }catch(IllegalArgumentException ignored) { + // Just use default icon + } + } + } + + if(imageBytes == null) { + String issuer = data.getIssuer() != null ? data.getIssuer() : data.getName(); + holder.getBinding().otpCodeIcon.setImageBitmap(IconUtil.generateCodeImage(issuer)); + }else { + Bitmap bm = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length); + if(bm != null) { + holder.getBinding().otpCodeIcon.setImageBitmap(bm); + }else { + try { + SVG svg = SVG.getFromInputStream(new ByteArrayInputStream(imageBytes)); + holder.getBinding().otpCodeIcon.setSVG(svg); + }catch(SVGParseException e) { + holder.getBinding().otpCodeIcon.setImageBitmap(IconUtil.generateCodeImage(data.getIssuer())); + } + } + } + holder.getBinding().refresh.setOnClickListener(view -> { if (data.getType() != OTPType.HOTP) return; diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/StyledDialogBuilder.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/StyledDialogBuilder.java index 06c5ce0..a9d6146 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/util/StyledDialogBuilder.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/util/StyledDialogBuilder.java @@ -3,7 +3,7 @@ package com.cringe_studios.cringe_authenticator.util; import android.content.Context; import android.content.res.TypedArray; -import android.util.Log; +import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -27,10 +27,11 @@ public class StyledDialogBuilder extends AlertDialog.Builder { TypedArray arr = dialog.getContext().obtainStyledAttributes(new int[] {R.attr.dialogBackground}); try { - Log.d("WINDOW", dialog.getWindow().getClass().toString()); dialog.getWindow().setBackgroundDrawable(arr.getDrawable(0)); - //dialog.getWindow().getContext().getResources().obtainAttributes().getDrawable() - //R.attr.dialogBackground; + + if(SettingsUtil.isScreenSecurity(getContext())) { + dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + } }finally { arr.close(); } diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 92d1be6..fbeeba1 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -60,5 +60,13 @@ android:layout_marginEnd="10dp" android:text="Developed by Cringe Studios and JG-Cody" android:textAlignment="center" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_menu.xml b/app/src/main/res/layout/fragment_menu.xml deleted file mode 100644 index 8822237..0000000 --- a/app/src/main/res/layout/fragment_menu.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 52e960b..258c94e 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto" tools:context=".fragment.HomeFragment"> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/otp_code.xml b/app/src/main/res/layout/otp_code.xml index 67d1819..ef1d9f4 100644 --- a/app/src/main/res/layout/otp_code.xml +++ b/app/src/main/res/layout/otp_code.xml @@ -18,14 +18,14 @@ android:paddingBottom="5dp" android:background="?attr/buttonBackground"> - + android:src="@drawable/cringeauth_white" />