0.0.1版本

This commit is contained in:
2026-06-16 18:30:19 +08:00
parent f10504e4a1
commit 128226e57f
84 changed files with 5571 additions and 0 deletions
+95
View File
@@ -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)
}
+44
View File
@@ -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.** { *; }
+33
View File
@@ -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>
+26
View File
@@ -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})
+60
View File
@@ -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;
}
+11
View File
@@ -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
+56
View File
@@ -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>();
}
}
+16
View File
@@ -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
+80
View File
@@ -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());
}
+61
View File
@@ -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;
}
+16
View File
@@ -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
+86
View File
@@ -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);
}
}
+23
View File
@@ -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
+75
View File
@@ -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);
}
}
+23
View File
@@ -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
+96
View File
@@ -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);
}
}
+23
View File
@@ -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")
}
}
}
+12
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+123
View File
@@ -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>
+123
View File
@@ -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>
+10
View File
@@ -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>
+123
View File
@@ -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 &amp; 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>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme._2FAir" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -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>