Implement icon packs (WIP), Add FLAG_SECURE to dialogs

This commit is contained in:
MrLetsplay 2023-09-30 18:38:39 +02:00
parent 80ffea7a10
commit 1ee9a6fabe
Signed by: mr
SSH Key Fingerprint: SHA256:92jBH80vpXyaZHjaIl47pjRq+Yt7XGTArqQg1V7hSqg
15 changed files with 468 additions and 53 deletions

View File

@ -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'
}

View File

@ -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<Uri> pickBackupFileLoadCallback;
private ActivityResultLauncher<String[]> 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;

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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<Integer> 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<String, IconPack> 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<IconPack> loadAllIconPacks(Context context) {
File iconPacksDir = getIconPacksDir(context);
String[] packIDs = iconPacksDir.list();
if(packIDs == null) return Collections.emptyList();
List<IconPack> 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<String, byte[]> 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;
}
}

View File

@ -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();
}

View File

@ -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<OTPListItem> {
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<IconPack> 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;

View File

@ -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();
}

View File

@ -60,5 +60,13 @@
android:layout_marginEnd="10dp"
android:text="Developed by Cringe Studios and JG-Cody"
android:textAlignment="center" />
<Space
android:layout_width="match_parent"
android:layout_height="76dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/about_text" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragment.HomeFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--<LinearLayout
android:id="@+id/menuItems"
android:layout_width="0dp"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_editor_absoluteY="16dp">
<Switch
android:id="@+id/editSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Edit" />
</LinearLayout>-->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/menu_items"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<Space
android:layout_width="0dp"
android:layout_height="76dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/menu_items" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -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">
<LinearLayout
@ -130,5 +131,38 @@
android:layout_marginTop="10dp"
android:textAllCaps="false" />
<View
android:layout_width="match_parent"
android:background="@color/background_light_grey"
android:layout_height="1dp"
android:layout_marginVertical="10dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Icon Packs" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/settings_load_icon_pack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/buttonBackground"
android:text="Import icon pack"
android:layout_marginTop="10dp"
android:textAllCaps="false" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/settings_manage_icon_packs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/buttonBackground"
android:text="Manage icon packs"
android:layout_marginTop="10dp"
android:textAllCaps="false" />
<Space
android:layout_width="match_parent"
android:layout_height="76dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -18,14 +18,14 @@
android:paddingBottom="5dp"
android:background="?attr/buttonBackground">
<ImageView
android:id="@+id/imageView5"
<com.caverock.androidsvg.SVGImageView
android:id="@+id/otp_code_icon"
android:layout_width="60dp"
android:layout_height="match_parent"
android:paddingLeft="10dp"
android:paddingEnd="10dp"
android:scaleType="centerInside"
app:srcCompat="@drawable/cringeauth_white" />
android:src="@drawable/cringeauth_white" />
<LinearLayout
android:layout_width="match_parent"