Add OTP edit fragment, Icon picker (WIP)

This commit is contained in:
MrLetsplay 2023-09-30 23:40:15 +02:00
parent 1ee9a6fabe
commit 0352d9c777
Signed by: mr
SSH Key Fingerprint: SHA256:92jBH80vpXyaZHjaIl47pjRq+Yt7XGTArqQg1V7hSqg
16 changed files with 683 additions and 70 deletions

View File

@ -93,6 +93,8 @@ dependencies {
implementation "androidx.camera:camera-view:${camerax_version}" implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-extensions:${camerax_version}" implementation "androidx.camera:camera-extensions:${camerax_version}"
implementation "androidx.exifinterface:exifinterface:1.3.6"
implementation 'com.google.protobuf:protobuf-javalite:3.24.3' implementation 'com.google.protobuf:protobuf-javalite:3.24.3'
implementation 'org.bouncycastle:bcprov-jdk15on:1.70' implementation 'org.bouncycastle:bcprov-jdk15on:1.70'

View File

@ -34,7 +34,8 @@
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.CringeAuthenticator.None" android:theme="@style/Theme.CringeAuthenticator.None"
android:configChanges="orientation|screenSize"> android:configChanges="orientation|screenSize"
android:windowSoftInputMode="adjustPan" >
</activity> </activity>
<activity android:name=".unlock.UnlockActivity" <activity android:name=".unlock.UnlockActivity"
android:exported="false" android:exported="false"

View File

@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment;
import com.cringe_studios.cringe_authenticator.databinding.ActivityMainBinding; import com.cringe_studios.cringe_authenticator.databinding.ActivityMainBinding;
import com.cringe_studios.cringe_authenticator.databinding.DialogInputCodeChoiceBinding; import com.cringe_studios.cringe_authenticator.databinding.DialogInputCodeChoiceBinding;
import com.cringe_studios.cringe_authenticator.fragment.AboutFragment; import com.cringe_studios.cringe_authenticator.fragment.AboutFragment;
import com.cringe_studios.cringe_authenticator.fragment.EditOTPFragment;
import com.cringe_studios.cringe_authenticator.fragment.GroupFragment; import com.cringe_studios.cringe_authenticator.fragment.GroupFragment;
import com.cringe_studios.cringe_authenticator.fragment.HomeFragment; import com.cringe_studios.cringe_authenticator.fragment.HomeFragment;
import com.cringe_studios.cringe_authenticator.fragment.NamedFragment; import com.cringe_studios.cringe_authenticator.fragment.NamedFragment;
@ -61,6 +62,10 @@ public class MainActivity extends BaseActivity {
private ActivityResultLauncher<String[]> pickIconPackFileLoad; private ActivityResultLauncher<String[]> pickIconPackFileLoad;
private ActivityResultLauncher<PickVisualMediaRequest> pickIconImage;
private Consumer<Uri> pickIconImageCallback;
private QRScanner qrScanner; private QRScanner qrScanner;
private boolean fullyLaunched; private boolean fullyLaunched;
@ -137,6 +142,8 @@ public class MainActivity extends BaseActivity {
}); });
pickIconPackFileLoad = registerForActivityResult(new ActivityResultContracts.OpenDocument(), doc -> { pickIconPackFileLoad = registerForActivityResult(new ActivityResultContracts.OpenDocument(), doc -> {
lockOnStop = true;
try { try {
if(doc == null) return; if(doc == null) return;
IconPackMetadata meta = IconUtil.importIconPack(this, doc); IconPackMetadata meta = IconUtil.importIconPack(this, doc);
@ -146,6 +153,13 @@ public class MainActivity extends BaseActivity {
} }
}); });
pickIconImage = registerForActivityResult(new ActivityResultContracts.PickVisualMedia(), img -> {
if(pickIconImageCallback != null) {
pickIconImageCallback.accept(img);
pickIconImageCallback = null;
}
});
if(SettingsUtil.isFirstLaunch(this) && SettingsUtil.getGroups(this).isEmpty()) { if(SettingsUtil.isFirstLaunch(this) && SettingsUtil.getGroups(this).isEmpty()) {
SettingsUtil.addGroup(this, UUID.randomUUID().toString(), "My Codes"); SettingsUtil.addGroup(this, UUID.randomUUID().toString(), "My Codes");
SettingsUtil.setFirstLaunch(this, false); SettingsUtil.setFirstLaunch(this, false);
@ -194,6 +208,11 @@ public class MainActivity extends BaseActivity {
return true; return true;
} }
if(fragment instanceof EditOTPFragment) {
getMenuInflater().inflate(R.menu.menu_edit_otp, menu);
return true;
}
getMenuInflater().inflate(R.menu.menu_main, menu); getMenuInflater().inflate(R.menu.menu_main, menu);
return true; return true;
} }
@ -224,6 +243,11 @@ public class MainActivity extends BaseActivity {
} }
} }
if(fragment instanceof EditOTPFragment) {
((EditOTPFragment) fragment).cancel();
return;
}
if(!(fragment instanceof HomeFragment)) { if(!(fragment instanceof HomeFragment)) {
NavigationUtil.navigate(this, HomeFragment.class, null); NavigationUtil.navigate(this, HomeFragment.class, null);
} }
@ -331,6 +355,20 @@ public class MainActivity extends BaseActivity {
} }
} }
public void saveOTP(MenuItem item) {
Fragment frag = NavigationUtil.getCurrentFragment(this);
if(frag instanceof EditOTPFragment) {
((EditOTPFragment) frag).save();
}
}
public void cancelEditingOTP(MenuItem item) {
Fragment frag = NavigationUtil.getCurrentFragment(this);
if(frag instanceof EditOTPFragment) {
((EditOTPFragment) frag).cancel();
}
}
public void lockApp(MenuItem item) { public void lockApp(MenuItem item) {
OTPDatabase.unloadDatabase(); OTPDatabase.unloadDatabase();
OTPDatabase.promptLoadDatabase(this, () -> {}, () -> {}); OTPDatabase.promptLoadDatabase(this, () -> {}, () -> {});
@ -359,6 +397,17 @@ public class MainActivity extends BaseActivity {
pickIconPackFileLoad.launch(new String[]{"application/zip", "*/*"}); pickIconPackFileLoad.launch(new String[]{"application/zip", "*/*"});
} }
public void promptPickIconImage(Consumer<Uri> callback) {
this.lockOnStop = false;
this.pickIconImageCallback = uri -> {
lockOnStop = true;
callback.accept(uri);
};
pickIconImage.launch(new PickVisualMediaRequest.Builder()
.setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE)
.build());
}
@Override @Override
public void recreate() { public void recreate() {
lockOnStop = false; lockOnStop = false;

View File

@ -0,0 +1,293 @@
package com.cringe_studios.cringe_authenticator.fragment;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Base64;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import androidx.exifinterface.media.ExifInterface;
import com.cringe_studios.cringe_authenticator.MainActivity;
import com.cringe_studios.cringe_authenticator.R;
import com.cringe_studios.cringe_authenticator.databinding.FragmentEditOtpBinding;
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.util.IOUtil;
import com.cringe_studios.cringe_authenticator.util.NavigationUtil;
import com.cringe_studios.cringe_authenticator.util.StyledDialogBuilder;
import com.cringe_studios.cringe_authenticator.util.ThemeUtil;
import com.cringe_studios.cringe_authenticator_library.OTPAlgorithm;
import com.cringe_studios.cringe_authenticator_library.OTPType;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
public class EditOTPFragment extends NamedFragment {
private static final Integer[] DIGITS = new Integer[]{6, 7, 8, 9, 10, 11, 12};
private static final String[] TYPES = new String[]{
OTPType.HOTP.getFriendlyName() + " (HOTP)",
OTPType.TOTP.getFriendlyName() + " (TOTP)"
};
private FragmentEditOtpBinding binding;
private OTPData data;
private String imageData;
private boolean view;
private Consumer<OTPData> callback;
public EditOTPFragment(OTPData data, boolean view, Consumer<OTPData> callback) {
this.data = data;
this.view = view;
this.callback = callback;
}
@Override
public String getName() {
return requireActivity().getString(R.string.fragment_edit_otp);
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentEditOtpBinding.inflate(inflater);
@DrawableRes int bg = ThemeUtil.getBackground(requireContext());
if(bg != 0) {
binding.getRoot().setBackgroundResource(bg);
}
IconUtil.loadEffectiveImage(requireContext(), data, binding.inputImage, null);
binding.inputImage.setOnClickListener(v -> {
new StyledDialogBuilder(requireContext())
.setTitle("Choose Image")
.setItems(new String[]{"Image from icon pack", "Image from gallery", "No image", "Reset to default image"}, (d, which) -> {
switch(which) {
case 0:
// TODO: pick from icon pack
break;
case 1:
pickGalleryImage();
break;
case 2:
setNoImage();
break;
case 3:
setDefaultImage();
break;
}
})
.show();
});
binding.inputType.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, TYPES));
binding.inputType.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
OTPType newType = OTPType.values()[position];
switch(newType) {
case HOTP:
binding.textPeriod.setVisibility(View.GONE);
binding.inputPeriod.setVisibility(View.GONE);
binding.textCounter.setVisibility(View.VISIBLE);
binding.inputCounter.setVisibility(View.VISIBLE);
break;
case TOTP:
binding.textCounter.setVisibility(View.GONE);
binding.inputCounter.setVisibility(View.GONE);
binding.textPeriod.setVisibility(View.VISIBLE);
binding.inputPeriod.setVisibility(View.VISIBLE);
break;
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {}
});
binding.inputType.setEnabled(!view);
binding.inputType.setSelection(data != null ? data.getType().ordinal() : 0);
binding.inputAlgorithm.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, OTPAlgorithm.values()));
binding.inputAlgorithm.setEnabled(!view);
binding.inputDigits.setAdapter(new ArrayAdapter<>(requireContext(), android.R.layout.simple_list_item_1, DIGITS));
binding.inputDigits.setEnabled(!view);
TextWatcher watcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {}
@Override
public void afterTextChanged(Editable s) {
updateImage();
}
};
binding.inputName.setEnabled(!view);
binding.inputName.addTextChangedListener(watcher);
binding.inputIssuer.setEnabled(!view);
binding.inputIssuer.addTextChangedListener(watcher);
binding.inputSecret.setEnabled(!view);
binding.inputPeriod.setEnabled(!view);
binding.inputChecksum.setEnabled(!view);
if(data != null) {
imageData = data.getImageData();
binding.inputName.setText(data.getName());
binding.inputIssuer.setText(data.getIssuer());
binding.inputSecret.setText(data.getSecret());
binding.inputAlgorithm.setSelection(data.getAlgorithm().ordinal());
int index = Arrays.asList(DIGITS).indexOf(data.getDigits());
if(index != -1) binding.inputDigits.setSelection(index);
binding.inputChecksum.setChecked(data.hasChecksum());
switch(data.getType()) {
case HOTP:
binding.inputCounter.setText(String.valueOf(data.getCounter()));
break;
case TOTP:
binding.inputPeriod.setText(String.valueOf(data.getPeriod()));
break;
}
}
return binding.getRoot();
}
private void updateImage() {
if(imageData != null && !imageData.equals(OTPData.IMAGE_DATA_NONE)) return;
IconUtil.loadEffectiveImage(requireContext(), imageData, binding.inputIssuer.getText().toString(), binding.inputName.getText().toString(), binding.inputImage, null);
}
private void pickGalleryImage() {
((MainActivity) requireActivity()).promptPickIconImage(uri -> {
if(uri == null) return;
try(InputStream in = requireActivity().getContentResolver().openInputStream(uri)) {
if(in == null) return;
byte[] bytes = IOUtil.readBytes(in);
Bitmap bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
ExifInterface i = new ExifInterface(new ByteArrayInputStream(bytes));
int orientation = i.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
Matrix matrix = new Matrix();
switch(orientation) {
case ExifInterface.ORIENTATION_ROTATE_270:
matrix.postRotate(90);
case ExifInterface.ORIENTATION_ROTATE_180:
matrix.postRotate(90);
case ExifInterface.ORIENTATION_ROTATE_90:
matrix.postRotate(90);
bm = Bitmap.createBitmap(bm, 0, 0, bm.getWidth(), bm.getHeight(), matrix, true);
}
bm = IconUtil.cutToIcon(bm);
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
bm.compress(Bitmap.CompressFormat.PNG, 100, bOut);
imageData = Base64.encodeToString(bOut.toByteArray(), Base64.DEFAULT);
binding.inputImage.setImageBitmap(bm);
}catch(IOException e) {
DialogUtil.showErrorDialog(requireContext(), "Failed to open image", e);
}
});
}
private void setNoImage() {
imageData = OTPData.IMAGE_DATA_NONE;
updateImage();
}
private void setDefaultImage() {
imageData = null;
updateImage();
}
public boolean isView() {
return view;
}
public void cancel() {
NavigationUtil.closeOverlay(this);
}
public void save() {
try {
String name = binding.inputName.getText().toString();
if(name.trim().isEmpty()) {
DialogUtil.showErrorDialog(requireContext(), requireContext().getString(R.string.otp_add_missing_name));
return;
}
String issuer = binding.inputIssuer.getText().toString();
if(issuer.trim().isEmpty()) {
issuer = null;
}
String secret = binding.inputSecret.getText().toString();
OTPAlgorithm algorithm = (OTPAlgorithm) binding.inputAlgorithm.getSelectedItem();
int digits = (int) binding.inputDigits.getSelectedItem();
boolean checksum = binding.inputChecksum.isChecked();
int period = 0;
int counter = 0;
OTPType type = OTPType.values()[binding.inputType.getSelectedItemPosition()];
switch(type) {
case HOTP:
counter = Integer.parseInt(binding.inputCounter.getText().toString());
if(counter < 0) throw new NumberFormatException();
break;
case TOTP:
period = Integer.parseInt(binding.inputPeriod.getText().toString());
if(period <= 0) throw new NumberFormatException();
break;
}
OTPData data = new OTPData(name, issuer, type, secret, algorithm, digits, period, counter, checksum);
data.setImageData(imageData);
String errorMessage = data.validate();
if(errorMessage != null) {
DialogUtil.showErrorDialog(requireContext(), errorMessage);
return;
}
callback.accept(data);
NavigationUtil.closeOverlay(this);
}catch(NumberFormatException e) {
DialogUtil.showErrorDialog(requireContext(), requireContext().getString(R.string.input_code_invalid_number));
}
}
}

