0.0.1版本
@@ -2,6 +2,7 @@
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.trae/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cn.airnan.a2fair"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cn.airnan.a2fair"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags += ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.gson)
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-dontwarn androidx.room.paging.**
|
||||
-keep @androidx.room.Entity class *
|
||||
-keep class * {
|
||||
@androidx.room.PrimaryKey <fields>;
|
||||
}
|
||||
|
||||
# CameraX
|
||||
-keep class androidx.camera.core.** { *; }
|
||||
|
||||
# JNI
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keep class cn.airnan.a2fair.core.NativeLib {
|
||||
*;
|
||||
}
|
||||
|
||||
# Base32 and algorithms (in case of reflection or specific needs)
|
||||
-keep class cn.airnan.a2fair.core.algorithm.** { *; }
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<application
|
||||
android:name=".A2FairApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme._2FAir">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme._2FAir"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,26 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("a2fair")
|
||||
|
||||
# Optimization and symbol stripping for Release build
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -flto -fvisibility=hidden -fvisibility-inlines-hidden -fdata-sections -ffunction-sections")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} -flto -Wl,--gc-sections -Wl,-s -Wl,--exclude-libs,ALL")
|
||||
|
||||
add_library(
|
||||
a2fair
|
||||
SHARED
|
||||
native-lib.cpp
|
||||
base32.cpp
|
||||
sha1.cpp
|
||||
sha256.cpp
|
||||
sha512.cpp
|
||||
hmac.cpp
|
||||
otp.cpp)
|
||||
|
||||
find_library(
|
||||
log-lib
|
||||
log)
|
||||
|
||||
target_link_libraries(
|
||||
a2fair
|
||||
${log-lib})
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "base32.h"
|
||||
#include <stdexcept>
|
||||
|
||||
static const char* base32_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
std::vector<uint8_t> base32_decode(const std::string& encoded) {
|
||||
std::vector<uint8_t> decoded;
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
|
||||
for (char c : encoded) {
|
||||
if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '-') continue;
|
||||
if (c == '=') break;
|
||||
|
||||
int val = -1;
|
||||
if (c >= 'A' && c <= 'Z') val = c - 'A';
|
||||
else if (c >= 'a' && c <= 'z') val = c - 'a';
|
||||
else if (c >= '2' && c <= '7') val = c - '2' + 26;
|
||||
|
||||
if (val == -1) {
|
||||
continue; // Ignore invalid characters
|
||||
}
|
||||
|
||||
buffer = (buffer << 5) | val;
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
decoded.push_back(static_cast<uint8_t>((buffer >> (bitsLeft - 8)) & 0xFF));
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
std::string base32_encode(const std::vector<uint8_t>& data) {
|
||||
std::string encoded;
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
|
||||
for (uint8_t b : data) {
|
||||
buffer = (buffer << 8) | b;
|
||||
bitsLeft += 8;
|
||||
|
||||
while (bitsLeft >= 5) {
|
||||
encoded.push_back(base32_chars[(buffer >> (bitsLeft - 5)) & 0x1F]);
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bitsLeft > 0) {
|
||||
encoded.push_back(base32_chars[(buffer << (5 - bitsLeft)) & 0x1F]);
|
||||
}
|
||||
|
||||
while (encoded.length() % 8 != 0) {
|
||||
encoded.push_back('=');
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#ifndef BASE32_H
|
||||
#define BASE32_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
std::vector<uint8_t> base32_decode(const std::string& encoded);
|
||||
std::string base32_encode(const std::vector<uint8_t>& data);
|
||||
|
||||
#endif // BASE32_H
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "hmac.h"
|
||||
#include "sha1.h"
|
||||
#include "sha256.h"
|
||||
#include "sha512.h"
|
||||
#include <cstring>
|
||||
|
||||
template <typename HashType>
|
||||
std::vector<uint8_t> hmac_impl(const std::vector<uint8_t>& key, const std::vector<uint8_t>& message) {
|
||||
std::vector<uint8_t> actual_key = key;
|
||||
|
||||
if (actual_key.size() > HashType::BLOCK_SIZE) {
|
||||
HashType hash;
|
||||
hash.update(actual_key.data(), actual_key.size());
|
||||
actual_key.resize(HashType::HASH_SIZE);
|
||||
hash.finalize(actual_key.data());
|
||||
}
|
||||
|
||||
if (actual_key.size() < HashType::BLOCK_SIZE) {
|
||||
actual_key.resize(HashType::BLOCK_SIZE, 0x00);
|
||||
}
|
||||
|
||||
std::vector<uint8_t> o_key_pad(HashType::BLOCK_SIZE);
|
||||
std::vector<uint8_t> i_key_pad(HashType::BLOCK_SIZE);
|
||||
|
||||
for (size_t i = 0; i < HashType::BLOCK_SIZE; i++) {
|
||||
o_key_pad[i] = actual_key[i] ^ 0x5c;
|
||||
i_key_pad[i] = actual_key[i] ^ 0x36;
|
||||
}
|
||||
|
||||
HashType inner_hash;
|
||||
inner_hash.update(i_key_pad.data(), i_key_pad.size());
|
||||
inner_hash.update(message.data(), message.size());
|
||||
std::vector<uint8_t> inner_result(HashType::HASH_SIZE);
|
||||
inner_hash.finalize(inner_result.data());
|
||||
|
||||
HashType outer_hash;
|
||||
outer_hash.update(o_key_pad.data(), o_key_pad.size());
|
||||
outer_hash.update(inner_result.data(), inner_result.size());
|
||||
std::vector<uint8_t> final_result(HashType::HASH_SIZE);
|
||||
outer_hash.finalize(final_result.data());
|
||||
|
||||
return final_result;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> hmac(HashAlgorithm algo, const std::vector<uint8_t>& key, const std::vector<uint8_t>& message) {
|
||||
switch (algo) {
|
||||
case HashAlgorithm::SHA1:
|
||||
return hmac_impl<SHA1>(key, message);
|
||||
case HashAlgorithm::SHA256:
|
||||
return hmac_impl<SHA256>(key, message);
|
||||
case HashAlgorithm::SHA512:
|
||||
return hmac_impl<SHA512>(key, message);
|
||||
default:
|
||||
return std::vector<uint8_t>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef HMAC_H
|
||||
#define HMAC_H
|
||||
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
enum class HashAlgorithm {
|
||||
SHA1,
|
||||
SHA256,
|
||||
SHA512
|
||||
};
|
||||
|
||||
std::vector<uint8_t> hmac(HashAlgorithm algo, const std::vector<uint8_t>& key, const std::vector<uint8_t>& message);
|
||||
|
||||
#endif // HMAC_H
|
||||
@@ -0,0 +1,80 @@
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "base32.h"
|
||||
#include "otp.h"
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_cn_airnan_a2fair_core_NativeLib_base32Decode(JNIEnv *env, jobject thiz, jstring encoded) {
|
||||
const char *encoded_chars = env->GetStringUTFChars(encoded, nullptr);
|
||||
std::string encoded_str(encoded_chars);
|
||||
env->ReleaseStringUTFChars(encoded, encoded_chars);
|
||||
|
||||
std::vector<uint8_t> decoded = base32_decode(encoded_str);
|
||||
|
||||
jbyteArray result = env->NewByteArray(decoded.size());
|
||||
if (decoded.size() > 0) {
|
||||
env->SetByteArrayRegion(result, 0, decoded.size(), reinterpret_cast<const jbyte*>(decoded.data()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_cn_airnan_a2fair_core_NativeLib_base32Encode(JNIEnv *env, jobject thiz, jbyteArray data) {
|
||||
jsize length = env->GetArrayLength(data);
|
||||
jbyte *bytes = env->GetByteArrayElements(data, nullptr);
|
||||
std::vector<uint8_t> vec_data(bytes, bytes + length);
|
||||
env->ReleaseByteArrayElements(data, bytes, JNI_ABORT);
|
||||
|
||||
std::string encoded = base32_encode(vec_data);
|
||||
return env->NewStringUTF(encoded.c_str());
|
||||
}
|
||||
|
||||
HashAlgorithm getHashAlgo(int algo) {
|
||||
switch (algo) {
|
||||
case 1: return HashAlgorithm::SHA256;
|
||||
case 2: return HashAlgorithm::SHA512;
|
||||
default: return HashAlgorithm::SHA1;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_cn_airnan_a2fair_core_NativeLib_generateHOTP(JNIEnv *env, jobject thiz, jbyteArray secret,
|
||||
jlong counter, jint digits, jint algo) {
|
||||
jsize length = env->GetArrayLength(secret);
|
||||
jbyte *bytes = env->GetByteArrayElements(secret, nullptr);
|
||||
std::vector<uint8_t> vec_secret(bytes, bytes + length);
|
||||
env->ReleaseByteArrayElements(secret, bytes, JNI_ABORT);
|
||||
|
||||
std::string code = OTP::generateHOTP(vec_secret, counter, digits, getHashAlgo(algo));
|
||||
return env->NewStringUTF(code.c_str());
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_cn_airnan_a2fair_core_NativeLib_generateTOTP(JNIEnv *env, jobject thiz, jbyteArray secret,
|
||||
jlong time, jint period, jint digits, jint algo) {
|
||||
jsize length = env->GetArrayLength(secret);
|
||||
jbyte *bytes = env->GetByteArrayElements(secret, nullptr);
|
||||
std::vector<uint8_t> vec_secret(bytes, bytes + length);
|
||||
env->ReleaseByteArrayElements(secret, bytes, JNI_ABORT);
|
||||
|
||||
std::string code = OTP::generateTOTP(vec_secret, time, period, digits, getHashAlgo(algo));
|
||||
return env->NewStringUTF(code.c_str());
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_cn_airnan_a2fair_core_NativeLib_generateSteam(JNIEnv *env, jobject thiz, jbyteArray secret,
|
||||
jlong time) {
|
||||
jsize length = env->GetArrayLength(secret);
|
||||
jbyte *bytes = env->GetByteArrayElements(secret, nullptr);
|
||||
std::vector<uint8_t> vec_secret(bytes, bytes + length);
|
||||
env->ReleaseByteArrayElements(secret, bytes, JNI_ABORT);
|
||||
|
||||
std::string code = OTP::generateSteam(vec_secret, time);
|
||||
return env->NewStringUTF(code.c_str());
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#include "otp.h"
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
std::string OTP::generateHOTP(const std::vector<uint8_t>& secret, uint64_t counter, int digits, HashAlgorithm algo) {
|
||||
std::vector<uint8_t> message(8);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
message[i] = counter & 0xFF;
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> hash = hmac(algo, secret, message);
|
||||
if (hash.empty()) return "";
|
||||
|
||||
int offset = hash.back() & 0x0F;
|
||||
uint32_t binary =
|
||||
((hash[offset] & 0x7f) << 24) |
|
||||
((hash[offset + 1] & 0xff) << 16) |
|
||||
((hash[offset + 2] & 0xff) << 8) |
|
||||
(hash[offset + 3] & 0xff);
|
||||
|
||||
uint32_t otp = binary % static_cast<uint32_t>(std::pow(10, digits));
|
||||
|
||||
std::ostringstream ss;
|
||||
ss << std::setw(digits) << std::setfill('0') << otp;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
std::string OTP::generateTOTP(const std::vector<uint8_t>& secret, uint64_t time, int period, int digits, HashAlgorithm algo) {
|
||||
uint64_t counter = time / period;
|
||||
return generateHOTP(secret, counter, digits, algo);
|
||||
}
|
||||
|
||||
std::string OTP::generateSteam(const std::vector<uint8_t>& secret, uint64_t time) {
|
||||
uint64_t counter = time / 30;
|
||||
std::vector<uint8_t> message(8);
|
||||
for (int i = 7; i >= 0; i--) {
|
||||
message[i] = counter & 0xFF;
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> hash = hmac(HashAlgorithm::SHA1, secret, message);
|
||||
if (hash.empty()) return "";
|
||||
|
||||
int offset = hash.back() & 0x0F;
|
||||
uint32_t binary =
|
||||
((hash[offset] & 0x7f) << 24) |
|
||||
((hash[offset + 1] & 0xff) << 16) |
|
||||
((hash[offset + 2] & 0xff) << 8) |
|
||||
(hash[offset + 3] & 0xff);
|
||||
|
||||
const char steamChars[] = "23456789BCDFGHJKMNPQRTVWXY";
|
||||
std::string code;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
code.push_back(steamChars[binary % 26]);
|
||||
binary /= 26;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#ifndef OTP_H
|
||||
#define OTP_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include "hmac.h"
|
||||
|
||||
class OTP {
|
||||
public:
|
||||
static std::string generateHOTP(const std::vector<uint8_t>& secret, uint64_t counter, int digits = 6, HashAlgorithm algo = HashAlgorithm::SHA1);
|
||||
static std::string generateTOTP(const std::vector<uint8_t>& secret, uint64_t time, int period = 30, int digits = 6, HashAlgorithm algo = HashAlgorithm::SHA1);
|
||||
static std::string generateSteam(const std::vector<uint8_t>& secret, uint64_t time);
|
||||
};
|
||||
|
||||
#endif // OTP_H
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "sha1.h"
|
||||
#include <cstring>
|
||||
|
||||
#define SHA1_ROTL(bits, word) (((word) << (bits)) | ((word) >> (32 - (bits))))
|
||||
|
||||
SHA1::SHA1() {
|
||||
state[0] = 0x67452301;
|
||||
state[1] = 0xEFCDAB89;
|
||||
state[2] = 0x98BADCFE;
|
||||
state[3] = 0x10325476;
|
||||
state[4] = 0xC3D2E1F0;
|
||||
count = 0;
|
||||
}
|
||||
|
||||
void SHA1::transform(const uint8_t* block) {
|
||||
uint32_t a = state[0], b = state[1], c = state[2], d = state[3], e = state[4];
|
||||
uint32_t w[80];
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
w[i] = (block[i * 4] << 24) | (block[i * 4 + 1] << 16) | (block[i * 4 + 2] << 8) | (block[i * 4 + 3]);
|
||||
}
|
||||
for (int i = 16; i < 80; i++) {
|
||||
w[i] = SHA1_ROTL(1, w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 80; i++) {
|
||||
uint32_t f, k;
|
||||
if (i < 20) {
|
||||
f = (b & c) | ((~b) & d);
|
||||
k = 0x5A827999;
|
||||
} else if (i < 40) {
|
||||
f = b ^ c ^ d;
|
||||
k = 0x6ED9EBA1;
|
||||
} else if (i < 60) {
|
||||
f = (b & c) | (b & d) | (c & d);
|
||||
k = 0x8F1BBCDC;
|
||||
} else {
|
||||
f = b ^ c ^ d;
|
||||
k = 0xCA62C1D6;
|
||||
}
|
||||
|
||||
uint32_t temp = SHA1_ROTL(5, a) + f + e + k + w[i];
|
||||
e = d;
|
||||
d = c;
|
||||
c = SHA1_ROTL(30, b);
|
||||
b = a;
|
||||
a = temp;
|
||||
}
|
||||
|
||||
state[0] += a;
|
||||
state[1] += b;
|
||||
state[2] += c;
|
||||
state[3] += d;
|
||||
state[4] += e;
|
||||
}
|
||||
|
||||
void SHA1::update(const uint8_t* data, size_t length) {
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
buffer[count % 64] = data[i];
|
||||
count++;
|
||||
if (count % 64 == 0) {
|
||||
transform(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SHA1::finalize(uint8_t* hash) {
|
||||
uint64_t bitCount = count * 8;
|
||||
update(reinterpret_cast<const uint8_t*>("\x80"), 1);
|
||||
while (count % 64 != 56) {
|
||||
update(reinterpret_cast<const uint8_t*>("\x00"), 1);
|
||||
}
|
||||
|
||||
uint8_t lengthBytes[8];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
lengthBytes[i] = static_cast<uint8_t>((bitCount >> (56 - 8 * i)) & 0xFF);
|
||||
}
|
||||
update(lengthBytes, 8);
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
hash[i * 4] = static_cast<uint8_t>((state[i] >> 24) & 0xFF);
|
||||
hash[i * 4 + 1] = static_cast<uint8_t>((state[i] >> 16) & 0xFF);
|
||||
hash[i * 4 + 2] = static_cast<uint8_t>((state[i] >> 8) & 0xFF);
|
||||
hash[i * 4 + 3] = static_cast<uint8_t>((state[i]) & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef SHA1_H
|
||||
#define SHA1_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
class SHA1 {
|
||||
public:
|
||||
static const size_t HASH_SIZE = 20;
|
||||
static const size_t BLOCK_SIZE = 64;
|
||||
|
||||
SHA1();
|
||||
void update(const uint8_t* data, size_t length);
|
||||
void finalize(uint8_t* hash);
|
||||
|
||||
private:
|
||||
void transform(const uint8_t* block);
|
||||
uint32_t state[5];
|
||||
uint8_t buffer[64];
|
||||
uint64_t count;
|
||||
};
|
||||
|
||||
#endif // SHA1_H
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "sha256.h"
|
||||
#include <cstring>
|
||||
|
||||
#define SHA256_ROTR(bits, word) (((word) >> (bits)) | ((word) << (32 - (bits))))
|
||||
|
||||
static const uint32_t K256[64] = {
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
||||
};
|
||||
|
||||
SHA256::SHA256() {
|
||||
state[0] = 0x6a09e667; state[1] = 0xbb67ae85; state[2] = 0x3c6ef372; state[3] = 0xa54ff53a;
|
||||
state[4] = 0x510e527f; state[5] = 0x9b05688c; state[6] = 0x1f83d9ab; state[7] = 0x5be0cd19;
|
||||
count = 0;
|
||||
}
|
||||
|
||||
void SHA256::transform(const uint8_t* block) {
|
||||
uint32_t a = state[0], b = state[1], c = state[2], d = state[3], e = state[4], f = state[5], g = state[6], h = state[7];
|
||||
uint32_t w[64];
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
w[i] = (block[i * 4] << 24) | (block[i * 4 + 1] << 16) | (block[i * 4 + 2] << 8) | (block[i * 4 + 3]);
|
||||
}
|
||||
for (int i = 16; i < 64; i++) {
|
||||
uint32_t s0 = SHA256_ROTR(7, w[i - 15]) ^ SHA256_ROTR(18, w[i - 15]) ^ (w[i - 15] >> 3);
|
||||
uint32_t s1 = SHA256_ROTR(17, w[i - 2]) ^ SHA256_ROTR(19, w[i - 2]) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t S1 = SHA256_ROTR(6, e) ^ SHA256_ROTR(11, e) ^ SHA256_ROTR(25, e);
|
||||
uint32_t ch = (e & f) ^ ((~e) & g);
|
||||
uint32_t temp1 = h + S1 + ch + K256[i] + w[i];
|
||||
uint32_t S0 = SHA256_ROTR(2, a) ^ SHA256_ROTR(13, a) ^ SHA256_ROTR(22, a);
|
||||
uint32_t maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
uint32_t temp2 = S0 + maj;
|
||||
|
||||
h = g; g = f; f = e; e = d + temp1;
|
||||
d = c; c = b; b = a; a = temp1 + temp2;
|
||||
}
|
||||
|
||||
state[0] += a; state[1] += b; state[2] += c; state[3] += d;
|
||||
state[4] += e; state[5] += f; state[6] += g; state[7] += h;
|
||||
}
|
||||
|
||||
void SHA256::update(const uint8_t* data, size_t length) {
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
buffer[count % 64] = data[i];
|
||||
count++;
|
||||
if (count % 64 == 0) transform(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
void SHA256::finalize(uint8_t* hash) {
|
||||
uint64_t bitCount = count * 8;
|
||||
update(reinterpret_cast<const uint8_t*>("\x80"), 1);
|
||||
while (count % 64 != 56) update(reinterpret_cast<const uint8_t*>("\x00"), 1);
|
||||
|
||||
uint8_t lengthBytes[8];
|
||||
for (int i = 0; i < 8; i++) lengthBytes[i] = static_cast<uint8_t>((bitCount >> (56 - 8 * i)) & 0xFF);
|
||||
update(lengthBytes, 8);
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
hash[i * 4] = static_cast<uint8_t>((state[i] >> 24) & 0xFF);
|
||||
hash[i * 4 + 1] = static_cast<uint8_t>((state[i] >> 16) & 0xFF);
|
||||
hash[i * 4 + 2] = static_cast<uint8_t>((state[i] >> 8) & 0xFF);
|
||||
hash[i * 4 + 3] = static_cast<uint8_t>((state[i]) & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef SHA256_H
|
||||
#define SHA256_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
class SHA256 {
|
||||
public:
|
||||
static const size_t HASH_SIZE = 32;
|
||||
static const size_t BLOCK_SIZE = 64;
|
||||
|
||||
SHA256();
|
||||
void update(const uint8_t* data, size_t length);
|
||||
void finalize(uint8_t* hash);
|
||||
|
||||
private:
|
||||
void transform(const uint8_t* block);
|
||||
uint32_t state[8];
|
||||
uint8_t buffer[64];
|
||||
uint64_t count;
|
||||
};
|
||||
|
||||
#endif // SHA256_H
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "sha512.h"
|
||||
#include <cstring>
|
||||
|
||||
#define SHA512_ROTR(bits, word) (((word) >> (bits)) | ((word) << (64 - (bits))))
|
||||
|
||||
static const uint64_t K512[80] = {
|
||||
0x428a2f98d728ae22ULL, 0x7137449123ef65cdULL, 0xb5c0fbcfec4d3b2fULL, 0xe9b5dba58189dbbcULL,
|
||||
0x3956c25bf348b538ULL, 0x59f111f1b605d019ULL, 0x923f82a4af194f9bULL, 0xab1c5ed5da6d8118ULL,
|
||||
0xd807aa98a3030242ULL, 0x12835b0145706fbeULL, 0x243185be4ee4b28cULL, 0x550c7dc3d5ffb4e2ULL,
|
||||
0x72be5d74f27b896fULL, 0x80deb1fe3b1696b1ULL, 0x9bdc06a725c71235ULL, 0xc19bf174cf692694ULL,
|
||||
0xe49b69c19ef14ad2ULL, 0xefbe4786384f25e3ULL, 0x0fc19dc68b8cd5b5ULL, 0x240ca1cc77ac9c65ULL,
|
||||
0x2de92c6f592b0275ULL, 0x4a7484aa6ea6e483ULL, 0x5cb0a9dcbd41fbd4ULL, 0x76f988da831153b5ULL,
|
||||
0x983e5152ee66dfabULL, 0xa831c66d2db43210ULL, 0xb00327c898fb213fULL, 0xbf597fc7beef0ee4ULL,
|
||||
0xc6e00bf33da88fc2ULL, 0xd5a79147930aa725ULL, 0x06ca6351e003826fULL, 0x142929670a0e6e70ULL,
|
||||
0x27b70a8546d22ffcULL, 0x2e1b21385c26c926ULL, 0x4d2c6dfc5ac42aedULL, 0x53380d139d95b3dfULL,
|
||||
0x650a73548baf63deULL, 0x766a0abb3c77b2a8ULL, 0x81c2c92e47edaee6ULL, 0x92722c851482353bULL,
|
||||
0xa2bfe8a14cf10364ULL, 0xa81a664bbc423001ULL, 0xc24b8b70d0f89791ULL, 0xc76c51a30654be30ULL,
|
||||
0xd192e819d6ef5218ULL, 0xd69906245565a910ULL, 0xf40e35855771202aULL, 0x106aa07032bbd1b8ULL,
|
||||
0x19a4c116b8d2d0c8ULL, 0x1e376c085141ab53ULL, 0x2748774cdf8eeb99ULL, 0x34b0bcb5e19b48a8ULL,
|
||||
0x391c0cb3c5c95a63ULL, 0x4ed8aa4ae3418acbULL, 0x5b9cca4f7763e373ULL, 0x682e6ff3d6b2b8a3ULL,
|
||||
0x748f82ee5defb2fcULL, 0x78a5636f43172f60ULL, 0x84c87814a1f0ab72ULL, 0x8cc702081a6439ecULL,
|
||||
0x90befffa23631e28ULL, 0xa4506cebde82bde9ULL, 0xbef9a3f7b2c67915ULL, 0xc67178f2e372532bULL,
|
||||
0xca273eceea26619cULL, 0xd186b8c721c0c207ULL, 0xeada7dd6cde0eb1eULL, 0xf57d4f7fee6ed178ULL,
|
||||
0x06f067aa72176fbaULL, 0x0a637dc5a2c898a6ULL, 0x113f9804bef90daeULL, 0x1b710b35131c471bULL,
|
||||
0x28db77f523047d84ULL, 0x32caab7b40c72493ULL, 0x3c9ebe0a15c9bebcULL, 0x431d67c49c100d4cULL,
|
||||
0x4cc5d4becb3e42b6ULL, 0x597f299cfc657e2aULL, 0x5fcb6fab3ad6faecULL, 0x6c44198c4a475817ULL
|
||||
};
|
||||
|
||||
SHA512::SHA512() {
|
||||
state[0] = 0x6a09e667f3bcc908ULL; state[1] = 0xbb67ae8584caa73bULL;
|
||||
state[2] = 0x3c6ef372fe94f82bULL; state[3] = 0xa54ff53a5f1d36f1ULL;
|
||||
state[4] = 0x510e527fade682d1ULL; state[5] = 0x9b05688c2b3e6c1fULL;
|
||||
state[6] = 0x1f83d9abfb41bd6bULL; state[7] = 0x5be0cd19137e2179ULL;
|
||||
count = 0;
|
||||
}
|
||||
|
||||
void SHA512::transform(const uint8_t* block) {
|
||||
uint64_t a = state[0], b = state[1], c = state[2], d = state[3], e = state[4], f = state[5], g = state[6], h = state[7];
|
||||
uint64_t w[80];
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
w[i] = (static_cast<uint64_t>(block[i * 8]) << 56) | (static_cast<uint64_t>(block[i * 8 + 1]) << 48) |
|
||||
(static_cast<uint64_t>(block[i * 8 + 2]) << 40) | (static_cast<uint64_t>(block[i * 8 + 3]) << 32) |
|
||||
(static_cast<uint64_t>(block[i * 8 + 4]) << 24) | (static_cast<uint64_t>(block[i * 8 + 5]) << 16) |
|
||||
(static_cast<uint64_t>(block[i * 8 + 6]) << 8) | (static_cast<uint64_t>(block[i * 8 + 7]));
|
||||
}
|
||||
for (int i = 16; i < 80; i++) {
|
||||
uint64_t s0 = SHA512_ROTR(1, w[i - 15]) ^ SHA512_ROTR(8, w[i - 15]) ^ (w[i - 15] >> 7);
|
||||
uint64_t s1 = SHA512_ROTR(19, w[i - 2]) ^ SHA512_ROTR(61, w[i - 2]) ^ (w[i - 2] >> 6);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 80; i++) {
|
||||
uint64_t S1 = SHA512_ROTR(14, e) ^ SHA512_ROTR(18, e) ^ SHA512_ROTR(41, e);
|
||||
uint64_t ch = (e & f) ^ ((~e) & g);
|
||||
uint64_t temp1 = h + S1 + ch + K512[i] + w[i];
|
||||
uint64_t S0 = SHA512_ROTR(28, a) ^ SHA512_ROTR(34, a) ^ SHA512_ROTR(39, a);
|
||||
uint64_t maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
uint64_t temp2 = S0 + maj;
|
||||
|
||||
h = g; g = f; f = e; e = d + temp1;
|
||||
d = c; c = b; b = a; a = temp1 + temp2;
|
||||
}
|
||||
|
||||
state[0] += a; state[1] += b; state[2] += c; state[3] += d;
|
||||
state[4] += e; state[5] += f; state[6] += g; state[7] += h;
|
||||
}
|
||||
|
||||
void SHA512::update(const uint8_t* data, size_t length) {
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
buffer[count % 128] = data[i];
|
||||
count++;
|
||||
if (count % 128 == 0) transform(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
void SHA512::finalize(uint8_t* hash) {
|
||||
uint64_t bitCount = count * 8;
|
||||
update(reinterpret_cast<const uint8_t*>("\x80"), 1);
|
||||
while (count % 128 != 112) update(reinterpret_cast<const uint8_t*>("\x00"), 1);
|
||||
|
||||
uint8_t lengthBytes[16] = {0};
|
||||
for (int i = 0; i < 8; i++) lengthBytes[8 + i] = static_cast<uint8_t>((bitCount >> (56 - 8 * i)) & 0xFF);
|
||||
update(lengthBytes, 16);
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
hash[i * 8] = static_cast<uint8_t>((state[i] >> 56) & 0xFF);
|
||||
hash[i * 8 + 1] = static_cast<uint8_t>((state[i] >> 48) & 0xFF);
|
||||
hash[i * 8 + 2] = static_cast<uint8_t>((state[i] >> 40) & 0xFF);
|
||||
hash[i * 8 + 3] = static_cast<uint8_t>((state[i] >> 32) & 0xFF);
|
||||
hash[i * 8 + 4] = static_cast<uint8_t>((state[i] >> 24) & 0xFF);
|
||||
hash[i * 8 + 5] = static_cast<uint8_t>((state[i] >> 16) & 0xFF);
|
||||
hash[i * 8 + 6] = static_cast<uint8_t>((state[i] >> 8) & 0xFF);
|
||||
hash[i * 8 + 7] = static_cast<uint8_t>((state[i]) & 0xFF);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef SHA512_H
|
||||
#define SHA512_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
class SHA512 {
|
||||
public:
|
||||
static const size_t HASH_SIZE = 64;
|
||||
static const size_t BLOCK_SIZE = 128;
|
||||
|
||||
SHA512();
|
||||
void update(const uint8_t* data, size_t length);
|
||||
void finalize(uint8_t* hash);
|
||||
|
||||
private:
|
||||
void transform(const uint8_t* block);
|
||||
uint64_t state[8];
|
||||
uint8_t buffer[128];
|
||||
uint64_t count;
|
||||
};
|
||||
|
||||
#endif // SHA512_H
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.airnan.a2fair
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import cn.airnan.a2fair.core.backup.BackupManager
|
||||
import cn.airnan.a2fair.core.security.AppLockManager
|
||||
import cn.airnan.a2fair.crypto.CryptoManager
|
||||
import cn.airnan.a2fair.data.AppDatabase
|
||||
import cn.airnan.a2fair.data.repository.SettingsRepository
|
||||
import cn.airnan.a2fair.data.repository.TokenRepository
|
||||
import cn.airnan.a2fair.data.repository.dataStore
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Locale
|
||||
|
||||
class A2FairApplication : Application() {
|
||||
|
||||
lateinit var cryptoManager: CryptoManager
|
||||
private set
|
||||
|
||||
lateinit var database: AppDatabase
|
||||
private set
|
||||
|
||||
lateinit var tokenRepository: TokenRepository
|
||||
private set
|
||||
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
private set
|
||||
|
||||
lateinit var appLockManager: AppLockManager
|
||||
private set
|
||||
|
||||
lateinit var backupManager: BackupManager
|
||||
private set
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
val languageCode = try {
|
||||
runBlocking { base.dataStore.data.first() }[intPreferencesKey("language")] ?: 0
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
val locale = codeToLocale(languageCode) ?: return super.attachBaseContext(base)
|
||||
Locale.setDefault(locale)
|
||||
val config = Configuration(base.resources.configuration)
|
||||
config.setLocale(locale)
|
||||
val wrappedContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
base.createConfigurationContext(config)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
base.resources.updateConfiguration(config, base.resources.displayMetrics)
|
||||
base
|
||||
}
|
||||
super.attachBaseContext(wrappedContext)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
cryptoManager = CryptoManager()
|
||||
database = AppDatabase.getDatabase(this, cryptoManager)
|
||||
tokenRepository = TokenRepository(database.tokenDao(), database.groupDao())
|
||||
settingsRepository = SettingsRepository(this, cryptoManager)
|
||||
|
||||
appLockManager = AppLockManager(settingsRepository)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(appLockManager)
|
||||
|
||||
backupManager = BackupManager(this, tokenRepository, cryptoManager)
|
||||
}
|
||||
}
|
||||
|
||||
fun codeToLocale(code: Int): Locale? = when (code) {
|
||||
1 -> Locale.SIMPLIFIED_CHINESE // zh-CN
|
||||
2 -> Locale.TRADITIONAL_CHINESE // zh-TW
|
||||
3 -> Locale.ENGLISH
|
||||
else -> null // 0: System, use default
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.airnan.a2fair
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.data.repository.dataStore
|
||||
import cn.airnan.a2fair.ui.navigation.AppNavigation
|
||||
import cn.airnan.a2fair.ui.settings.security.LockScreen
|
||||
import cn.airnan.a2fair.ui.theme._2FAirTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val languageCode = try {
|
||||
runBlocking { newBase.dataStore.data.first() }[intPreferencesKey("language")] ?: 0
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
val locale = codeToLocale(languageCode)
|
||||
val wrappedContext = if (locale != null) {
|
||||
val config = Configuration(newBase.resources.configuration)
|
||||
config.setLocale(locale)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
newBase.createConfigurationContext(config)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
newBase.resources.updateConfiguration(config, newBase.resources.displayMetrics)
|
||||
newBase
|
||||
}
|
||||
} else {
|
||||
newBase
|
||||
}
|
||||
super.attachBaseContext(wrappedContext)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val app = application as A2FairApplication
|
||||
val settingsRepo = app.settingsRepository
|
||||
val appLockManager = app.appLockManager
|
||||
|
||||
val themeMode by settingsRepo.themeMode.collectAsStateWithLifecycle(initialValue = 0)
|
||||
val dynamicColor by settingsRepo.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
|
||||
val seedColor by settingsRepo.seedColor.collectAsStateWithLifecycle(initialValue = 0xFF6750A4.toInt())
|
||||
val isFlagSecureEnabled by settingsRepo.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isLocked by appLockManager.isLocked.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(isFlagSecureEnabled) {
|
||||
if (isFlagSecureEnabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
settingsRepo.languageChangeEvent.collect {
|
||||
this@MainActivity.recreate()
|
||||
}
|
||||
}
|
||||
|
||||
_2FAirTheme(
|
||||
themeMode = themeMode,
|
||||
dynamicColor = dynamicColor,
|
||||
seedColor = seedColor
|
||||
) {
|
||||
if (isLocked) {
|
||||
LockScreen(
|
||||
onUnlock = { appLockManager.unlock() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.airnan.a2fair.core
|
||||
|
||||
object NativeLib {
|
||||
init {
|
||||
System.loadLibrary("a2fair")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Base32 encoded string to byte array
|
||||
*/
|
||||
external fun base32Decode(encoded: String): ByteArray
|
||||
|
||||
/**
|
||||
* Encode a byte array to Base32 string
|
||||
*/
|
||||
external fun base32Encode(data: ByteArray): String
|
||||
|
||||
/**
|
||||
* Generate HOTP code
|
||||
*/
|
||||
external fun generateHOTP(secret: ByteArray, counter: Long, digits: Int, algo: Int): String
|
||||
|
||||
/**
|
||||
* Generate TOTP code
|
||||
*/
|
||||
external fun generateTOTP(secret: ByteArray, time: Long, period: Int, digits: Int, algo: Int): String
|
||||
|
||||
/**
|
||||
* Generate Steam TOTP code
|
||||
*/
|
||||
external fun generateSteam(secret: ByteArray, time: Long): String
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package cn.airnan.a2fair.core
|
||||
|
||||
import android.net.Uri
|
||||
import cn.airnan.a2fair.data.SecretString
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
|
||||
object OtpUriParser {
|
||||
|
||||
class ParseException(message: String) : Exception(message)
|
||||
|
||||
fun parse(uriString: String): Token {
|
||||
val uri = Uri.parse(uriString)
|
||||
|
||||
if (uri.scheme != "otpauth") {
|
||||
throw ParseException("Invalid scheme: ${uri.scheme}")
|
||||
}
|
||||
|
||||
val typeStr = uri.authority ?: throw ParseException("Missing token type")
|
||||
val tokenType = when (typeStr.lowercase()) {
|
||||
"totp" -> TokenType.TOTP
|
||||
"hotp" -> TokenType.HOTP
|
||||
"steam" -> TokenType.STEAM
|
||||
else -> throw ParseException("Unsupported token type: $typeStr")
|
||||
}
|
||||
|
||||
// Path is usually /issuer:account or /account
|
||||
val path = uri.path?.removePrefix("/") ?: throw ParseException("Missing path/label")
|
||||
if (path.isEmpty()) {
|
||||
throw ParseException("Missing path/label")
|
||||
}
|
||||
|
||||
val labelDecoded = Uri.decode(path)
|
||||
val colonIndex = labelDecoded.indexOf(':')
|
||||
|
||||
var issuer = uri.getQueryParameter("issuer") ?: ""
|
||||
var serviceName = labelDecoded
|
||||
|
||||
if (colonIndex != -1) {
|
||||
val labelIssuer = labelDecoded.substring(0, colonIndex).trim()
|
||||
val labelAccount = labelDecoded.substring(colonIndex + 1).trim()
|
||||
|
||||
if (issuer.isEmpty()) {
|
||||
issuer = labelIssuer
|
||||
}
|
||||
serviceName = labelAccount
|
||||
}
|
||||
|
||||
val secret = uri.getQueryParameter("secret") ?: throw ParseException("Missing secret")
|
||||
if (secret.isEmpty()) {
|
||||
throw ParseException("Secret is empty")
|
||||
}
|
||||
|
||||
val algorithmStr = uri.getQueryParameter("algorithm") ?: "SHA1"
|
||||
val algorithm = when (algorithmStr.uppercase()) {
|
||||
"SHA1" -> HashAlgorithm.SHA1
|
||||
"SHA256" -> HashAlgorithm.SHA256
|
||||
"SHA512" -> HashAlgorithm.SHA512
|
||||
else -> throw ParseException("Unsupported algorithm: $algorithmStr")
|
||||
}
|
||||
|
||||
val digitsStr = uri.getQueryParameter("digits") ?: "6"
|
||||
val digits = digitsStr.toIntOrNull() ?: throw ParseException("Invalid digits: $digitsStr")
|
||||
|
||||
val periodStr = uri.getQueryParameter("period") ?: "30"
|
||||
val period = periodStr.toIntOrNull() ?: throw ParseException("Invalid period: $periodStr")
|
||||
|
||||
val counterStr = uri.getQueryParameter("counter") ?: "0"
|
||||
val counter = counterStr.toLongOrNull() ?: throw ParseException("Invalid counter: $counterStr")
|
||||
|
||||
if (tokenType == TokenType.HOTP && !uri.queryParameterNames.contains("counter")) {
|
||||
throw ParseException("HOTP requires a counter")
|
||||
}
|
||||
|
||||
return Token(
|
||||
issuer = issuer,
|
||||
serviceName = serviceName,
|
||||
secret = SecretString(secret),
|
||||
tokenType = tokenType,
|
||||
algorithm = algorithm,
|
||||
digits = digits,
|
||||
period = period,
|
||||
counter = counter
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package cn.airnan.a2fair.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import cn.airnan.a2fair.crypto.CryptoManager
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.repository.TokenRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
data class BackupData(
|
||||
val groups: List<Group>,
|
||||
val tokens: List<Token>
|
||||
)
|
||||
|
||||
class BackupManager(
|
||||
private val context: Context,
|
||||
private val tokenRepository: TokenRepository,
|
||||
private val cryptoManager: CryptoManager
|
||||
) {
|
||||
private val gson = Gson()
|
||||
|
||||
suspend fun exportData(uri: Uri, password: CharArray): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val tokens = tokenRepository.getAllTokens().first()
|
||||
val groups = tokenRepository.getAllGroups().first()
|
||||
|
||||
// Decrypt tokens before exporting
|
||||
val decryptedTokens = tokens.map { token ->
|
||||
token.copy(
|
||||
secret = cn.airnan.a2fair.data.SecretString(token.secret.value) // Actually the secret in entity is plain when loaded from DB because of TypeConverter, wait, Room's TypeConverter encrypts on save, decrypts on load.
|
||||
// So token.secret is already decrypted!
|
||||
)
|
||||
}
|
||||
|
||||
val backupData = BackupData(groups, decryptedTokens)
|
||||
val json = gson.toJson(backupData)
|
||||
val jsonBytes = json.toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Generate salt and derive key
|
||||
val salt = ByteArray(16)
|
||||
SecureRandom().nextBytes(salt)
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
val spec = PBEKeySpec(password, salt, 10000, 256)
|
||||
val secretKey = SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
|
||||
|
||||
// Encrypt data
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
val iv = cipher.iv
|
||||
val encryptedData = cipher.doFinal(jsonBytes)
|
||||
|
||||
// Write to file: format = [SALT_LENGTH (1)] + [SALT] + [IV_LENGTH (1)] + [IV] + [DATA]
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
output.write(salt.size)
|
||||
output.write(salt)
|
||||
output.write(iv.size)
|
||||
output.write(iv)
|
||||
output.write(encryptedData)
|
||||
} ?: return@withContext Result.failure(Exception("Could not open output stream"))
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importData(uri: Uri, password: CharArray): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val encryptedBytes = context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
input.readBytes()
|
||||
} ?: return@withContext Result.failure(Exception("Could not open input stream"))
|
||||
|
||||
var offset = 0
|
||||
val saltLength = encryptedBytes[offset++].toInt()
|
||||
val salt = encryptedBytes.copyOfRange(offset, offset + saltLength)
|
||||
offset += saltLength
|
||||
|
||||
val ivLength = encryptedBytes[offset++].toInt()
|
||||
val iv = encryptedBytes.copyOfRange(offset, offset + ivLength)
|
||||
offset += ivLength
|
||||
|
||||
val data = encryptedBytes.copyOfRange(offset, encryptedBytes.size)
|
||||
|
||||
// Derive key
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
val spec = PBEKeySpec(password, salt, 10000, 256)
|
||||
val secretKey = SecretKeySpec(factory.generateSecret(spec).encoded, "AES")
|
||||
|
||||
// Decrypt
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val gcmSpec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
||||
val decryptedBytes = cipher.doFinal(data)
|
||||
|
||||
val json = String(decryptedBytes, Charsets.UTF_8)
|
||||
val backupData = gson.fromJson(json, BackupData::class.java)
|
||||
|
||||
// Merge logic (simple replace or merge)
|
||||
// For now, let's just insert all if not exists, or replace. We'll do a simple insert.
|
||||
backupData.groups.forEach { group ->
|
||||
// Ensure group doesn't duplicate by name?
|
||||
// For simplicity, insert directly, handle id mapping later if needed.
|
||||
tokenRepository.insertGroup(group.copy(id = 0))
|
||||
}
|
||||
|
||||
backupData.tokens.forEach { token ->
|
||||
tokenRepository.insertToken(token.copy(id = 0))
|
||||
}
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.airnan.a2fair.core.security
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import cn.airnan.a2fair.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AppLockManager(private val settingsRepository: SettingsRepository) : DefaultLifecycleObserver {
|
||||
private val _isLocked = MutableStateFlow(false)
|
||||
val isLocked: StateFlow<Boolean> = _isLocked.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
fun lock() {
|
||||
scope.launch {
|
||||
val isPinEnabled = settingsRepository.isPinEnabled.first()
|
||||
if (isPinEnabled) {
|
||||
_isLocked.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unlock() {
|
||||
_isLocked.value = false
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
lock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cn.airnan.a2fair.crypto
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class CryptoManager {
|
||||
|
||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
private fun getSecretKey(): SecretKey {
|
||||
val existingKey = keyStore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry
|
||||
return existingKey?.secretKey ?: createSecretKey()
|
||||
}
|
||||
|
||||
private fun createSecretKey(): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore"
|
||||
)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(spec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
|
||||
fun encrypt(unencryptedString: String): String {
|
||||
if (unencryptedString.isBlank()) return unencryptedString
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
||||
val iv = cipher.iv
|
||||
val encryptedBytes = cipher.doFinal(unencryptedString.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val combined = ByteArray(iv.size + encryptedBytes.size)
|
||||
System.arraycopy(iv, 0, combined, 0, iv.size)
|
||||
System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size)
|
||||
|
||||
return Base64.encodeToString(combined, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun decrypt(encryptedString: String): String {
|
||||
if (encryptedString.isBlank()) return encryptedString
|
||||
return try {
|
||||
val combined = Base64.decode(encryptedString, Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
|
||||
// For AES-GCM, IV length is 12 bytes
|
||||
val iv = combined.copyOfRange(0, 12)
|
||||
val encryptedBytes = combined.copyOfRange(12, combined.size)
|
||||
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
String(decryptedBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ALIAS = "a2fair_secret_key"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.airnan.a2fair.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import cn.airnan.a2fair.crypto.CryptoManager
|
||||
import cn.airnan.a2fair.data.dao.GroupDao
|
||||
import cn.airnan.a2fair.data.dao.TokenDao
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
|
||||
@Database(entities = [Group::class, Token::class], version = 1, exportSchema = false)
|
||||
@TypeConverters(SecretTypeConverter::class, EnumConverters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun tokenDao(): TokenDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context, cryptoManager: CryptoManager): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"a2fair_database"
|
||||
)
|
||||
.addTypeConverter(SecretTypeConverter(cryptoManager))
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.airnan.a2fair.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
|
||||
class EnumConverters {
|
||||
|
||||
@TypeConverter
|
||||
fun fromTokenType(value: TokenType): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toTokenType(value: String): TokenType {
|
||||
return try {
|
||||
TokenType.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
TokenType.TOTP
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromHashAlgorithm(value: HashAlgorithm): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toHashAlgorithm(value: String): HashAlgorithm {
|
||||
return try {
|
||||
HashAlgorithm.valueOf(value)
|
||||
} catch (e: Exception) {
|
||||
HashAlgorithm.SHA1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cn.airnan.a2fair.data
|
||||
|
||||
data class SecretString(val value: String) {
|
||||
override fun toString(): String {
|
||||
return "***"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.airnan.a2fair.data
|
||||
|
||||
import androidx.room.ProvidedTypeConverter
|
||||
import androidx.room.TypeConverter
|
||||
import cn.airnan.a2fair.crypto.CryptoManager
|
||||
|
||||
@ProvidedTypeConverter
|
||||
class SecretTypeConverter(private val cryptoManager: CryptoManager) {
|
||||
|
||||
@TypeConverter
|
||||
fun fromSecretString(secretString: SecretString?): String? {
|
||||
if (secretString == null) return null
|
||||
return cryptoManager.encrypt(secretString.value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toSecretString(encryptedString: String?): SecretString? {
|
||||
if (encryptedString == null) return null
|
||||
return SecretString(cryptoManager.decrypt(encryptedString))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.airnan.a2fair.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface GroupDao {
|
||||
@Query("SELECT * FROM groups ORDER BY sortOrder ASC")
|
||||
fun getAllGroups(): Flow<List<Group>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertGroup(group: Group): Long
|
||||
|
||||
@Update
|
||||
fun updateGroup(group: Group): Int
|
||||
|
||||
@Delete
|
||||
fun deleteGroup(group: Group): Int
|
||||
|
||||
@Query("SELECT * FROM groups WHERE id = :id LIMIT 1")
|
||||
fun getGroupById(id: Long): Group?
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.airnan.a2fair.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TokenDao {
|
||||
@Query("SELECT * FROM tokens ORDER BY sortOrder ASC")
|
||||
fun getAllTokens(): Flow<List<Token>>
|
||||
|
||||
@Query("SELECT * FROM tokens WHERE groupId = :groupId ORDER BY sortOrder ASC")
|
||||
fun getTokensByGroupId(groupId: Long): Flow<List<Token>>
|
||||
|
||||
@Query("SELECT * FROM tokens WHERE groupId IS NULL ORDER BY sortOrder ASC")
|
||||
fun getUncategorizedTokens(): Flow<List<Token>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertToken(token: Token): Long
|
||||
|
||||
@Update
|
||||
fun updateToken(token: Token): Int
|
||||
|
||||
@Delete
|
||||
fun deleteToken(token: Token): Int
|
||||
|
||||
@Query("SELECT * FROM tokens WHERE id = :id LIMIT 1")
|
||||
fun getTokenById(id: Long): Token?
|
||||
|
||||
@Query("UPDATE tokens SET counter = :counter WHERE id = :id")
|
||||
fun updateCounter(id: Long, counter: Long): Int
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.airnan.a2fair.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "groups")
|
||||
data class Group(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val name: String,
|
||||
val sortOrder: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.airnan.a2fair.data.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
enum class TokenType {
|
||||
TOTP, HOTP, STEAM
|
||||
}
|
||||
|
||||
enum class HashAlgorithm {
|
||||
SHA1, SHA256, SHA512
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "tokens",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Group::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["groupId"],
|
||||
onDelete = ForeignKey.SET_NULL
|
||||
)
|
||||
],
|
||||
indices = [Index("groupId")]
|
||||
)
|
||||
data class Token(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val issuer: String = "",
|
||||
val serviceName: String,
|
||||
val secret: cn.airnan.a2fair.data.SecretString, // will be encrypted
|
||||
val tokenType: TokenType = TokenType.TOTP,
|
||||
val algorithm: HashAlgorithm = HashAlgorithm.SHA1,
|
||||
val digits: Int = 6,
|
||||
val period: Int = 30,
|
||||
val counter: Long = 0,
|
||||
val groupId: Long? = null,
|
||||
val icon: String? = null,
|
||||
val sortOrder: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
package cn.airnan.a2fair.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import cn.airnan.a2fair.crypto.CryptoManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
class SettingsRepository(private val context: Context, private val cryptoManager: CryptoManager) {
|
||||
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
val isPinEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.PIN_ENABLED] ?: false
|
||||
}
|
||||
|
||||
suspend fun setPinEnabled(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.PIN_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val pinCode: Flow<String?> = dataStore.data.map { preferences ->
|
||||
val encryptedPin = preferences[PreferencesKeys.PIN_CODE]
|
||||
if (encryptedPin != null) {
|
||||
try {
|
||||
cryptoManager.decrypt(encryptedPin)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPinCode(pin: String?) {
|
||||
dataStore.edit { preferences ->
|
||||
if (pin == null) {
|
||||
preferences.remove(PreferencesKeys.PIN_CODE)
|
||||
} else {
|
||||
preferences[PreferencesKeys.PIN_CODE] = cryptoManager.encrypt(pin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isBiometricEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.BIOMETRIC_ENABLED] ?: false
|
||||
}
|
||||
|
||||
suspend fun setBiometricEnabled(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.BIOMETRIC_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val isFlagSecureEnabled: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.FLAG_SECURE_ENABLED] ?: false
|
||||
}
|
||||
|
||||
suspend fun setFlagSecureEnabled(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.FLAG_SECURE_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val failedAttempts: Flow<Int> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.FAILED_ATTEMPTS] ?: 0
|
||||
}
|
||||
|
||||
suspend fun setFailedAttempts(attempts: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.FAILED_ATTEMPTS] = attempts
|
||||
}
|
||||
}
|
||||
|
||||
val lockoutEndTime: Flow<Long> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.LOCKOUT_END_TIME] ?: 0L
|
||||
}
|
||||
|
||||
suspend fun setLockoutEndTime(time: Long) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.LOCKOUT_END_TIME] = time
|
||||
}
|
||||
}
|
||||
|
||||
val themeMode: Flow<Int> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] ?: 0 // 0: System, 1: Light, 2: Dark
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.THEME_MODE] = mode
|
||||
}
|
||||
}
|
||||
|
||||
val dynamicColor: Flow<Boolean> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.DYNAMIC_COLOR] ?: true
|
||||
}
|
||||
|
||||
suspend fun setDynamicColor(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.DYNAMIC_COLOR] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
val seedColor: Flow<Int> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.SEED_COLOR] ?: 0xFF6750A4.toInt() // Default to Material You purple
|
||||
}
|
||||
|
||||
suspend fun setSeedColor(color: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.SEED_COLOR] = color
|
||||
}
|
||||
}
|
||||
|
||||
val language: Flow<Int> = dataStore.data.map { preferences ->
|
||||
preferences[PreferencesKeys.LANGUAGE] ?: 0 // 0: System, 1: zh-CN, 2: zh-TW, 3: en
|
||||
}
|
||||
|
||||
private val _languageChangeEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val languageChangeEvent: SharedFlow<Unit> = _languageChangeEvent
|
||||
|
||||
suspend fun setLanguage(code: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[PreferencesKeys.LANGUAGE] = code
|
||||
}
|
||||
_languageChangeEvent.emit(Unit)
|
||||
}
|
||||
|
||||
private object PreferencesKeys {
|
||||
val PIN_ENABLED = booleanPreferencesKey("pin_enabled")
|
||||
val PIN_CODE = stringPreferencesKey("pin_code")
|
||||
val BIOMETRIC_ENABLED = booleanPreferencesKey("biometric_enabled")
|
||||
val FLAG_SECURE_ENABLED = booleanPreferencesKey("flag_secure_enabled")
|
||||
val FAILED_ATTEMPTS = intPreferencesKey("failed_attempts")
|
||||
val LOCKOUT_END_TIME = longPreferencesKey("lockout_end_time")
|
||||
val THEME_MODE = intPreferencesKey("theme_mode")
|
||||
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||
val SEED_COLOR = intPreferencesKey("seed_color")
|
||||
val LANGUAGE = intPreferencesKey("language")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.airnan.a2fair.data.repository
|
||||
|
||||
import cn.airnan.a2fair.data.dao.GroupDao
|
||||
import cn.airnan.a2fair.data.dao.TokenDao
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class TokenRepository(
|
||||
private val tokenDao: TokenDao,
|
||||
private val groupDao: GroupDao
|
||||
) {
|
||||
// Group
|
||||
fun getAllGroups(): Flow<List<Group>> = groupDao.getAllGroups()
|
||||
suspend fun getGroupById(id: Long): Group? = withContext(Dispatchers.IO) { groupDao.getGroupById(id) }
|
||||
suspend fun insertGroup(group: Group): Long = withContext(Dispatchers.IO) { groupDao.insertGroup(group) }
|
||||
suspend fun updateGroup(group: Group) { withContext(Dispatchers.IO) { groupDao.updateGroup(group) } }
|
||||
suspend fun deleteGroup(group: Group) { withContext(Dispatchers.IO) { groupDao.deleteGroup(group) } }
|
||||
|
||||
// Token
|
||||
fun getAllTokens(): Flow<List<Token>> = tokenDao.getAllTokens()
|
||||
fun getTokensByGroupId(groupId: Long): Flow<List<Token>> = tokenDao.getTokensByGroupId(groupId)
|
||||
fun getUncategorizedTokens(): Flow<List<Token>> = tokenDao.getUncategorizedTokens()
|
||||
suspend fun getTokenById(id: Long): Token? = withContext(Dispatchers.IO) { tokenDao.getTokenById(id) }
|
||||
suspend fun insertToken(token: Token): Long = withContext(Dispatchers.IO) { tokenDao.insertToken(token) }
|
||||
suspend fun updateToken(token: Token) { withContext(Dispatchers.IO) { tokenDao.updateToken(token) } }
|
||||
suspend fun deleteToken(token: Token) { withContext(Dispatchers.IO) { tokenDao.deleteToken(token) } }
|
||||
suspend fun updateCounter(id: Long, counter: Long) { withContext(Dispatchers.IO) { tokenDao.updateCounter(id, counter) } }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.airnan.a2fair.ui.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import cn.airnan.a2fair.R
|
||||
|
||||
enum class TopLevelDestination(
|
||||
val route: String,
|
||||
val icon: ImageVector,
|
||||
@StringRes val titleTextId: Int
|
||||
) {
|
||||
TOKENS(
|
||||
route = "tokens",
|
||||
icon = Icons.Outlined.Shield,
|
||||
titleTextId = R.string.tokens
|
||||
),
|
||||
SETTINGS(
|
||||
route = "settings",
|
||||
icon = Icons.Default.Settings,
|
||||
titleTextId = R.string.settings
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package cn.airnan.a2fair.ui.navigation
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.ui.settings.SettingsScreen
|
||||
import cn.airnan.a2fair.ui.settings.security.PinSetupScreen
|
||||
import cn.airnan.a2fair.ui.settings.security.SecuritySettingsScreen
|
||||
import cn.airnan.a2fair.ui.tokens.TokensScreen
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
appState: AppState = rememberAppState()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val animDuration = context.resources.getInteger(android.R.integer.config_shortAnimTime)
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
destinations = appState.topLevelDestinations,
|
||||
onNavigateToDestination = appState::navigateToTopLevelDestination,
|
||||
currentDestination = appState.currentTopLevelDestination
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = appState.navController,
|
||||
startDestination = TopLevelDestination.TOKENS.route,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
composable(
|
||||
route = TopLevelDestination.TOKENS.route,
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None }
|
||||
) {
|
||||
TokensScreen()
|
||||
}
|
||||
composable(
|
||||
route = TopLevelDestination.SETTINGS.route,
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None }
|
||||
) {
|
||||
SettingsScreen(
|
||||
onNavigateToSecurity = { appState.navController.navigate("security") },
|
||||
onNavigateToBackup = { appState.navController.navigate("backup") },
|
||||
onNavigateToTheme = { appState.navController.navigate("theme") },
|
||||
onNavigateToLanguage = { appState.navController.navigate("language") },
|
||||
onNavigateToAbout = { appState.navController.navigate("about") }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "security",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
SecuritySettingsScreen(
|
||||
settingsRepository = app.settingsRepository,
|
||||
onNavigateBack = { appState.navController.popBackStack() },
|
||||
onNavigateToPinSetup = { appState.navController.navigate("pin_setup") }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "pin_setup",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
PinSetupScreen(
|
||||
settingsRepository = app.settingsRepository,
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "backup",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
cn.airnan.a2fair.ui.settings.backup.BackupScreen(
|
||||
backupManager = app.backupManager,
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "theme",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
cn.airnan.a2fair.ui.settings.ThemeSettingsScreen(
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "about",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
cn.airnan.a2fair.ui.settings.AboutScreen(
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "language",
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(animDuration))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(animDuration))
|
||||
}
|
||||
) {
|
||||
cn.airnan.a2fair.ui.settings.LanguageSettingsScreen(
|
||||
onNavigateBack = { appState.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppBottomBar(
|
||||
destinations: List<TopLevelDestination>,
|
||||
onNavigateToDestination: (TopLevelDestination) -> Unit,
|
||||
currentDestination: TopLevelDestination?
|
||||
) {
|
||||
NavigationBar {
|
||||
destinations.forEach { destination ->
|
||||
val selected = currentDestination == destination
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = { onNavigateToDestination(destination) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(id = destination.titleTextId)
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = destination.titleTextId))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cn.airnan.a2fair.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@Composable
|
||||
fun rememberAppState(
|
||||
navController: NavHostController = rememberNavController()
|
||||
): AppState {
|
||||
return remember(navController) {
|
||||
AppState(navController)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AppState(
|
||||
val navController: NavHostController
|
||||
) {
|
||||
val currentDestination: NavDestination?
|
||||
@Composable get() = navController.currentBackStackEntryAsState().value?.destination
|
||||
|
||||
val currentTopLevelDestination: TopLevelDestination?
|
||||
@Composable get() {
|
||||
val route = currentDestination?.route
|
||||
return TopLevelDestination.values().find { it.route == route }
|
||||
}
|
||||
|
||||
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().toList()
|
||||
|
||||
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
|
||||
navController.navigate(topLevelDestination.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cn.airnan.a2fair.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cn.airnan.a2fair.BuildConfig
|
||||
import cn.airnan.a2fair.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.about)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Use app icon (assuming ic_launcher is available)
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_launcher),
|
||||
contentDescription = stringResource(R.string.app_icon),
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.version_format, BuildConfig.VERSION_NAME),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.source_code)) },
|
||||
supportingContent = { Text(stringResource(R.string.view_on_github)) },
|
||||
modifier = Modifier.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/"))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.open_source_licenses)) },
|
||||
modifier = Modifier.clickable {
|
||||
// TODO: Implement license viewer or navigation
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.copyright),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cn.airnan.a2fair.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LanguageSettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val viewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory(app)
|
||||
)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
fun changeLanguage(code: Int) {
|
||||
if (code != uiState.language) {
|
||||
viewModel.setLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.language)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
LanguageOption(
|
||||
title = stringResource(R.string.lang_follow_system),
|
||||
selected = uiState.language == 0,
|
||||
onClick = { changeLanguage(0) }
|
||||
)
|
||||
LanguageOption(
|
||||
title = stringResource(R.string.lang_zh_cn),
|
||||
selected = uiState.language == 1,
|
||||
onClick = { changeLanguage(1) }
|
||||
)
|
||||
LanguageOption(
|
||||
title = stringResource(R.string.lang_zh_tw),
|
||||
selected = uiState.language == 2,
|
||||
onClick = { changeLanguage(2) }
|
||||
)
|
||||
LanguageOption(
|
||||
title = stringResource(R.string.lang_en),
|
||||
selected = uiState.language == 3,
|
||||
onClick = { changeLanguage(3) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageOption(
|
||||
title: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
androidx.compose.material3.ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
},
|
||||
modifier = Modifier.clickable(onClick = onClick)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.airnan.a2fair.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ColorLens
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.SettingsBackupRestore
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cn.airnan.a2fair.BuildConfig
|
||||
import cn.airnan.a2fair.R
|
||||
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateToSecurity: () -> Unit,
|
||||
onNavigateToBackup: () -> Unit,
|
||||
onNavigateToTheme: () -> Unit,
|
||||
onNavigateToLanguage: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.settings)) }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.security)) },
|
||||
leadingContent = { Icon(Icons.Default.Lock, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToSecurity() }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.backup_restore)) },
|
||||
leadingContent = { Icon(Icons.Default.SettingsBackupRestore, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToBackup() }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.theme)) },
|
||||
leadingContent = { Icon(Icons.Default.ColorLens, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToTheme() }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.language)) },
|
||||
leadingContent = { Icon(Icons.Default.Language, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToLanguage() }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.about)) },
|
||||
leadingContent = { Icon(Icons.Default.Info, contentDescription = null) },
|
||||
trailingContent = {
|
||||
Text(
|
||||
text = "v${BuildConfig.VERSION_NAME}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onNavigateToAbout() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cn.airnan.a2fair.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
data class SettingsUiState(
|
||||
val themeMode: Int = 0,
|
||||
val dynamicColor: Boolean = true,
|
||||
val seedColor: Int = 0xFF6750A4.toInt(),
|
||||
val language: Int = 0
|
||||
)
|
||||
|
||||
class SettingsViewModel(
|
||||
application: Application,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
val uiState: StateFlow<SettingsUiState> = combine(
|
||||
settingsRepository.themeMode,
|
||||
settingsRepository.dynamicColor,
|
||||
settingsRepository.seedColor,
|
||||
settingsRepository.language
|
||||
) { themeMode, dynamicColor, seedColor, language ->
|
||||
SettingsUiState(
|
||||
themeMode = themeMode,
|
||||
dynamicColor = dynamicColor,
|
||||
seedColor = seedColor,
|
||||
language = language
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = SettingsUiState()
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: Int) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setThemeMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDynamicColor(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setDynamicColor(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSeedColor(color: Int) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setSeedColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLanguage(code: Int) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setLanguage(code)
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
|
||||
val app = application as A2FairApplication
|
||||
return SettingsViewModel(application, app.settingsRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package cn.airnan.a2fair.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThemeSettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val viewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory(app)
|
||||
)
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.theme_settings)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.theme_mode),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
ThemeModeItem(
|
||||
title = stringResource(R.string.system),
|
||||
selected = uiState.themeMode == 0,
|
||||
onClick = { viewModel.setThemeMode(0) }
|
||||
)
|
||||
ThemeModeItem(
|
||||
title = stringResource(R.string.light),
|
||||
selected = uiState.themeMode == 1,
|
||||
onClick = { viewModel.setThemeMode(1) }
|
||||
)
|
||||
ThemeModeItem(
|
||||
title = stringResource(R.string.dark),
|
||||
selected = uiState.themeMode == 2,
|
||||
onClick = { viewModel.setThemeMode(2) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.color_palette),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.dynamic_color)) },
|
||||
supportingContent = { Text(stringResource(R.string.dynamic_color_desc)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = uiState.dynamicColor,
|
||||
onCheckedChange = { viewModel.setDynamicColor(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (!uiState.dynamicColor) {
|
||||
Text(
|
||||
text = stringResource(R.string.seed_color),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
|
||||
val colors = listOf(
|
||||
0xFF6750A4, // Default Purple
|
||||
0xFF4CAF50, // Green
|
||||
0xFF2196F3, // Blue
|
||||
0xFFFF9800, // Light Blue
|
||||
0xFFE91E63, // Orange
|
||||
0xFFF44336, // Pink
|
||||
0xFF9C27B0, // Deep Purple
|
||||
0xFF795548 // Purple
|
||||
)
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
items(colors) { colorInt ->
|
||||
val color = Color(colorInt)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
width = if (uiState.seedColor == colorInt.toInt()) 3.dp else 0.dp,
|
||||
color = if (uiState.seedColor == colorInt.toInt()) MaterialTheme.colorScheme.onSurface else Color.Transparent,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable { viewModel.setSeedColor(colorInt.toInt()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeModeItem(title: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package cn.airnan.a2fair.ui.settings.backup
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.core.backup.BackupManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
backupManager: BackupManager,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var isExporting by remember { mutableStateOf(true) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
|
||||
) { uri: Uri? ->
|
||||
if (uri != null && password.isNotEmpty()) {
|
||||
scope.launch {
|
||||
val result = backupManager.exportData(uri, password.toCharArray())
|
||||
if (result.isSuccess) {
|
||||
Toast.makeText(context, context.getString(R.string.export_successful), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.export_failed, result.exceptionOrNull()?.message ?: ""), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null && password.isNotEmpty()) {
|
||||
scope.launch {
|
||||
val result = backupManager.importData(uri, password.toCharArray())
|
||||
if (result.isSuccess) {
|
||||
Toast.makeText(context, context.getString(R.string.import_successful), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.import_failed, result.exceptionOrNull()?.message ?: ""), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.backup_restore)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.export_data),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.export_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
isExporting = true
|
||||
showPasswordDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.export_to_file))
|
||||
}
|
||||
|
||||
Divider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.import_data),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.import_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
isExporting = false
|
||||
showPasswordDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.import_from_file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPasswordDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showPasswordDialog = false
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
},
|
||||
title = { Text(if (isExporting) stringResource(R.string.set_backup_password) else stringResource(R.string.enter_backup_password)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (isExporting) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(R.string.confirm_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = password != confirmPassword && confirmPassword.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (password.isNotEmpty() && (!isExporting || password == confirmPassword)) {
|
||||
showPasswordDialog = false
|
||||
if (isExporting) {
|
||||
exportLauncher.launch("a2fair_backup_${System.currentTimeMillis()}.bak")
|
||||
} else {
|
||||
importLauncher.launch(arrayOf("application/octet-stream", "*/*"))
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = password.isNotEmpty() && (!isExporting || password == confirmPassword)
|
||||
) {
|
||||
Text(if (isExporting) stringResource(R.string.export_btn) else stringResource(R.string.import_btn))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showPasswordDialog = false
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package cn.airnan.a2fair.ui.settings.security
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Fingerprint
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.R
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LockScreen(
|
||||
onUnlock: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val settingsRepo = app.settingsRepository
|
||||
|
||||
val isBiometricEnabled by settingsRepo.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val pinCode by settingsRepo.pinCode.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
val failedAttempts by settingsRepo.failedAttempts.collectAsStateWithLifecycle(initialValue = 0)
|
||||
val lockoutEndTime by settingsRepo.lockoutEndTime.collectAsStateWithLifecycle(initialValue = 0L)
|
||||
|
||||
var enteredPin by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val incorrectPinMsg = stringResource(R.string.incorrect_pin)
|
||||
var remainingLockOutTime by remember { mutableStateOf(0L) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val biometricFailedMsg = stringResource(R.string.biometric_failed)
|
||||
|
||||
LaunchedEffect(lockoutEndTime) {
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now < lockoutEndTime) {
|
||||
remainingLockOutTime = (lockoutEndTime - now) / 1000
|
||||
} else {
|
||||
remainingLockOutTime = 0L
|
||||
if (failedAttempts >= 5) {
|
||||
settingsRepo.setFailedAttempts(0)
|
||||
}
|
||||
break
|
||||
}
|
||||
kotlinx.coroutines.delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto prompt biometric if enabled
|
||||
LaunchedEffect(isBiometricEnabled) {
|
||||
if (isBiometricEnabled) {
|
||||
showBiometricPrompt(context as FragmentActivity, onUnlock, onFailed = {
|
||||
errorMessage = biometricFailedMsg
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = stringResource(R.string.lock_icon),
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.enter_pin),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = enteredPin,
|
||||
onValueChange = {
|
||||
enteredPin = it
|
||||
errorMessage = null
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
isError = errorMessage != null,
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (remainingLockOutTime > 0) {
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.try_again_in, remainingLockOutTime),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.8f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isBiometricEnabled && remainingLockOutTime == 0L) {
|
||||
IconButton(onClick = {
|
||||
showBiometricPrompt(context as FragmentActivity, onUnlock, onFailed = {
|
||||
errorMessage = biometricFailedMsg
|
||||
})
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fingerprint,
|
||||
contentDescription = stringResource(R.string.use_biometric),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(48.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (pinCode != null && enteredPin == pinCode) {
|
||||
scope.launch {
|
||||
settingsRepo.setFailedAttempts(0)
|
||||
settingsRepo.setLockoutEndTime(0L)
|
||||
}
|
||||
onUnlock()
|
||||
} else {
|
||||
val newAttempts = failedAttempts + 1
|
||||
errorMessage = incorrectPinMsg
|
||||
enteredPin = ""
|
||||
scope.launch {
|
||||
settingsRepo.setFailedAttempts(newAttempts)
|
||||
if (newAttempts >= 5) {
|
||||
val lockoutTime = System.currentTimeMillis() + 30000L // 30s lockout
|
||||
settingsRepo.setLockoutEndTime(lockoutTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = remainingLockOutTime == 0L && enteredPin.isNotEmpty()
|
||||
) {
|
||||
Text(stringResource(R.string.unlock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBiometricPrompt(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onFailed: () -> Unit
|
||||
) {
|
||||
val biometricManager = BiometricManager.from(activity)
|
||||
if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
return
|
||||
}
|
||||
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val biometricPrompt = BiometricPrompt(activity, executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
onFailed()
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
onFailed()
|
||||
}
|
||||
})
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(activity.getString(R.string.unlock_2fair))
|
||||
.setSubtitle(activity.getString(R.string.biometric_prompt_subtitle))
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cn.airnan.a2fair.ui.settings.security
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PinSetupScreen(
|
||||
settingsRepository: SettingsRepository,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var step by remember { mutableStateOf(1) }
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var confirmPin by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val pinTooShortMsg = stringResource(R.string.pin_too_short)
|
||||
val pinsDoNotMatchMsg = stringResource(R.string.pins_do_not_match)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.set_pin)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (step == 1) stringResource(R.string.enter_new_pin) else stringResource(R.string.confirm_pin),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = if (step == 1) pin else confirmPin,
|
||||
onValueChange = {
|
||||
if (step == 1) {
|
||||
pin = it
|
||||
} else {
|
||||
confirmPin = it
|
||||
}
|
||||
errorMessage = null
|
||||
},
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
isError = errorMessage != null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (step == 1) {
|
||||
if (pin.length < 4) {
|
||||
errorMessage = pinTooShortMsg
|
||||
} else {
|
||||
step = 2
|
||||
}
|
||||
} else {
|
||||
if (pin == confirmPin) {
|
||||
scope.launch {
|
||||
settingsRepository.setPinCode(pin)
|
||||
settingsRepository.setPinEnabled(true)
|
||||
onNavigateBack()
|
||||
}
|
||||
} else {
|
||||
errorMessage = pinsDoNotMatchMsg
|
||||
confirmPin = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (step == 1) stringResource(R.string.next) else stringResource(R.string.confirm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cn.airnan.a2fair.ui.settings.security
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SecuritySettingsScreen(
|
||||
settingsRepository: SettingsRepository,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPinSetup: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isPinEnabled by settingsRepository.isPinEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isBiometricEnabled by settingsRepository.isBiometricEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isFlagSecureEnabled by settingsRepository.isFlagSecureEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.security)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(stringResource(R.string.app_lock_pin), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.app_lock_pin_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isPinEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
onNavigateToPinSetup()
|
||||
} else {
|
||||
scope.launch {
|
||||
settingsRepository.setPinEnabled(false)
|
||||
settingsRepository.setPinCode(null)
|
||||
settingsRepository.setBiometricEnabled(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (isPinEnabled) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(stringResource(R.string.biometric_unlock), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.biometric_unlock_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isBiometricEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
scope.launch {
|
||||
settingsRepository.setBiometricEnabled(checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(stringResource(R.string.block_screenshots), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.block_screenshots_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = isFlagSecureEnabled,
|
||||
onCheckedChange = { checked ->
|
||||
scope.launch {
|
||||
settingsRepository.setFlagSecureEnabled(checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package cn.airnan.a2fair.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.google.android.material.color.utilities.Hct
|
||||
import com.google.android.material.color.utilities.Scheme
|
||||
import com.google.android.material.color.utilities.TonalPalette
|
||||
|
||||
@Composable
|
||||
private fun colorSchemeFromSeed(seedColor: Int, darkTheme: Boolean): ColorScheme {
|
||||
val hct = Hct.fromInt(seedColor)
|
||||
val scheme = if (darkTheme) Scheme.dark(seedColor) else Scheme.light(seedColor)
|
||||
|
||||
// 中性调色板(与种子色相同色相,极低色度)
|
||||
val neutralPalette = TonalPalette.fromHueAndChroma(hct.hue, 4.0)
|
||||
val surfaceContainerLowest = Color(neutralPalette.tone(if (darkTheme) 6 else 100))
|
||||
val surfaceContainerLow = Color(neutralPalette.tone(if (darkTheme) 10 else 96))
|
||||
val surfaceContainer = Color(neutralPalette.tone(if (darkTheme) 12 else 94))
|
||||
val surfaceContainerHigh = Color(neutralPalette.tone(if (darkTheme) 17 else 92))
|
||||
val surfaceContainerHighest = Color(neutralPalette.tone(if (darkTheme) 22 else 90))
|
||||
|
||||
return if (darkTheme) {
|
||||
darkColorScheme(
|
||||
primary = Color(scheme.primary),
|
||||
onPrimary = Color(scheme.onPrimary),
|
||||
primaryContainer = Color(scheme.primaryContainer),
|
||||
onPrimaryContainer = Color(scheme.onPrimaryContainer),
|
||||
secondary = Color(scheme.secondary),
|
||||
onSecondary = Color(scheme.onSecondary),
|
||||
secondaryContainer = Color(scheme.secondaryContainer),
|
||||
onSecondaryContainer = Color(scheme.onSecondaryContainer),
|
||||
tertiary = Color(scheme.tertiary),
|
||||
onTertiary = Color(scheme.onTertiary),
|
||||
tertiaryContainer = Color(scheme.tertiaryContainer),
|
||||
onTertiaryContainer = Color(scheme.onTertiaryContainer),
|
||||
error = Color(scheme.error),
|
||||
onError = Color(scheme.onError),
|
||||
errorContainer = Color(scheme.errorContainer),
|
||||
onErrorContainer = Color(scheme.onErrorContainer),
|
||||
background = Color(scheme.background),
|
||||
onBackground = Color(scheme.onBackground),
|
||||
surface = Color(scheme.surface),
|
||||
onSurface = Color(scheme.onSurface),
|
||||
surfaceVariant = Color(scheme.surfaceVariant),
|
||||
onSurfaceVariant = Color(scheme.onSurfaceVariant),
|
||||
surfaceContainerLowest = surfaceContainerLowest,
|
||||
surfaceContainerLow = surfaceContainerLow,
|
||||
surfaceContainer = surfaceContainer,
|
||||
surfaceContainerHigh = surfaceContainerHigh,
|
||||
surfaceContainerHighest = surfaceContainerHighest,
|
||||
outline = Color(scheme.outline),
|
||||
outlineVariant = Color(scheme.outlineVariant),
|
||||
inverseSurface = Color(scheme.inverseSurface),
|
||||
inverseOnSurface = Color(scheme.inverseOnSurface),
|
||||
inversePrimary = Color(scheme.inversePrimary),
|
||||
surfaceTint = Color(scheme.primary),
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = Color(scheme.primary),
|
||||
onPrimary = Color(scheme.onPrimary),
|
||||
primaryContainer = Color(scheme.primaryContainer),
|
||||
onPrimaryContainer = Color(scheme.onPrimaryContainer),
|
||||
secondary = Color(scheme.secondary),
|
||||
onSecondary = Color(scheme.onSecondary),
|
||||
secondaryContainer = Color(scheme.secondaryContainer),
|
||||
onSecondaryContainer = Color(scheme.onSecondaryContainer),
|
||||
tertiary = Color(scheme.tertiary),
|
||||
onTertiary = Color(scheme.onTertiary),
|
||||
tertiaryContainer = Color(scheme.tertiaryContainer),
|
||||
onTertiaryContainer = Color(scheme.onTertiaryContainer),
|
||||
error = Color(scheme.error),
|
||||
onError = Color(scheme.onError),
|
||||
errorContainer = Color(scheme.errorContainer),
|
||||
onErrorContainer = Color(scheme.onErrorContainer),
|
||||
background = Color(scheme.background),
|
||||
onBackground = Color(scheme.onBackground),
|
||||
surface = Color(scheme.surface),
|
||||
onSurface = Color(scheme.onSurface),
|
||||
surfaceVariant = Color(scheme.surfaceVariant),
|
||||
onSurfaceVariant = Color(scheme.onSurfaceVariant),
|
||||
surfaceContainerLowest = surfaceContainerLowest,
|
||||
surfaceContainerLow = surfaceContainerLow,
|
||||
surfaceContainer = surfaceContainer,
|
||||
surfaceContainerHigh = surfaceContainerHigh,
|
||||
surfaceContainerHighest = surfaceContainerHighest,
|
||||
outline = Color(scheme.outline),
|
||||
outlineVariant = Color(scheme.outlineVariant),
|
||||
inverseSurface = Color(scheme.inverseSurface),
|
||||
inverseOnSurface = Color(scheme.inverseOnSurface),
|
||||
inversePrimary = Color(scheme.inversePrimary),
|
||||
surfaceTint = Color(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun _2FAirTheme(
|
||||
themeMode: Int = 0, // 0: System, 1: Light, 2: Dark
|
||||
dynamicColor: Boolean = true,
|
||||
seedColor: Int = 0xFF6750A4.toInt(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
1 -> false
|
||||
2 -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
else -> colorSchemeFromSeed(seedColor, darkTheme)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = view.context.findActivity()?.window
|
||||
if (window != null) {
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.airnan.a2fair.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,406 @@
|
||||
package cn.airnan.a2fair.ui.tokens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddTokenBottomSheet(
|
||||
viewModel: TokensViewModel,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
val state by viewModel.addTokenState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showManualEntry by remember { mutableStateOf(false) }
|
||||
var showCameraScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val photoPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
val image = InputImage.fromFilePath(context, uri)
|
||||
val scanner = BarcodeScanning.getClient()
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val barcode = barcodes.firstOrNull { it.valueType == Barcode.TYPE_WIFI || it.rawValue?.startsWith("otpauth://") == true }
|
||||
?: barcodes.firstOrNull()
|
||||
val rawValue = barcode?.rawValue
|
||||
if (rawValue != null && rawValue.startsWith("otpauth://")) {
|
||||
viewModel.addTokenFromUri(rawValue)
|
||||
} else {
|
||||
// Handle error: no valid QR code found
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
// Handle error
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
showCameraScanner = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.isSuccess) {
|
||||
if (state.isSuccess) {
|
||||
viewModel.resetAddState()
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
viewModel.resetAddState()
|
||||
onDismissRequest()
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = if (showManualEntry) stringResource(R.string.enter_details_manually) else stringResource(R.string.add_token_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
if (state.isError) {
|
||||
Text(
|
||||
text = state.errorMessage ?: stringResource(R.string.unknown_error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!showManualEntry) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.scan_qr_code)) },
|
||||
leadingContent = { Icon(Icons.Default.QrCodeScanner, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
showCameraScanner = true
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.import_from_image)) },
|
||||
leadingContent = { Icon(Icons.Default.Image, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
photoPickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.enter_manually)) },
|
||||
leadingContent = { Icon(Icons.Default.Keyboard, contentDescription = null) },
|
||||
modifier = Modifier.clickable { showManualEntry = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
} else {
|
||||
ManualEntryForm(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onCancel = { showManualEntry = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCameraScanner) {
|
||||
CameraScannerDialog(
|
||||
onScan = { uri ->
|
||||
showCameraScanner = false
|
||||
viewModel.addTokenFromUri(uri)
|
||||
},
|
||||
onDismissRequest = { showCameraScanner = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ManualEntryForm(
|
||||
state: AddTokenUiState,
|
||||
viewModel: TokensViewModel,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.accountName,
|
||||
onValueChange = viewModel::updateAccountName,
|
||||
label = { Text(stringResource(R.string.account_name_required)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.secret,
|
||||
onValueChange = viewModel::updateSecret,
|
||||
label = { Text(stringResource(R.string.secret_key_required)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.issuer,
|
||||
onValueChange = viewModel::updateIssuer,
|
||||
label = { Text(stringResource(R.string.issuer_optional)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Type selection
|
||||
var expandedType by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedType,
|
||||
onExpandedChange = { expandedType = !expandedType },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.tokenType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedType) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedType,
|
||||
onDismissRequest = { expandedType = false }
|
||||
) {
|
||||
TokenType.entries.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name) },
|
||||
onClick = {
|
||||
viewModel.updateTokenType(type)
|
||||
expandedType = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced options could be hidden behind a toggle, but for simplicity we show them
|
||||
var expandedAlgorithm by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expandedAlgorithm,
|
||||
onExpandedChange = { expandedAlgorithm = !expandedAlgorithm },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.algorithm.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.algorithm_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedAlgorithm) },
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expandedAlgorithm,
|
||||
onDismissRequest = { expandedAlgorithm = false }
|
||||
) {
|
||||
HashAlgorithm.entries.forEach { alg ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(alg.name) },
|
||||
onClick = {
|
||||
viewModel.updateAlgorithm(alg)
|
||||
expandedAlgorithm = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = state.digits.toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let { d -> viewModel.updateDigits(d) } },
|
||||
label = { Text(stringResource(R.string.digits_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
if (state.tokenType == TokenType.HOTP) {
|
||||
OutlinedTextField(
|
||||
value = state.counter.toString(),
|
||||
onValueChange = { it.toLongOrNull()?.let { c -> viewModel.updateCounter(c) } },
|
||||
label = { Text(stringResource(R.string.counter_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.period.toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let { p -> viewModel.updatePeriod(p) } },
|
||||
label = { Text(stringResource(R.string.period_label)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = viewModel::addTokenManually) {
|
||||
Text(stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraScannerDialog(
|
||||
onScan: (String) -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CameraPreview(onScan = onScan)
|
||||
IconButton(
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close), tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraPreview(onScan: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).also {
|
||||
previewView = it
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
LaunchedEffect(previewView) {
|
||||
val pv = previewView ?: return@LaunchedEffect
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(pv.surfaceProvider)
|
||||
}
|
||||
val imageAnalyzer = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(Executors.newSingleThreadExecutor(), ZxingAnalyzer(onScan))
|
||||
}
|
||||
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner, cameraSelector, preview, imageAnalyzer
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
}
|
||||
|
||||
class ZxingAnalyzer(private val onScan: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader()
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
val buffer = image.planes[0].buffer
|
||||
val data = ByteArray(buffer.capacity())
|
||||
buffer.get(data)
|
||||
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data,
|
||||
image.width,
|
||||
image.height,
|
||||
0,
|
||||
0,
|
||||
image.width,
|
||||
image.height,
|
||||
false
|
||||
)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
|
||||
try {
|
||||
val result = reader.decode(binaryBitmap)
|
||||
val text = result.text
|
||||
if (text.startsWith("otpauth://")) {
|
||||
onScan(text)
|
||||
}
|
||||
} catch (e: NotFoundException) {
|
||||
// No barcode found
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package cn.airnan.a2fair.ui.tokens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.core.NativeLib
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TokensScreen() {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as A2FairApplication
|
||||
val viewModel: TokensViewModel = viewModel(
|
||||
factory = TokensViewModel.Factory(app)
|
||||
)
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var showAddBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tokens)) },
|
||||
actions = {
|
||||
// Optional action icons
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showAddBottomSheet = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_token))
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
// Search Bar
|
||||
OutlinedTextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = { viewModel.updateSearchQuery(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
placeholder = { Text(stringResource(R.string.search_tokens)) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.search)) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.large
|
||||
)
|
||||
|
||||
// Token List
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 80.dp)
|
||||
) {
|
||||
uiState.groupedTokens.forEach { (group, tokens) ->
|
||||
val groupId = group?.id
|
||||
val isCollapsed = uiState.collapsedGroups.contains(groupId ?: -1L)
|
||||
|
||||
item(key = "group_${groupId ?: "unassigned"}") {
|
||||
GroupHeader(
|
||||
group = group,
|
||||
isCollapsed = isCollapsed,
|
||||
onToggle = { viewModel.toggleGroupCollapse(groupId) }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isCollapsed) {
|
||||
items(tokens, key = { it.id }) { token ->
|
||||
TokenCard(
|
||||
token = token,
|
||||
onIncrement = { viewModel.incrementCounter(token) },
|
||||
onDelete = { viewModel.deleteToken(token) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddBottomSheet) {
|
||||
AddTokenBottomSheet(
|
||||
viewModel = viewModel,
|
||||
onDismissRequest = { showAddBottomSheet = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupHeader(
|
||||
group: Group?,
|
||||
isCollapsed: Boolean,
|
||||
onToggle: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = group?.name ?: stringResource(R.string.uncategorized),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
|
||||
contentDescription = stringResource(R.string.toggle_group)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TokenCard(
|
||||
token: Token,
|
||||
onIncrement: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val otpPlaceholder = stringResource(R.string.otp_placeholder)
|
||||
val errorText = stringResource(R.string.error)
|
||||
var currentCode by remember { mutableStateOf(otpPlaceholder) }
|
||||
var progress by remember { mutableStateOf(1f) }
|
||||
var remainingSeconds by remember { mutableStateOf(0) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val secretBytes = remember(token.secret.value) {
|
||||
try {
|
||||
NativeLib.base32Decode(token.secret.value)
|
||||
} catch (e: Exception) {
|
||||
ByteArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(token, secretBytes) {
|
||||
if (secretBytes.isEmpty()) {
|
||||
currentCode = errorText
|
||||
return@LaunchedEffect
|
||||
}
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
when (token.tokenType) {
|
||||
TokenType.TOTP -> {
|
||||
val periodMs = token.period * 1000L
|
||||
val time = now / 1000L
|
||||
currentCode = NativeLib.generateTOTP(
|
||||
secretBytes,
|
||||
time,
|
||||
token.period,
|
||||
token.digits,
|
||||
token.algorithm.ordinal
|
||||
)
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
remainingSeconds = (remaining / 1000L).toInt()
|
||||
}
|
||||
TokenType.STEAM -> {
|
||||
val time = now / 1000L
|
||||
currentCode = NativeLib.generateSteam(
|
||||
secretBytes,
|
||||
time
|
||||
)
|
||||
val periodMs = 30000L
|
||||
val remaining = periodMs - (now % periodMs)
|
||||
progress = remaining.toFloat() / periodMs.toFloat()
|
||||
remainingSeconds = (remaining / 1000L).toInt()
|
||||
}
|
||||
TokenType.HOTP -> {
|
||||
currentCode = NativeLib.generateHOTP(
|
||||
secretBytes,
|
||||
token.counter,
|
||||
token.digits,
|
||||
token.algorithm.ordinal
|
||||
)
|
||||
progress = 1f
|
||||
}
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
},
|
||||
onLongClick = { showMenu = true }
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TokenIcon(token)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = token.serviceName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (token.issuer.isNotBlank()) {
|
||||
Text(
|
||||
text = token.issuer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = formatCode(currentCode),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
letterSpacing = 2.sp,
|
||||
fontFeatureSettings = "tnum"
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
if (token.tokenType == TokenType.HOTP) {
|
||||
IconButton(onClick = onIncrement) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.refresh_hotp))
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(44.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
Text(
|
||||
text = "$remainingSeconds",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (progress < 0.15f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.copy_code)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
copyToClipboard(context, currentCode, token.serviceName)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.ContentCopy, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.edit)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.delete)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TokenIcon(token: Token) {
|
||||
val letter = token.issuer.takeIf { it.isNotBlank() }?.firstOrNull()?.uppercaseChar()
|
||||
?: token.serviceName.firstOrNull()?.uppercaseChar()
|
||||
?: '?'
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = letter.toString(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatCode(code: String): String {
|
||||
if (code.length == 6) {
|
||||
return "${code.substring(0, 3)} ${code.substring(3)}"
|
||||
}
|
||||
if (code.length == 8) {
|
||||
return "${code.substring(0, 4)} ${code.substring(4)}"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, code: String, label: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("OTP Code", code)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, context.getString(R.string.code_copied, label), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package cn.airnan.a2fair.ui.tokens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cn.airnan.a2fair.A2FairApplication
|
||||
import cn.airnan.a2fair.R
|
||||
import cn.airnan.a2fair.core.OtpUriParser
|
||||
import cn.airnan.a2fair.data.SecretString
|
||||
import cn.airnan.a2fair.data.entity.HashAlgorithm
|
||||
import cn.airnan.a2fair.data.entity.Token
|
||||
import cn.airnan.a2fair.data.entity.TokenType
|
||||
import cn.airnan.a2fair.data.repository.TokenRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import cn.airnan.a2fair.data.entity.Group
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
|
||||
data class AddTokenUiState(
|
||||
val issuer: String = "",
|
||||
val accountName: String = "",
|
||||
val secret: String = "",
|
||||
val tokenType: TokenType = TokenType.TOTP,
|
||||
val algorithm: HashAlgorithm = HashAlgorithm.SHA1,
|
||||
val digits: Int = 6,
|
||||
val period: Int = 30,
|
||||
val counter: Long = 0,
|
||||
val isError: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isSuccess: Boolean = false
|
||||
)
|
||||
|
||||
data class TokensUiState(
|
||||
val groupedTokens: Map<Group?, List<Token>> = emptyMap(),
|
||||
val searchQuery: String = "",
|
||||
val collapsedGroups: Set<Long> = emptySet()
|
||||
)
|
||||
|
||||
class TokensViewModel(
|
||||
application: Application,
|
||||
private val tokenRepository: TokenRepository
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
private val _collapsedGroups = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
val uiState: StateFlow<TokensUiState> = combine(
|
||||
tokenRepository.getAllTokens(),
|
||||
tokenRepository.getAllGroups(),
|
||||
_searchQuery,
|
||||
_collapsedGroups
|
||||
) { tokens, groups, query, collapsed ->
|
||||
val filteredTokens = if (query.isBlank()) {
|
||||
tokens
|
||||
} else {
|
||||
tokens.filter {
|
||||
it.issuer.contains(query, ignoreCase = true) ||
|
||||
it.serviceName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val groupMap = groups.associateBy { it.id }
|
||||
|
||||
val grouped = filteredTokens.groupBy { token ->
|
||||
token.groupId?.let { groupMap[it] }
|
||||
}.toSortedMap(compareBy(nullsFirst()) { it?.sortOrder ?: Int.MAX_VALUE })
|
||||
|
||||
val sortedGrouped = grouped.mapValues { entry ->
|
||||
entry.value.sortedWith(compareBy({ it.sortOrder }, { it.serviceName }))
|
||||
}
|
||||
|
||||
TokensUiState(
|
||||
groupedTokens = sortedGrouped,
|
||||
searchQuery = query,
|
||||
collapsedGroups = collapsed
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = TokensUiState()
|
||||
)
|
||||
|
||||
fun updateSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun toggleGroupCollapse(groupId: Long?) {
|
||||
val id = groupId ?: -1L
|
||||
_collapsedGroups.update { current ->
|
||||
if (current.contains(id)) {
|
||||
current - id
|
||||
} else {
|
||||
current + id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteToken(token: Token) {
|
||||
viewModelScope.launch {
|
||||
tokenRepository.deleteToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
fun incrementCounter(token: Token) {
|
||||
viewModelScope.launch {
|
||||
tokenRepository.updateCounter(token.id, token.counter + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private val _addTokenState = MutableStateFlow(AddTokenUiState())
|
||||
val addTokenState: StateFlow<AddTokenUiState> = _addTokenState.asStateFlow()
|
||||
|
||||
fun updateIssuer(issuer: String) {
|
||||
_addTokenState.update { it.copy(issuer = issuer) }
|
||||
}
|
||||
|
||||
fun updateAccountName(accountName: String) {
|
||||
_addTokenState.update { it.copy(accountName = accountName) }
|
||||
}
|
||||
|
||||
fun updateSecret(secret: String) {
|
||||
// Strip spaces
|
||||
_addTokenState.update { it.copy(secret = secret.replace(" ", "")) }
|
||||
}
|
||||
|
||||
fun updateTokenType(type: TokenType) {
|
||||
_addTokenState.update { it.copy(tokenType = type) }
|
||||
}
|
||||
|
||||
fun updateAlgorithm(algorithm: HashAlgorithm) {
|
||||
_addTokenState.update { it.copy(algorithm = algorithm) }
|
||||
}
|
||||
|
||||
fun updateDigits(digits: Int) {
|
||||
_addTokenState.update { it.copy(digits = digits) }
|
||||
}
|
||||
|
||||
fun updatePeriod(period: Int) {
|
||||
_addTokenState.update { it.copy(period = period) }
|
||||
}
|
||||
|
||||
fun updateCounter(counter: Long) {
|
||||
_addTokenState.update { it.copy(counter = counter) }
|
||||
}
|
||||
|
||||
fun resetAddState() {
|
||||
_addTokenState.value = AddTokenUiState()
|
||||
}
|
||||
|
||||
fun addTokenFromUri(uriString: String) {
|
||||
try {
|
||||
val token = OtpUriParser.parse(uriString)
|
||||
saveToken(token)
|
||||
} catch (e: Exception) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = getApplication<Application>().getString(R.string.failed_parse_qr, e.message ?: "")) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addTokenManually() {
|
||||
val state = _addTokenState.value
|
||||
if (state.secret.isBlank() || state.accountName.isBlank()) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = getApplication<Application>().getString(R.string.secret_and_account_required)) }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val token = Token(
|
||||
issuer = state.issuer,
|
||||
serviceName = state.accountName,
|
||||
secret = SecretString(state.secret),
|
||||
tokenType = state.tokenType,
|
||||
algorithm = state.algorithm,
|
||||
digits = state.digits,
|
||||
period = state.period,
|
||||
counter = state.counter
|
||||
)
|
||||
saveToken(token)
|
||||
} catch (e: Exception) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = e.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToken(token: Token) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
tokenRepository.insertToken(token)
|
||||
_addTokenState.update { it.copy(isSuccess = true, isError = false, errorMessage = null) }
|
||||
} catch (e: Exception) {
|
||||
_addTokenState.update { it.copy(isError = true, errorMessage = getApplication<Application>().getString(R.string.failed_save_token, e.message ?: "")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_addTokenState.update { it.copy(isError = false, errorMessage = null) }
|
||||
}
|
||||
|
||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(TokensViewModel::class.java)) {
|
||||
val app = application as A2FairApplication
|
||||
return TokensViewModel(application, app.tokenRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Add project specific R8 rules here.
|
||||
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
|
||||
#
|
||||
# For more details, see
|
||||
# https://d.android.com/r/tools/r8/keep-rules
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">2FAir</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="tokens">令牌</string>
|
||||
<string name="settings">设置</string>
|
||||
|
||||
<!-- Token List -->
|
||||
<string name="add_token">添加令牌</string>
|
||||
<string name="search_tokens">搜索令牌…</string>
|
||||
<string name="search">搜索</string>
|
||||
<string name="uncategorized">未分组</string>
|
||||
<string name="toggle_group">展开/折叠分组</string>
|
||||
<string name="otp_placeholder">------</string>
|
||||
<string name="error">错误</string>
|
||||
<string name="refresh_hotp">刷新 HOTP</string>
|
||||
<string name="copy_code">复制验证码</string>
|
||||
<string name="edit">编辑</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="code_copied">已复制 %s 的验证码</string>
|
||||
|
||||
<!-- Add Token -->
|
||||
<string name="scan_qr_code">扫描二维码</string>
|
||||
<string name="import_from_image">从图片导入</string>
|
||||
<string name="enter_manually">手动输入</string>
|
||||
<string name="enter_details_manually">手动输入详情</string>
|
||||
<string name="add_token_title">添加令牌</string>
|
||||
<string name="account_name_required">账户名称(必填)</string>
|
||||
<string name="secret_key_required">密钥(必填)</string>
|
||||
<string name="issuer_optional">发行方(选填)</string>
|
||||
<string name="type_label">类型</string>
|
||||
<string name="algorithm_label">算法</string>
|
||||
<string name="digits_label">位数</string>
|
||||
<string name="counter_label">计数器</string>
|
||||
<string name="period_label">周期</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="add">添加</string>
|
||||
<string name="close">关闭</string>
|
||||
<string name="unknown_error">未知错误</string>
|
||||
<string name="failed_parse_qr">解析二维码失败:%s</string>
|
||||
<string name="secret_and_account_required">密钥和账户名称为必填项</string>
|
||||
<string name="failed_save_token">保存令牌失败:%s</string>
|
||||
|
||||
<!-- Settings Main -->
|
||||
<string name="security">安全</string>
|
||||
<string name="backup_restore">备份与恢复</string>
|
||||
<string name="theme">主题</string>
|
||||
<string name="about">关于</string>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<string name="app_lock_pin">应用锁 PIN 码</string>
|
||||
<string name="app_lock_pin_desc">打开应用时需要输入 PIN 码</string>
|
||||
<string name="biometric_unlock">生物识别解锁</string>
|
||||
<string name="biometric_unlock_desc">使用指纹/面部解锁应用</string>
|
||||
<string name="block_screenshots">禁止截屏</string>
|
||||
<string name="block_screenshots_desc">防止对应用内容进行截屏或录屏</string>
|
||||
|
||||
<!-- PIN Setup -->
|
||||
<string name="set_pin">设置 PIN 码</string>
|
||||
<string name="enter_new_pin">输入新 PIN 码</string>
|
||||
<string name="confirm_pin">确认 PIN 码</string>
|
||||
<string name="pin_too_short">PIN 码至少需要 4 位</string>
|
||||
<string name="pins_do_not_match">两次输入的 PIN 码不一致</string>
|
||||
<string name="next">下一步</string>
|
||||
<string name="confirm">确认</string>
|
||||
|
||||
<!-- Lock Screen -->
|
||||
<string name="enter_pin">请输入 PIN 码</string>
|
||||
<string name="incorrect_pin">PIN 码错误</string>
|
||||
<string name="unlock">解锁</string>
|
||||
<string name="lock_icon">锁定</string>
|
||||
<string name="use_biometric">使用生物识别</string>
|
||||
<string name="biometric_failed">生物识别验证失败</string>
|
||||
<string name="try_again_in">请等待 %d 秒后重试</string>
|
||||
<string name="unlock_2fair">解锁 2FAir</string>
|
||||
<string name="biometric_prompt_subtitle">使用您的生物识别凭证登录</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<string name="export_successful">导出成功</string>
|
||||
<string name="export_failed">导出失败:%s</string>
|
||||
<string name="import_successful">导入成功</string>
|
||||
<string name="import_failed">导入失败:%s</string>
|
||||
<string name="export_data">导出数据</string>
|
||||
<string name="export_desc">创建令牌的加密备份文件。</string>
|
||||
<string name="export_to_file">导出到文件</string>
|
||||
<string name="import_data">导入数据</string>
|
||||
<string name="import_desc">从加密备份文件中恢复令牌。</string>
|
||||
<string name="import_from_file">从文件导入</string>
|
||||
<string name="set_backup_password">设置备份密码</string>
|
||||
<string name="enter_backup_password">输入备份密码</string>
|
||||
<string name="password">密码</string>
|
||||
<string name="confirm_password">确认密码</string>
|
||||
<string name="export_btn">导出</string>
|
||||
<string name="import_btn">导入</string>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<string name="theme_settings">主题设置</string>
|
||||
<string name="theme_mode">主题模式</string>
|
||||
<string name="system">跟随系统</string>
|
||||
<string name="light">浅色</string>
|
||||
<string name="dark">深色</string>
|
||||
<string name="color_palette">色板</string>
|
||||
<string name="dynamic_color">动态取色</string>
|
||||
<string name="dynamic_color_desc">使用壁纸提取的颜色(Android 12+)</string>
|
||||
<string name="seed_color">主题色</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="back">返回</string>
|
||||
<string name="app_icon">应用图标</string>
|
||||
<string name="version_format">版本 %s</string>
|
||||
<string name="source_code">源代码</string>
|
||||
<string name="view_on_github">在 GitHub 上查看</string>
|
||||
<string name="open_source_licenses">开源许可</string>
|
||||
<string name="copyright">© 2026 2FAir</string>
|
||||
|
||||
<!-- Language -->
|
||||
<string name="language">语言</string>
|
||||
<string name="lang_follow_system">跟随系统</string>
|
||||
<string name="lang_zh_cn">简体中文</string>
|
||||
<string name="lang_zh_tw">繁體中文</string>
|
||||
<string name="lang_en">English</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">2FAir</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="tokens">權杖</string>
|
||||
<string name="settings">設定</string>
|
||||
|
||||
<!-- Token List -->
|
||||
<string name="add_token">新增權杖</string>
|
||||
<string name="search_tokens">搜尋權杖…</string>
|
||||
<string name="search">搜尋</string>
|
||||
<string name="uncategorized">未分組</string>
|
||||
<string name="toggle_group">展開/折疊分組</string>
|
||||
<string name="otp_placeholder">------</string>
|
||||
<string name="error">錯誤</string>
|
||||
<string name="refresh_hotp">重新整理 HOTP</string>
|
||||
<string name="copy_code">複製驗證碼</string>
|
||||
<string name="edit">編輯</string>
|
||||
<string name="delete">刪除</string>
|
||||
<string name="code_copied">已複製 %s 的驗證碼</string>
|
||||
|
||||
<!-- Add Token -->
|
||||
<string name="scan_qr_code">掃描 QR 碼</string>
|
||||
<string name="import_from_image">從圖片匯入</string>
|
||||
<string name="enter_manually">手動輸入</string>
|
||||
<string name="enter_details_manually">手動輸入詳情</string>
|
||||
<string name="add_token_title">新增權杖</string>
|
||||
<string name="account_name_required">帳戶名稱(必填)</string>
|
||||
<string name="secret_key_required">金鑰(必填)</string>
|
||||
<string name="issuer_optional">發行方(選填)</string>
|
||||
<string name="type_label">類型</string>
|
||||
<string name="algorithm_label">演算法</string>
|
||||
<string name="digits_label">位數</string>
|
||||
<string name="counter_label">計數器</string>
|
||||
<string name="period_label">週期</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="add">新增</string>
|
||||
<string name="close">關閉</string>
|
||||
<string name="unknown_error">未知錯誤</string>
|
||||
<string name="failed_parse_qr">解析 QR 碼失敗:%s</string>
|
||||
<string name="secret_and_account_required">金鑰和帳戶名稱為必填項</string>
|
||||
<string name="failed_save_token">儲存權杖失敗:%s</string>
|
||||
|
||||
<!-- Settings Main -->
|
||||
<string name="security">安全性</string>
|
||||
<string name="backup_restore">備份與還原</string>
|
||||
<string name="theme">主題</string>
|
||||
<string name="about">關於</string>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<string name="app_lock_pin">應用程式 PIN 碼鎖定</string>
|
||||
<string name="app_lock_pin_desc">開啟應用程式時需要輸入 PIN 碼</string>
|
||||
<string name="biometric_unlock">生物辨識解鎖</string>
|
||||
<string name="biometric_unlock_desc">使用指紋/臉部解鎖應用程式</string>
|
||||
<string name="block_screenshots">禁止截圖</string>
|
||||
<string name="block_screenshots_desc">防止對應用程式內容進行截圖或錄影</string>
|
||||
|
||||
<!-- PIN Setup -->
|
||||
<string name="set_pin">設定 PIN 碼</string>
|
||||
<string name="enter_new_pin">輸入新 PIN 碼</string>
|
||||
<string name="confirm_pin">確認 PIN 碼</string>
|
||||
<string name="pin_too_short">PIN 碼至少需要 4 位</string>
|
||||
<string name="pins_do_not_match">兩次輸入的 PIN 碼不一致</string>
|
||||
<string name="next">下一步</string>
|
||||
<string name="confirm">確認</string>
|
||||
|
||||
<!-- Lock Screen -->
|
||||
<string name="enter_pin">請輸入 PIN 碼</string>
|
||||
<string name="incorrect_pin">PIN 碼錯誤</string>
|
||||
<string name="unlock">解鎖</string>
|
||||
<string name="lock_icon">鎖定</string>
|
||||
<string name="use_biometric">使用生物辨識</string>
|
||||
<string name="biometric_failed">生物辨識驗證失敗</string>
|
||||
<string name="try_again_in">請等待 %d 秒後重試</string>
|
||||
<string name="unlock_2fair">解鎖 2FAir</string>
|
||||
<string name="biometric_prompt_subtitle">使用您的生物辨識憑證登入</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<string name="export_successful">匯出成功</string>
|
||||
<string name="export_failed">匯出失敗:%s</string>
|
||||
<string name="import_successful">匯入成功</string>
|
||||
<string name="import_failed">匯入失敗:%s</string>
|
||||
<string name="export_data">匯出資料</string>
|
||||
<string name="export_desc">建立權杖的加密備份檔案。</string>
|
||||
<string name="export_to_file">匯出至檔案</string>
|
||||
<string name="import_data">匯入資料</string>
|
||||
<string name="import_desc">從加密備份檔案中還原權杖。</string>
|
||||
<string name="import_from_file">從檔案匯入</string>
|
||||
<string name="set_backup_password">設定備份密碼</string>
|
||||
<string name="enter_backup_password">輸入備份密碼</string>
|
||||
<string name="password">密碼</string>
|
||||
<string name="confirm_password">確認密碼</string>
|
||||
<string name="export_btn">匯出</string>
|
||||
<string name="import_btn">匯入</string>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<string name="theme_settings">主題設定</string>
|
||||
<string name="theme_mode">主題模式</string>
|
||||
<string name="system">跟隨系統</string>
|
||||
<string name="light">淺色</string>
|
||||
<string name="dark">深色</string>
|
||||
<string name="color_palette">色板</string>
|
||||
<string name="dynamic_color">動態取色</string>
|
||||
<string name="dynamic_color_desc">使用桌布提取的顏色(Android 12+)</string>
|
||||
<string name="seed_color">主題色</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="back">返回</string>
|
||||
<string name="app_icon">應用程式圖示</string>
|
||||
<string name="version_format">版本 %s</string>
|
||||
<string name="source_code">原始碼</string>
|
||||
<string name="view_on_github">在 GitHub 上檢視</string>
|
||||
<string name="open_source_licenses">開放原始碼授權</string>
|
||||
<string name="copyright">© 2026 2FAir</string>
|
||||
|
||||
<!-- Language -->
|
||||
<string name="language">語言</string>
|
||||
<string name="lang_follow_system">跟隨系統</string>
|
||||
<string name="lang_zh_cn">简体中文</string>
|
||||
<string name="lang_zh_tw">繁體中文</string>
|
||||
<string name="lang_en">English</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">2FAir</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="tokens">Tokens</string>
|
||||
<string name="settings">Settings</string>
|
||||
|
||||
<!-- Token List -->
|
||||
<string name="add_token">Add Token</string>
|
||||
<string name="search_tokens">Search tokens…</string>
|
||||
<string name="search">Search</string>
|
||||
<string name="uncategorized">Uncategorized</string>
|
||||
<string name="toggle_group">Toggle group</string>
|
||||
<string name="otp_placeholder">------</string>
|
||||
<string name="error">Error</string>
|
||||
<string name="refresh_hotp">Refresh HOTP</string>
|
||||
<string name="copy_code">Copy Code</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="code_copied">Code for %s copied</string>
|
||||
|
||||
<!-- Add Token -->
|
||||
<string name="scan_qr_code">Scan QR Code</string>
|
||||
<string name="import_from_image">Import from Image</string>
|
||||
<string name="enter_manually">Enter Manually</string>
|
||||
<string name="enter_details_manually">Enter Details Manually</string>
|
||||
<string name="add_token_title">Add Token</string>
|
||||
<string name="account_name_required">Account Name (Required)</string>
|
||||
<string name="secret_key_required">Secret Key (Required)</string>
|
||||
<string name="issuer_optional">Issuer (Optional)</string>
|
||||
<string name="type_label">Type</string>
|
||||
<string name="algorithm_label">Algorithm</string>
|
||||
<string name="digits_label">Digits</string>
|
||||
<string name="counter_label">Counter</string>
|
||||
<string name="period_label">Period</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="add">Add</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="unknown_error">Unknown error</string>
|
||||
<string name="failed_parse_qr">Failed to parse QR code: %s</string>
|
||||
<string name="secret_and_account_required">Secret and Account Name are required</string>
|
||||
<string name="failed_save_token">Failed to save token: %s</string>
|
||||
|
||||
<!-- Settings Main -->
|
||||
<string name="security">Security</string>
|
||||
<string name="backup_restore">Backup & Restore</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="about">About</string>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<string name="app_lock_pin">App Lock PIN</string>
|
||||
<string name="app_lock_pin_desc">Require PIN to open the app</string>
|
||||
<string name="biometric_unlock">Biometric Unlock</string>
|
||||
<string name="biometric_unlock_desc">Use fingerprint/face to unlock</string>
|
||||
<string name="block_screenshots">Block Screenshots</string>
|
||||
<string name="block_screenshots_desc">Prevent taking screenshots of the app</string>
|
||||
|
||||
<!-- PIN Setup -->
|
||||
<string name="set_pin">Set PIN</string>
|
||||
<string name="enter_new_pin">Enter a new PIN</string>
|
||||
<string name="confirm_pin">Confirm your PIN</string>
|
||||
<string name="pin_too_short">PIN must be at least 4 characters</string>
|
||||
<string name="pins_do_not_match">PINs do not match</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="confirm">Confirm</string>
|
||||
|
||||
<!-- Lock Screen -->
|
||||
<string name="enter_pin">Enter PIN</string>
|
||||
<string name="incorrect_pin">Incorrect PIN</string>
|
||||
<string name="unlock">Unlock</string>
|
||||
<string name="lock_icon">Lock</string>
|
||||
<string name="use_biometric">Use Biometric</string>
|
||||
<string name="biometric_failed">Biometric authentication failed</string>
|
||||
<string name="try_again_in">Try again in %ds</string>
|
||||
<string name="unlock_2fair">Unlock 2FAir</string>
|
||||
<string name="biometric_prompt_subtitle">Log in using your biometric credential</string>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<string name="export_successful">Export successful</string>
|
||||
<string name="export_failed">Export failed: %s</string>
|
||||
<string name="import_successful">Import successful</string>
|
||||
<string name="import_failed">Import failed: %s</string>
|
||||
<string name="export_data">Export Data</string>
|
||||
<string name="export_desc">Create an encrypted backup of your tokens.</string>
|
||||
<string name="export_to_file">Export to File</string>
|
||||
<string name="import_data">Import Data</string>
|
||||
<string name="import_desc">Restore tokens from an encrypted backup file.</string>
|
||||
<string name="import_from_file">Import from File</string>
|
||||
<string name="set_backup_password">Set Backup Password</string>
|
||||
<string name="enter_backup_password">Enter Backup Password</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="export_btn">Export</string>
|
||||
<string name="import_btn">Import</string>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<string name="theme_settings">Theme Settings</string>
|
||||
<string name="theme_mode">Theme Mode</string>
|
||||
<string name="system">System</string>
|
||||
<string name="light">Light</string>
|
||||
<string name="dark">Dark</string>
|
||||
<string name="color_palette">Color Palette</string>
|
||||
<string name="dynamic_color">Dynamic Color</string>
|
||||
<string name="dynamic_color_desc">Use colors extracted from wallpaper (Android 12+)</string>
|
||||
<string name="seed_color">Seed Color</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="back">Back</string>
|
||||
<string name="app_icon">App Icon</string>
|
||||
<string name="version_format">Version %s</string>
|
||||
<string name="source_code">Source Code</string>
|
||||
<string name="view_on_github">View on GitHub</string>
|
||||
<string name="open_source_licenses">Open Source Licenses</string>
|
||||
<string name="copyright">© 2026 2FAir</string>
|
||||
|
||||
<!-- Language -->
|
||||
<string name="language">Language</string>
|
||||
<string name="lang_follow_system">Follow System</string>
|
||||
<string name="lang_zh_cn">简体中文 (Simplified Chinese)</string>
|
||||
<string name="lang_zh_tw">繁體中文 (Traditional Chinese)</string>
|
||||
<string name="lang_en">English</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme._2FAir" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# When enabled, the Configuration Cache allows Gradle to skip the configuration
|
||||
# phase entirely if nothing that affects the build configuration (such as build scripts)
|
||||
# has changed. Additionally, Gradle applies performance optimizations to task execution.
|
||||
org.gradle.configuration-cache=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
android.disallowKotlinSourceSets=false
|
||||
@@ -0,0 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/383d972a977a9ec74f8eb175a980a2f3/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/466f185020ff3c07ab32696653613a8d/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/383d972a977a9ec74f8eb175a980a2f3/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/466f185020ff3c07ab32696653613a8d/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/6153b48c463751a85519c9ff41ad94ef/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/1eb45aa4611624296b0eece6307c88dd/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/383d972a977a9ec74f8eb175a980a2f3/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/466f185020ff3c07ab32696653613a8d/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3676ee7aa5095d7f22645eb0f22ca159/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/69a793dd932268c7d1ae9d8b855de8ed/redirect
|
||||
toolchainVersion=17
|
||||
@@ -0,0 +1,61 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
kotlin = "2.2.10"
|
||||
composeBom = "2026.02.01"
|
||||
ksp = "2.2.10-2.0.2"
|
||||
navigationCompose = "2.8.5"
|
||||
room = "2.6.1"
|
||||
cameraX = "1.4.0"
|
||||
zxing = "3.5.3"
|
||||
mlKitBarcode = "17.3.0"
|
||||
dataStore = "1.1.2"
|
||||
biometric = "1.1.0"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
|
||||
gson = "2.10.1"
|
||||
robolectric = "4.14.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
|
||||
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
|
||||
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
|
||||
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
|
||||
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
||||
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlKitBarcode" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStore" }
|
||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCrypto" }
|
||||
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#Tue Jun 16 13:49:32 CST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,27 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "2FAir"
|
||||
include(":app")
|
||||
|
||||