View File

@ -17,6 +17,7 @@ import com.cringe_studios.cringe_authenticator.model.OTPData;
import com.cringe_studios.cringe_authenticator.otplist.OTPListAdapter; import com.cringe_studios.cringe_authenticator.otplist.OTPListAdapter;
import com.cringe_studios.cringe_authenticator.otplist.OTPListItem; import com.cringe_studios.cringe_authenticator.otplist.OTPListItem;
import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.DialogUtil;
import com.cringe_studios.cringe_authenticator.util.NavigationUtil;
import com.cringe_studios.cringe_authenticator.util.OTPDatabase; import com.cringe_studios.cringe_authenticator.util.OTPDatabase;
import com.cringe_studios.cringe_authenticator.util.OTPDatabaseException; import com.cringe_studios.cringe_authenticator.util.OTPDatabaseException;
import com.cringe_studios.cringe_authenticator.util.SettingsUtil; import com.cringe_studios.cringe_authenticator.util.SettingsUtil;
@ -133,11 +134,11 @@ public class GroupFragment extends NamedFragment {
if(items.size() != 1) return; if(items.size() != 1) return;
OTPData data = items.get(0).getOTPData(); OTPData data = items.get(0).getOTPData();
DialogUtil.showEditCodeDialog(getLayoutInflater(), data, newData -> { NavigationUtil.openOverlay(this, new EditOTPFragment(data, false, newData -> {
otpListAdapter.replace(data, newData); otpListAdapter.replace(data, newData);
saveOTPs(); saveOTPs();
otpListAdapter.finishEditing(); otpListAdapter.finishEditing();
}); }));
} }
public void moveOTP() { public void moveOTP() {

View File

@ -2,16 +2,26 @@ package com.cringe_studios.cringe_authenticator.icon;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect; import android.graphics.Rect;
import android.net.Uri; 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.cringe_studios.cringe_authenticator.model.OTPData;
import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.DialogUtil;
import com.cringe_studios.cringe_authenticator.util.IOUtil; import com.cringe_studios.cringe_authenticator.util.IOUtil;
import com.cringe_studios.cringe_authenticator.util.SettingsUtil; import com.cringe_studios.cringe_authenticator.util.SettingsUtil;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
@ -30,6 +40,8 @@ import java.util.zip.ZipInputStream;
public class IconUtil { public class IconUtil {
private static final int ICON_SIZE = 128;
// Source: https://sashamaps.net/docs/resources/20-colors/ // Source: https://sashamaps.net/docs/resources/20-colors/
private static final List<Integer> DISTINCT_COLORS = Collections.unmodifiableList(Arrays.asList( private static final List<Integer> DISTINCT_COLORS = Collections.unmodifiableList(Arrays.asList(
Color.parseColor("#e6194B"), // Red Color.parseColor("#e6194B"), // Red
@ -189,16 +201,17 @@ public class IconUtil {
return entryBytes; return entryBytes;
} }
public static Bitmap generateCodeImage(String issuer) { public static Bitmap generateCodeImage(String issuer, String name) {
int imageSize = 128; if(issuer == null || issuer.isEmpty()) issuer = name;
if(issuer == null || issuer.isEmpty()) issuer = "?";
Bitmap b = Bitmap.createBitmap(imageSize, imageSize, Bitmap.Config.ARGB_8888); Bitmap b = Bitmap.createBitmap(ICON_SIZE, ICON_SIZE, Bitmap.Config.ARGB_8888);
Canvas c = new Canvas(b); Canvas c = new Canvas(b);
Paint p = new Paint(); Paint p = new Paint();
p.setColor(DISTINCT_COLORS.get(Math.abs(issuer.hashCode()) % DISTINCT_COLORS.size())); p.setColor(DISTINCT_COLORS.get(Math.abs(issuer.hashCode()) % DISTINCT_COLORS.size()));
p.setStyle(Paint.Style.FILL); p.setStyle(Paint.Style.FILL);
c.drawCircle(imageSize / 2, imageSize / 2, imageSize / 2, p); c.drawCircle(ICON_SIZE / 2, ICON_SIZE / 2, ICON_SIZE / 2, p);
p.setColor(Color.WHITE); p.setColor(Color.WHITE);
p.setAntiAlias(true); p.setAntiAlias(true);
@ -207,7 +220,83 @@ public class IconUtil {
String text = issuer.substring(0, 1); String text = issuer.substring(0, 1);
Rect r = new Rect(); Rect r = new Rect();
p.getTextBounds(text, 0, text.length(), r); p.getTextBounds(text, 0, text.length(), r);
c.drawText(text, imageSize / 2 - r.exactCenterX(), imageSize / 2 - r.exactCenterY(), p); 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<String> setOTPImage) {
List<IconPack> packs = IconUtil.loadAllIconPacks(context);
byte[] imageBytes = null;
if(!OTPData.IMAGE_DATA_NONE.equals(imageData)) {
if (imageData == null) {
for (IconPack pack : packs) {
Icon ic = pack.findIconForIssuer(issuer);
if (ic != null) {
imageBytes = ic.getBytes();
if(setOTPImage != null) {
setOTPImage.accept(Base64.encodeToString(imageBytes, Base64.DEFAULT));
}
break;
}
}
} else {
try {
imageBytes = Base64.decode(imageData, Base64.DEFAULT);
}catch(IllegalArgumentException ignored) {
// Just use default icon
}
}
}
if(imageBytes == null) {
view.setImageBitmap(IconUtil.generateCodeImage(issuer, name));
}else {
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) {
view.setImageBitmap(IconUtil.generateCodeImage(issuer, name));
}
}
}
}
public static void loadEffectiveImage(Context context, OTPData data, SVGImageView view, Runnable saveOTP) {
loadEffectiveImage(context, data.getImageData(), data.getIssuer(), data.getName(), view, saveOTP == null ? null : newData -> {
data.setImageData(newData);
saveOTP.run();
});
}
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; return b;
} }

View File

@ -1,11 +1,8 @@
package com.cringe_studios.cringe_authenticator.otplist; package com.cringe_studios.cringe_authenticator.otplist;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Base64;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -15,20 +12,15 @@ import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; 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.BaseActivity;
import com.cringe_studios.cringe_authenticator.R; import com.cringe_studios.cringe_authenticator.R;
import com.cringe_studios.cringe_authenticator.databinding.OtpCodeBinding; 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.icon.IconUtil;
import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator.model.OTPData;
import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.DialogUtil;
import com.cringe_studios.cringe_authenticator_library.OTPException; import com.cringe_studios.cringe_authenticator_library.OTPException;
import com.cringe_studios.cringe_authenticator_library.OTPType; import com.cringe_studios.cringe_authenticator_library.OTPType;
import java.io.ByteArrayInputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -84,45 +76,7 @@ public class OTPListAdapter extends RecyclerView.Adapter<OTPListItem> {
holder.getBinding().progress.setVisibility(data.getType() == OTPType.TOTP ? View.VISIBLE : View.GONE); holder.getBinding().progress.setVisibility(data.getType() == OTPType.TOTP ? View.VISIBLE : View.GONE);
holder.getBinding().refresh.setVisibility(data.getType() == OTPType.HOTP ? View.VISIBLE : View.GONE); holder.getBinding().refresh.setVisibility(data.getType() == OTPType.HOTP ? View.VISIBLE : View.GONE);
List<IconPack> packs = IconUtil.loadAllIconPacks(context); IconUtil.loadEffectiveImage(context, holder.getOTPData(), holder.getBinding().otpCodeIcon, saveOTPs);
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 -> { holder.getBinding().refresh.setOnClickListener(view -> {
if (data.getType() != OTPType.HOTP) return; if (data.getType() != OTPType.HOTP) return;

View File

@ -15,15 +15,22 @@ public class NavigationUtil {
// TODO: check if this still works after changes // TODO: check if this still works after changes
private static void updateActivity(AppCompatActivity activity, NamedFragment newFragment) {
ActionBar bar = activity.getSupportActionBar();
if(newFragment == null) newFragment = (NamedFragment) getCurrentFragment(activity.getSupportFragmentManager());
if(bar != null) bar.setTitle(newFragment.getName());
activity.invalidateMenu();
}
public static void navigate(AppCompatActivity activity, NamedFragment fragment) {
FragmentManager manager = activity.getSupportFragmentManager();
navigate(manager, fragment, () -> updateActivity(activity, fragment));
}
public static void navigate(AppCompatActivity activity, Class<? extends NamedFragment> fragmentClass, Bundle args) { public static void navigate(AppCompatActivity activity, Class<? extends NamedFragment> fragmentClass, Bundle args) {
FragmentManager manager = activity.getSupportFragmentManager(); FragmentManager manager = activity.getSupportFragmentManager();
NamedFragment fragment = instantiateFragment(manager, fragmentClass, args); NamedFragment fragment = instantiateFragment(manager, fragmentClass, args);
navigate(activity, fragment);
ActionBar bar = activity.getSupportActionBar();
navigate(manager, fragment, () -> {
if(bar != null) bar.setTitle(fragment.getName());
activity.invalidateMenu();
});
} }
public static void openMenu(AppCompatActivity activity, Bundle args) { public static void openMenu(AppCompatActivity activity, Bundle args) {
@ -37,6 +44,26 @@ public class NavigationUtil {
navigate((AppCompatActivity) currentFragment.requireActivity(), fragmentClass, args); navigate((AppCompatActivity) currentFragment.requireActivity(), fragmentClass, args);
} }
public static void openOverlay(Fragment currentFragment, NamedFragment overlay) {
AppCompatActivity activity = (AppCompatActivity) currentFragment.requireActivity();
FragmentManager manager = activity.getSupportFragmentManager();
manager.beginTransaction()
.setReorderingAllowed(true)
.add(R.id.nav_host_fragment_content_main, overlay)
.runOnCommit(() -> updateActivity(activity, overlay))
.commit();
}
public static void closeOverlay(Fragment currentFragment) {
AppCompatActivity activity = (AppCompatActivity) currentFragment.requireActivity();
FragmentManager manager = activity.getSupportFragmentManager();
manager.beginTransaction()
.setReorderingAllowed(true)
.remove(currentFragment)
.runOnCommit(() -> updateActivity(activity, null))
.commit();
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static <T extends Fragment> T instantiateFragment(FragmentManager manager, Class<? extends T> fragmentClass, Bundle args) { private static <T extends Fragment> T instantiateFragment(FragmentManager manager, Class<? extends T> fragmentClass, Bundle args) {
T fragment = (T) manager.getFragmentFactory().instantiate(ClassLoader.getSystemClassLoader(), fragmentClass.getName()); T fragment = (T) manager.getFragmentFactory().instantiate(ClassLoader.getSystemClassLoader(), fragmentClass.getName());

View File

@ -1,8 +1,10 @@
package com.cringe_studios.cringe_authenticator.util; package com.cringe_studios.cringe_authenticator.util;
import android.content.Context;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.view.View; import android.view.View;
import androidx.annotation.DrawableRes;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.app.AppCompatDelegate;
@ -21,12 +23,13 @@ public class ThemeUtil {
AppCompatDelegate.setDefaultNightMode(SettingsUtil.getAppearance(activity).getValue()); AppCompatDelegate.setDefaultNightMode(SettingsUtil.getAppearance(activity).getValue());
} }
public static void loadBackground(AppCompatActivity activity) { @DrawableRes
if(!SettingsUtil.isThemedBackgroundEnabled(activity)) return; public static int getBackground(Context context) {
if(!SettingsUtil.isThemedBackgroundEnabled(context)) return 0;
Theme theme = SettingsUtil.getTheme(activity); Theme theme = SettingsUtil.getTheme(context);
int nightMode = activity.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; int nightMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
boolean isNightMode; boolean isNightMode;
switch(nightMode) { switch(nightMode) {
case Configuration.UI_MODE_NIGHT_NO: case Configuration.UI_MODE_NIGHT_NO:
@ -38,9 +41,16 @@ public class ThemeUtil {
break; 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); View v = activity.findViewById(R.id.app_background);
if(v != null) { if(v != null) {
v.setBackgroundResource(!isNightMode ? theme.getLightBackground() : theme.getDarkBackground()); v.setBackgroundResource(background);
} }
} }

View File

@ -1,10 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item> <item>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape>
<stroke
android:width="0dp"
android:color="#FFFFFF" />
<solid android:color="?attr/colorOnBackground" /> <solid android:color="?attr/colorOnBackground" />
<corners <corners
android:bottomLeftRadius="20dp" android:bottomLeftRadius="20dp"

View File

@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
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
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="?android:attr/colorBackground">
<com.caverock.androidsvg.SVGImageView
android:id="@+id/input_image"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center_horizontal"
tools:src="@drawable/baseline_edit_24" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/otp_add_type" />
<Spinner
android:id="@+id/input_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_name" />
<EditText
android:id="@+id/input_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="text"
android:hint="@string/otp_add_name"
android:autofillHints="" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_issuer" />
<EditText
android:id="@+id/input_issuer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="text"
android:hint="@string/otp_add_issuer"
android:autofillHints="" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_secret" />
<EditText
android:id="@+id/input_secret"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="text"
android:hint="@string/otp_add_secret"
android:autofillHints="" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_algorithm" />
<Spinner
android:id="@+id/input_algorithm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_digits" />
<Spinner
android:id="@+id/input_digits"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
<CheckBox
android:id="@+id/input_checksum"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/otp_add_checksum" />
<TextView
android:id="@+id/text_period"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_period" />
<EditText
android:id="@+id/input_period"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="number"
android:hint="@string/otp_add_period"
android:autofillHints="" />
<TextView
android:id="@+id/text_counter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/otp_add_counter" />
<EditText
android:id="@+id/input_counter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="number"
android:hint="@string/otp_add_counter"
android:autofillHints="" />
<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

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_cancel"
android:orderInCategory="100"
android:title="Cancel"
android:onClick="cancelEditingOTP"
app:showAsAction="always" />
<item
android:id="@+id/action_save"
android:orderInCategory="100"
android:title="Save"
android:onClick="saveOTP"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_cancel"
android:orderInCategory="100"
android:title="Close"
android:onClick="cancelEditingOTP"
app:showAsAction="always" />
</menu>

View File

@ -75,6 +75,7 @@
<string name="fragment_home">Startseite</string> <string name="fragment_home">Startseite</string>
<string name="fragment_settings">Einstellungen</string> <string name="fragment_settings">Einstellungen</string>
<string name="fragment_about">Über</string> <string name="fragment_about">Über</string>
<string name="fragment_edit_otp">OTP bearbeiten</string>
<string name="theme_blue_green">Blau/Grün</string> <string name="theme_blue_green">Blau/Grün</string>
<string name="theme_red_blue">Rot/Blau</string> <string name="theme_red_blue">Rot/Blau</string>
<string name="theme_pink_green">Pink/Grün</string> <string name="theme_pink_green">Pink/Grün</string>

View File

@ -66,6 +66,7 @@
<string name="fragment_home">Home</string> <string name="fragment_home">Home</string>
<string name="fragment_settings">Settings</string> <string name="fragment_settings">Settings</string>
<string name="fragment_about">About</string> <string name="fragment_about">About</string>
<string name="fragment_edit_otp">Edit OTP</string>
<string-array name="view_edit_move_delete"> <string-array name="view_edit_move_delete">
<item>View</item> <item>View</item>
<item>Edit</item> <item>Edit</item>
@ -99,4 +100,7 @@
<string name="disable_encryption_message">Do you really want to disable encryption?</string> <string name="disable_encryption_message">Do you really want to disable encryption?</string>
<string name="load_backup_title">Load backup</string> <string name="load_backup_title">Load backup</string>
<string name="backup_load_message">Do you want to load this backup?\n\nThis will delete ALL of the current data in the app and replace it with the data from the backup!</string> <string name="backup_load_message">Do you want to load this backup?\n\nThis will delete ALL of the current data in the app and replace it with the data from the backup!</string>
<string name="otp_add_type">Type</string>
<string name="otp_add_algorithm">Algorithm</string>
<string name="otp_add_digits">Digits</string>
</resources> </resources>

View File

@ -14,6 +14,8 @@
<item name="dialogBackground">@drawable/dialog_themed</item> <item name="dialogBackground">@drawable/dialog_themed</item>
<item name="menuBackground">@drawable/menu_themed</item> <item name="menuBackground">@drawable/menu_themed</item>
<item name="buttonBackground">@drawable/button_themed</item> <item name="buttonBackground">@drawable/button_themed</item>
<item name="actionMenuTextAppearance">@style/ActionMenuTextAppearance</item>
</style> </style>
<style name="Theme.CringeAuthenticator" parent="Base.Theme.CringeAuthenticator" /> <style name="Theme.CringeAuthenticator" parent="Base.Theme.CringeAuthenticator" />
@ -53,4 +55,8 @@
<style name="ActionPopupMenuStyle" parent="Widget.AppCompat.PopupMenu"> <style name="ActionPopupMenuStyle" parent="Widget.AppCompat.PopupMenu">
<item name="android:popupBackground">?attr/menuBackground</item> <item name="android:popupBackground">?attr/menuBackground</item>
</style> </style>
<style name="ActionMenuTextAppearance" parent="TextAppearance.AppCompat.Widget.ActionBar.Menu">
<item name="android:textAllCaps">false</item>
</style>
</resources> </resources>