init: init proj
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/local.properties
|
||||
/.idea
|
||||
**/build
|
||||
/.hvigor
|
||||
.cxx
|
||||
/.clangd
|
||||
/.clang-format
|
||||
/.clang-tidy
|
||||
**/.test
|
||||
/.appanalyzer
|
||||
10
AppScope/app.json5
Normal file
10
AppScope/app.json5
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.njcqit.authenticator",
|
||||
"vendor": "Nanjing Changqing Information Technology Co., Ltd.",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
8
AppScope/resources/base/element/string.json
Normal file
8
AppScope/resources/base/element/string.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "Authenticator"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
AppScope/resources/base/media/background.png
Normal file
BIN
AppScope/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
AppScope/resources/base/media/foreground.png
Normal file
BIN
AppScope/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
AppScope/resources/base/media/icon.png
Normal file
BIN
AppScope/resources/base/media/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 874 KiB |
7
AppScope/resources/base/media/layered_image.json
Normal file
7
AppScope/resources/base/media/layered_image.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
160
CLAUDE.md
Normal file
160
CLAUDE.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个 **MFA Authenticator (多因素身份验证器)** HarmonyOS 应用程序,使用 ArkTS (扩展的 TypeScript) 和 ArkUI 框架开发。应用实现了基于时间的一次性密码(TOTP)算法,用于生成双因素认证验证码。
|
||||
|
||||
### 核心功能
|
||||
- TOTP 验证码生成(30秒自动刷新)
|
||||
- 账户管理(添加、编辑、删除)
|
||||
- 本地加密存储
|
||||
- 搜索功能
|
||||
- 暗色模式支持
|
||||
- Apple Design 风格的简洁 UI
|
||||
|
||||
## 构建系统
|
||||
|
||||
项目使用 Hvigor 作为构建工具:
|
||||
|
||||
- **构建命令**: 通过 DevEco Studio 或 `hvigorw` 脚本构建
|
||||
- **清理**: `hvigorw clean`
|
||||
- **构建**: `hvigorw assembleHap`
|
||||
- **调试构建**: `hvigorw --mode debug assembleHap`
|
||||
- **发布构建**: `hvigorw --mode release assembleHap`
|
||||
|
||||
## 测试
|
||||
|
||||
项目包含两种测试类型:
|
||||
|
||||
- **本地单元测试**: `entry/src/test/` - 使用 Hypium 框架
|
||||
- **设备测试**: `entry/src/ohosTest/` - 需要在设备或模拟器上运行
|
||||
- **运行测试**: 通过 DevEco Studio 的测试配置运行
|
||||
|
||||
依赖的测试框架:
|
||||
- `@ohos/hypium`: 单元测试框架
|
||||
- `@ohos/hamock`: Mock 工具
|
||||
|
||||
## 代码架构
|
||||
|
||||
### 模块结构
|
||||
|
||||
```
|
||||
entry/src/main/ets/
|
||||
├── common/
|
||||
│ ├── constants/
|
||||
│ │ └── AppConstants.ets # 应用常量(TOTP周期、UI常量等)
|
||||
│ └── utils/
|
||||
│ ├── Base32.ets # Base32 编解码工具
|
||||
│ ├── TOTPGenerator.ets # TOTP 算法实现
|
||||
│ └── StorageUtil.ets # 本地存储工具(Preferences)
|
||||
├── model/
|
||||
│ └── Account.ets # 账户数据模型
|
||||
├── viewmodel/
|
||||
│ └── AccountViewModel.ets # 账户业务逻辑
|
||||
├── view/
|
||||
│ └── components/
|
||||
│ ├── AccountCard.ets # 账户卡片组件
|
||||
│ └── ProgressRing.ets # 圆形进度指示器
|
||||
├── pages/
|
||||
│ ├── AccountListPage.ets # 主页面(账户列表)
|
||||
│ ├── AddAccountPage.ets # 添加账户页面
|
||||
│ └── AccountDetailPage.ets # 账户详情/编辑页面
|
||||
├── entryability/
|
||||
│ └── EntryAbility.ets # 应用入口
|
||||
└── entrybackupability/
|
||||
└── EntryBackupAbility.ets # 备份恢复功能
|
||||
```
|
||||
|
||||
### 核心模块说明
|
||||
|
||||
#### 1. TOTP 算法实现
|
||||
- **TOTPGenerator.ets**: 基于 RFC 6238 标准实现
|
||||
- 使用 HarmonyOS 的 `cryptoFramework` 生成 HMAC-SHA1
|
||||
- 支持自定义时间步长和验证码位数
|
||||
- 提供进度计算方法
|
||||
|
||||
#### 2. 数据存储
|
||||
- **StorageUtil.ets**: 使用 Preferences API 进行持久化
|
||||
- **Account Model**: JSON 序列化存储账户信息
|
||||
- 支持加密存储(未来可扩展)
|
||||
|
||||
#### 3. UI 组件
|
||||
- **AccountCard**: 显示账户信息、验证码、进度环
|
||||
- 点击复制验证码
|
||||
- 长按显示操作菜单
|
||||
- **ProgressRing**: 圆形进度指示器,显示倒计时
|
||||
|
||||
#### 4. 页面结构
|
||||
1. **AccountListPage** (主页)
|
||||
- 账户列表展示
|
||||
- 搜索功能
|
||||
- 定时器自动刷新验证码
|
||||
|
||||
2. **AddAccountPage** (添加账户)
|
||||
- Tab 切换:扫码/手动输入
|
||||
- 表单验证
|
||||
|
||||
3. **AccountDetailPage** (编辑账户)
|
||||
- 编辑发行者和账户名
|
||||
- 查看密钥(可切换显示/隐藏)
|
||||
|
||||
### ArkTS 开发要点
|
||||
|
||||
- **文件扩展名**: `.ets` (ArkTS 文件)
|
||||
- **UI 组件**: 使用声明式语法,如 `Text`, `RelativeContainer`, `Button` 等
|
||||
- **状态管理**: 使用装饰器 `@State`, `@Prop`, `@Link` 等
|
||||
- **日志输出**: 使用 `hilog` API,格式: `hilog.info(DOMAIN, tag, message)`
|
||||
- **资源引用**: 使用 `$r('app.type.name')` 格式,如 `$r('app.string.EntryAbility_label')`
|
||||
|
||||
### 配置文件
|
||||
|
||||
- **build-profile.json5**: 应用构建配置,包含签名、目标 SDK 版本等
|
||||
- **module.json5**: 模块配置,定义 abilities、页面、权限等
|
||||
- **oh-package.json5**: 依赖包管理
|
||||
- **code-linter.json5**: 代码检查规则
|
||||
|
||||
## SDK 版本
|
||||
|
||||
- **目标 SDK**: HarmonyOS 6.0.0(20)
|
||||
- **兼容 SDK**: HarmonyOS 6.0.0(20)
|
||||
- **运行时**: HarmonyOS
|
||||
|
||||
## UI 设计规范
|
||||
|
||||
### 配色方案(Apple Design 风格)
|
||||
- **主色**: `#007AFF` (浅色) / `#0A84FF` (深色)
|
||||
- **背景色**: `#F2F2F7` (浅色) / `#000000` (深色)
|
||||
- **卡片背景**: `#FFFFFF` (浅色) / `#1C1C1E` (深色)
|
||||
- **文字颜色**: 主要 / 次要 / 三级 (响应式暗色模式)
|
||||
|
||||
### 设计元素
|
||||
- **圆角**: 12-16vp
|
||||
- **间距**: 8/12/16/24vp 系统
|
||||
- **字体大小**:
|
||||
- 标题: 28-32fp
|
||||
- 正文: 15-17fp
|
||||
- 验证码: 36fp (等宽字体)
|
||||
- 辅助文字: 13fp
|
||||
|
||||
### 交互规范
|
||||
- 卡片点击: 复制验证码
|
||||
- 长按: 显示操作菜单
|
||||
- 进度环: 30秒倒计时动画
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有源代码文件使用 `.ets` 扩展名
|
||||
- 页面路径在 `entry/src/main/resources/base/profile/main_pages.json` 中注册
|
||||
- 使用 `@kit.*` 导入系统能力,如 `@kit.AbilityKit`, `@kit.ArkUI`
|
||||
- 日志使用统一的 `AppConstants.LOG_DOMAIN` 和 `AppConstants.LOG_TAG`
|
||||
- TOTP 密钥必须是 Base32 格式
|
||||
- 应用支持自动暗色模式切换
|
||||
|
||||
## 开发建议
|
||||
|
||||
- 在 DevEco Studio 中打开项目进行开发和调试
|
||||
- 使用 HarmonyOS 模拟器或真机进行测试
|
||||
- 验证码刷新使用定时器,注意在页面销毁时清理定时器
|
||||
- 添加新页面时记得更新 `main_pages.json`
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# MFA Authenticator
|
||||
|
||||
一个基于 HarmonyOS 的多因素身份验证器应用,使用 Apple Design 风格设计。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **TOTP 验证码生成**: 基于 RFC 6238 标准的时间一次性密码算法
|
||||
- ✅ **30秒自动刷新**: 实时显示剩余时间和进度
|
||||
- ✅ **账户管理**: 添加、编辑、删除账户
|
||||
- ✅ **搜索功能**: 快速查找账户
|
||||
- ✅ **一键复制**: 点击卡片即可复制验证码
|
||||
- ✅ **本地存储**: 使用 Preferences API 安全存储
|
||||
- ✅ **暗色模式**: 自动适配系统主题
|
||||
- ✅ **简洁 UI**: Apple Design 风格,清爽高效
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **开发框架**: ArkTS + ArkUI
|
||||
- **平台**: HarmonyOS 6.0.0
|
||||
- **架构模式**: MVVM
|
||||
- **加密算法**: HMAC-SHA1/SHA256
|
||||
- **存储方案**: Preferences API
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
entry/src/main/ets/
|
||||
├── common/
|
||||
│ ├── constants/ # 常量定义
|
||||
│ └── utils/ # 工具类(TOTP、Base32、存储)
|
||||
├── model/ # 数据模型
|
||||
├── viewmodel/ # 业务逻辑
|
||||
├── view/
|
||||
│ └── components/ # 可复用组件
|
||||
└── pages/ # 页面
|
||||
├── AccountListPage.ets # 主页面
|
||||
├── AddAccountPage.ets # 添加账户
|
||||
└── AccountDetailPage.ets # 账户详情
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- DevEco Studio 5.0+
|
||||
- HarmonyOS SDK 6.0.0+
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 使用 DevEco Studio 打开项目
|
||||
2. 等待依赖安装完成
|
||||
3. 连接 HarmonyOS 设备或启动模拟器
|
||||
4. 点击运行按钮
|
||||
|
||||
### 使用说明
|
||||
|
||||
1. **添加账户**
|
||||
- 点击右上角 "+" 按钮
|
||||
- 选择"手动输入"标签
|
||||
- 填写发行者、账户名和密钥(Base32 格式)
|
||||
- 点击"保存"
|
||||
|
||||
2. **查看验证码**
|
||||
- 主页面显示所有账户和对应的验证码
|
||||
- 验证码每 30 秒自动刷新
|
||||
- 右侧圆环显示倒计时进度
|
||||
|
||||
3. **复制验证码**
|
||||
- 点击账户卡片即可复制验证码到剪贴板
|
||||
|
||||
4. **管理账户**
|
||||
- 长按账户卡片显示操作菜单
|
||||
- 可选择"编辑"或"删除"
|
||||
|
||||
## 测试账户
|
||||
|
||||
可以使用以下测试密钥进行测试:
|
||||
|
||||
```
|
||||
发行者: Test
|
||||
账户: test@example.com
|
||||
密钥: JBSWY3DPEHPK3PXP
|
||||
```
|
||||
|
||||
## 设计规范
|
||||
|
||||
### 配色方案
|
||||
- 主色: SF Blue (#007AFF)
|
||||
- 背景: 浅灰 (#F2F2F7) / 深色 (#000000)
|
||||
- 卡片: 白色 (#FFFFFF) / 深色卡片 (#1C1C1E)
|
||||
|
||||
### UI 元素
|
||||
- 圆角: 12-16vp
|
||||
- 验证码字体: 36fp 等宽字体
|
||||
- 间距系统: 8/12/16/24vp
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 所有账户数据仅存储在本地设备
|
||||
- 使用 Preferences API 进行持久化存储
|
||||
- 密钥采用 Base32 编码存储
|
||||
- 不会上传任何数据到云端
|
||||
|
||||
## 后续计划
|
||||
|
||||
- [ ] 扫码功能实现
|
||||
- [ ] 数据加密存储
|
||||
- [ ] 应用锁功能
|
||||
- [ ] 备份与恢复
|
||||
- [ ] 更多算法支持(SHA256, SHA512)
|
||||
- [ ] 批量导入导出
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
56
build-profile.json5
Normal file
56
build-profile.json5
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [
|
||||
{
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "/Users/kid/Development/HarmonyOS/hm.p12",
|
||||
"storePassword": "0000001B30243D260315C67D6B170F40AD52B72CE7FA0F8712F80BA535A6B127D9950927D0C1048EBD21F3",
|
||||
"keyAlias": "default",
|
||||
"keyPassword": "0000001BDE4BC6A4AC112DDF9966DAD89C84D0BF5244796A206B8E682A0F72CE4168D11BF41917DF95B227",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "/Users/kid/Downloads/ReleaseRelease.p7b",
|
||||
"certpath": "/Users/kid/Downloads/Release.cer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"signingConfig": "default",
|
||||
"targetSdkVersion": "6.0.0(20)",
|
||||
"compatibleSdkVersion": "6.0.0(20)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug",
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "entry",
|
||||
"srcPath": "./entry",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
32
code-linter.json5
Normal file
32
code-linter.json5
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"files": [
|
||||
"**/*.ets"
|
||||
],
|
||||
"ignore": [
|
||||
"**/src/ohosTest/**/*",
|
||||
"**/src/test/**/*",
|
||||
"**/src/mock/**/*",
|
||||
"**/node_modules/**/*",
|
||||
"**/oh_modules/**/*",
|
||||
"**/build/**/*",
|
||||
"**/.preview/**/*"
|
||||
],
|
||||
"ruleSet": [
|
||||
"plugin:@performance/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@security/no-unsafe-aes": "error",
|
||||
"@security/no-unsafe-hash": "error",
|
||||
"@security/no-unsafe-mac": "warn",
|
||||
"@security/no-unsafe-dh": "error",
|
||||
"@security/no-unsafe-dsa": "error",
|
||||
"@security/no-unsafe-ecdsa": "error",
|
||||
"@security/no-unsafe-rsa-encrypt": "error",
|
||||
"@security/no-unsafe-rsa-sign": "error",
|
||||
"@security/no-unsafe-rsa-key": "error",
|
||||
"@security/no-unsafe-dsa-key": "error",
|
||||
"@security/no-unsafe-dh-key": "error",
|
||||
"@security/no-unsafe-3des": "error"
|
||||
}
|
||||
}
|
||||
6
entry/.gitignore
vendored
Normal file
6
entry/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/.preview
|
||||
/build
|
||||
/.cxx
|
||||
/.test
|
||||
33
entry/build-profile.json5
Normal file
33
entry/build-profile.json5
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
"resOptions": {
|
||||
"copyCodeResource": {
|
||||
"enable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": false,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
},
|
||||
{
|
||||
"name": "ohosTest",
|
||||
}
|
||||
]
|
||||
}
|
||||
6
entry/hvigorfile.ts
Normal file
6
entry/hvigorfile.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
23
entry/obfuscation-rules.txt
Normal file
23
entry/obfuscation-rules.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
# Define project specific obfuscation rules here.
|
||||
# You can include the obfuscation configuration files in the current module's build-profile.json5.
|
||||
#
|
||||
# For more details, see
|
||||
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
|
||||
|
||||
# Obfuscation options:
|
||||
# -disable-obfuscation: disable all obfuscations
|
||||
# -enable-property-obfuscation: obfuscate the property names
|
||||
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
|
||||
# -compact: remove unnecessary blank spaces and all line feeds
|
||||
# -remove-log: remove all console.* statements
|
||||
# -print-namecache: print the name cache that contains the mapping from the old names to new names
|
||||
# -apply-namecache: reuse the given cache file
|
||||
|
||||
# Keep options:
|
||||
# -keep-property-name: specifies property names that you want to keep
|
||||
# -keep-global-name: specifies names that you want to keep in the global scope
|
||||
|
||||
-enable-property-obfuscation
|
||||
-enable-toplevel-obfuscation
|
||||
-enable-filename-obfuscation
|
||||
-enable-export-obfuscation
|
||||
10
entry/oh-package.json5
Normal file
10
entry/oh-package.json5
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "authenticator",
|
||||
"version": "1.0.0",
|
||||
"description": "MFA/2FA Authenticator App",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
27
entry/src/main/ets/common/constants/AppConstants.ets
Normal file
27
entry/src/main/ets/common/constants/AppConstants.ets
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 应用常量定义
|
||||
*/
|
||||
export class AppConstants {
|
||||
// TOTP 配置
|
||||
static readonly TOTP_PERIOD: number = 30; // 30秒刷新周期
|
||||
static readonly TOTP_DIGITS: number = 6; // 6位验证码
|
||||
static readonly TOTP_ALGORITHM: string = 'SHA1'; // 默认算法
|
||||
|
||||
// 存储键名
|
||||
static readonly PREFERENCES_NAME: string = 'authenticator_prefs';
|
||||
static readonly KEY_ACCOUNTS: string = 'accounts';
|
||||
static readonly KEY_ENCRYPTION_KEY: string = 'encryption_key';
|
||||
|
||||
// 日志标签
|
||||
static readonly LOG_TAG: string = 'Authenticator';
|
||||
static readonly LOG_DOMAIN: number = 0x0000;
|
||||
|
||||
// UI 常量
|
||||
static readonly CARD_BORDER_RADIUS: number = 16;
|
||||
static readonly CARD_PADDING: number = 16;
|
||||
static readonly LIST_SPACING: number = 12;
|
||||
static readonly PAGE_PADDING: number = 16;
|
||||
|
||||
// 动画时长
|
||||
static readonly ANIMATION_DURATION: number = 300;
|
||||
}
|
||||
93
entry/src/main/ets/common/utils/Base32.ets
Normal file
93
entry/src/main/ets/common/utils/Base32.ets
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Base32 编解码工具类
|
||||
* 用于处理 TOTP 密钥的编解码
|
||||
*/
|
||||
class Base32Class {
|
||||
private readonly CHARSET: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Base32 解码
|
||||
* @param encoded Base32 编码的字符串
|
||||
* @returns 解码后的字节数组
|
||||
*/
|
||||
decode(encoded: string): Uint8Array {
|
||||
// 移除空格和转换为大写
|
||||
encoded = encoded.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
if (encoded.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
// 计算输出长度
|
||||
const outputLength = Math.floor(encoded.length * 5 / 8);
|
||||
const result = new Uint8Array(outputLength);
|
||||
|
||||
let buffer = 0;
|
||||
let bitsLeft = 0;
|
||||
let index = 0;
|
||||
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
const char = encoded.charAt(i);
|
||||
|
||||
// 忽略填充字符
|
||||
if (char === '=') {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = this.CHARSET.indexOf(char);
|
||||
if (value === -1) {
|
||||
throw new Error(`Invalid Base32 character: ${char}`);
|
||||
}
|
||||
|
||||
buffer = (buffer << 5) | value;
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
result[index++] = (buffer >> (bitsLeft - 8)) & 0xFF;
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base32 编码
|
||||
* @param data 字节数组
|
||||
* @returns Base32 编码的字符串
|
||||
*/
|
||||
encode(data: Uint8Array): string {
|
||||
if (data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let buffer = 0;
|
||||
let bitsLeft = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
buffer = (buffer << 8) | data[i];
|
||||
bitsLeft += 8;
|
||||
|
||||
while (bitsLeft >= 5) {
|
||||
const index = (buffer >> (bitsLeft - 5)) & 0x1F;
|
||||
result += this.CHARSET.charAt(index);
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (bitsLeft > 0) {
|
||||
const index = (buffer << (5 - bitsLeft)) & 0x1F;
|
||||
result += this.CHARSET.charAt(index);
|
||||
}
|
||||
|
||||
// 添加填充
|
||||
while (result.length % 8 !== 0) {
|
||||
result += '=';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const Base32 = new Base32Class();
|
||||
118
entry/src/main/ets/common/utils/StorageUtil.ets
Normal file
118
entry/src/main/ets/common/utils/StorageUtil.ets
Normal file
@@ -0,0 +1,118 @@
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { AppConstants } from '../constants/AppConstants';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/**
|
||||
* 数据存储工具类
|
||||
* 使用 Preferences 进行持久化存储
|
||||
*/
|
||||
class StorageUtilClass {
|
||||
private dataPreferences: preferences.Preferences | null = null;
|
||||
private static instance: StorageUtilClass | null = null;
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): StorageUtilClass {
|
||||
if (StorageUtilClass.instance === null) {
|
||||
StorageUtilClass.instance = new StorageUtilClass();
|
||||
}
|
||||
return StorageUtilClass.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化存储
|
||||
* @param context 应用上下文
|
||||
*/
|
||||
async init(context: Context): Promise<void> {
|
||||
try {
|
||||
this.dataPreferences = await preferences.getPreferences(context, AppConstants.PREFERENCES_NAME);
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Storage initialized successfully');
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to init storage: %{public}s', JSON.stringify(error));
|
||||
throw new Error(`Failed to init storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存字符串数据
|
||||
* @param key 键
|
||||
* @param value 值
|
||||
*/
|
||||
async putString(key: string, value: string): Promise<void> {
|
||||
if (!this.dataPreferences) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dataPreferences.put(key, value);
|
||||
await this.dataPreferences.flush();
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to save data: %{public}s', JSON.stringify(error));
|
||||
throw new Error(`Failed to save data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符串数据
|
||||
* @param key 键
|
||||
* @param defaultValue 默认值
|
||||
* @returns 数据值
|
||||
*/
|
||||
async getString(key: string, defaultValue: string = ''): Promise<string> {
|
||||
if (!this.dataPreferences) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await this.dataPreferences.get(key, defaultValue);
|
||||
return value as string;
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to get data: %{public}s', JSON.stringify(error));
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据
|
||||
* @param key 键
|
||||
*/
|
||||
async remove(key: string): Promise<void> {
|
||||
if (!this.dataPreferences) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dataPreferences.delete(key);
|
||||
await this.dataPreferences.flush();
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to remove data: %{public}s', JSON.stringify(error));
|
||||
throw new Error(`Failed to remove data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有数据
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
if (!this.dataPreferences) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dataPreferences.clear();
|
||||
await this.dataPreferences.flush();
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to clear data: %{public}s', JSON.stringify(error));
|
||||
throw new Error(`Failed to clear data: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const StorageUtil = StorageUtilClass.getInstance();
|
||||
127
entry/src/main/ets/common/utils/TOTPGenerator.ets
Normal file
127
entry/src/main/ets/common/utils/TOTPGenerator.ets
Normal file
@@ -0,0 +1,127 @@
|
||||
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
|
||||
import { Base32 } from './Base32';
|
||||
import { AppConstants } from '../constants/AppConstants';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) 生成器
|
||||
* 基于 RFC 6238 标准实现
|
||||
*/
|
||||
class TOTPGeneratorClass {
|
||||
/**
|
||||
* 生成 TOTP 验证码
|
||||
* @param secret Base32 编码的密钥
|
||||
* @param timestamp 时间戳(秒),默认为当前时间
|
||||
* @param period 时间步长(秒),默认30秒
|
||||
* @param digits 验证码位数,默认6位
|
||||
* @returns TOTP 验证码字符串
|
||||
*/
|
||||
async generate(
|
||||
secret: string,
|
||||
timestamp?: number,
|
||||
period: number = AppConstants.TOTP_PERIOD,
|
||||
digits: number = AppConstants.TOTP_DIGITS
|
||||
): Promise<string> {
|
||||
// 使用当前时间或指定时间
|
||||
const time = timestamp || Math.floor(Date.now() / 1000);
|
||||
|
||||
// 计算时间步数
|
||||
const timeStep = Math.floor(time / period);
|
||||
|
||||
// 解码 Base32 密钥
|
||||
const keyBytes = Base32.decode(secret);
|
||||
|
||||
// 生成 HMAC
|
||||
const hmac = await this.generateHMAC(keyBytes, timeStep);
|
||||
|
||||
// 动态截断
|
||||
const code = this.truncate(hmac, digits);
|
||||
|
||||
// 填充前导零
|
||||
return code.toString().padStart(digits, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 HMAC-SHA1
|
||||
* @param key 密钥字节数组
|
||||
* @param counter 计数器值
|
||||
* @returns HMAC 字节数组
|
||||
*/
|
||||
private async generateHMAC(key: Uint8Array, counter: number): Promise<Uint8Array> {
|
||||
// 将计数器转换为 8 字节大端序
|
||||
const counterBytes = new Uint8Array(8);
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = counter & 0xFF;
|
||||
counter >>= 8;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建 HMAC 生成器
|
||||
const macAlg = 'SHA1';
|
||||
const mac = cryptoFramework.createMac(macAlg);
|
||||
|
||||
// 创建对称密钥
|
||||
const keyBlob: cryptoFramework.DataBlob = { data: key };
|
||||
const symKeyGenerator = cryptoFramework.createSymKeyGenerator('HMAC');
|
||||
const symKey = await symKeyGenerator.convertKey(keyBlob);
|
||||
|
||||
// 初始化 MAC
|
||||
await mac.init(symKey);
|
||||
|
||||
// 更新数据并生成 MAC
|
||||
const dataBlob: cryptoFramework.DataBlob = { data: counterBytes };
|
||||
await mac.update(dataBlob);
|
||||
const result = await mac.doFinal();
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
console.error(`${AppConstants.LOG_TAG}: HMAC generation failed`, JSON.stringify(error));
|
||||
throw new Error(`HMAC generation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态截断
|
||||
* @param hmac HMAC 字节数组
|
||||
* @param digits 验证码位数
|
||||
* @returns 截断后的数字验证码
|
||||
*/
|
||||
private truncate(hmac: Uint8Array, digits: number): number {
|
||||
// 获取偏移量(最后一个字节的低4位)
|
||||
const offset = hmac[hmac.length - 1] & 0x0F;
|
||||
|
||||
// 提取 4 字节并转换为 31 位整数
|
||||
const binary =
|
||||
((hmac[offset] & 0x7F) << 24) |
|
||||
((hmac[offset + 1] & 0xFF) << 16) |
|
||||
((hmac[offset + 2] & 0xFF) << 8) |
|
||||
(hmac[offset + 3] & 0xFF);
|
||||
|
||||
// 取模得到验证码
|
||||
const divisor = Math.pow(10, digits);
|
||||
return binary % divisor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步的剩余秒数
|
||||
* @param period 时间步长(秒)
|
||||
* @returns 剩余秒数
|
||||
*/
|
||||
getRemainingSeconds(period: number = AppConstants.TOTP_PERIOD): number {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return period - (now % period);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间步的进度(0-1)
|
||||
* @param period 时间步长(秒)
|
||||
* @returns 进度值 (从满到空: 1.0 → 0.0)
|
||||
*/
|
||||
getProgress(period: number = AppConstants.TOTP_PERIOD): number {
|
||||
const remaining = this.getRemainingSeconds(period);
|
||||
return remaining / period;
|
||||
}
|
||||
}
|
||||
|
||||
export const TOTPGenerator = new TOTPGeneratorClass();
|
||||
114
entry/src/main/ets/common/utils/TOTPUriParser.ets
Normal file
114
entry/src/main/ets/common/utils/TOTPUriParser.ets
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* TOTP URI 解析工具
|
||||
* 解析标准 TOTP URI 格式: otpauth://totp/Label?secret=SECRET&issuer=ISSUER
|
||||
*/
|
||||
|
||||
export interface TOTPUriData {
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret: string;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
period?: number;
|
||||
}
|
||||
|
||||
class TOTPUriParserClass {
|
||||
/**
|
||||
* 解析 TOTP URI
|
||||
* @param uri TOTP URI 字符串
|
||||
* @returns 解析后的数据对象,解析失败返回 null
|
||||
*/
|
||||
parse(uri: string): TOTPUriData | null {
|
||||
try {
|
||||
// 检查URI格式
|
||||
if (!uri.startsWith('otpauth://totp/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 移除协议前缀
|
||||
const uriWithoutProtocol = uri.substring('otpauth://totp/'.length);
|
||||
|
||||
// 分割标签和参数
|
||||
const parts = uriWithoutProtocol.split('?');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析标签(Label)
|
||||
const label = decodeURIComponent(parts[0]);
|
||||
let issuer = '';
|
||||
let accountName = '';
|
||||
|
||||
// 标签格式: "Issuer:Account" 或 "Account"
|
||||
if (label.includes(':')) {
|
||||
const labelParts = label.split(':');
|
||||
issuer = labelParts[0].trim();
|
||||
accountName = labelParts.slice(1).join(':').trim();
|
||||
} else {
|
||||
accountName = label.trim();
|
||||
}
|
||||
|
||||
// 解析查询参数
|
||||
const params = this.parseQueryParams(parts[1]);
|
||||
|
||||
// 获取必需参数
|
||||
const secret = params['secret'];
|
||||
if (!secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取可选参数
|
||||
if (params['issuer'] && !issuer) {
|
||||
issuer = params['issuer'];
|
||||
}
|
||||
|
||||
const algorithm = params['algorithm'] || 'SHA1';
|
||||
const digits = params['digits'] ? parseInt(params['digits']) : 6;
|
||||
const period = params['period'] ? parseInt(params['period']) : 30;
|
||||
|
||||
return {
|
||||
issuer,
|
||||
accountName,
|
||||
secret,
|
||||
algorithm,
|
||||
digits,
|
||||
period
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to parse TOTP URI:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析查询参数
|
||||
* @param queryString 查询字符串
|
||||
* @returns 参数对象
|
||||
*/
|
||||
private parseQueryParams(queryString: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const pairs = queryString.split('&');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const parts = pair.split('=');
|
||||
const key = parts[0];
|
||||
const value = parts[1];
|
||||
if (key && value) {
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 URI 格式
|
||||
* @param uri URI 字符串
|
||||
* @returns 是否为有效的 TOTP URI
|
||||
*/
|
||||
isValidTOTPUri(uri: string): boolean {
|
||||
return uri.startsWith('otpauth://totp/') && uri.includes('secret=');
|
||||
}
|
||||
}
|
||||
|
||||
export const TOTPUriParser = new TOTPUriParserClass();
|
||||
48
entry/src/main/ets/entryability/EntryAbility.ets
Normal file
48
entry/src/main/ets/entryability/EntryAbility.ets
Normal file
@@ -0,0 +1,48 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
// Main window is created, set main page for this ability
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||
|
||||
windowStage.loadContent('pages/AccountListPage', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
});
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
// Main window is destroyed, release UI related resources
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
// Ability has brought to foreground
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
// Ability has back to background
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
}
|
||||
}
|
||||
16
entry/src/main/ets/entrybackupability/EntryBackupAbility.ets
Normal file
16
entry/src/main/ets/entrybackupability/EntryBackupAbility.ets
Normal file
@@ -0,0 +1,16 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryBackupAbility extends BackupExtensionAbility {
|
||||
async onBackup() {
|
||||
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onRestore(bundleVersion: BundleVersion) {
|
||||
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
119
entry/src/main/ets/model/Account.ets
Normal file
119
entry/src/main/ets/model/Account.ets
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 账户 JSON 接口
|
||||
*/
|
||||
export interface AccountJson {
|
||||
id: string;
|
||||
issuer: string;
|
||||
accountName: string;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
icon?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户数据模型
|
||||
*/
|
||||
export class Account {
|
||||
id: string; // 唯一标识
|
||||
issuer: string; // 发行者(如 Google, GitHub)
|
||||
accountName: string; // 账户名称(如邮箱或用户名)
|
||||
secret: string; // Base32 编码的密钥
|
||||
algorithm: string; // 算法类型: SHA1, SHA256, SHA512
|
||||
digits: number; // 验证码位数: 6 或 8
|
||||
period: number; // 时间步长(秒): 通常为 30
|
||||
icon?: string; // 图标(可选)
|
||||
createdAt: number; // 创建时间戳
|
||||
updatedAt: number; // 更新时间戳
|
||||
|
||||
constructor(
|
||||
issuer: string,
|
||||
accountName: string,
|
||||
secret: string,
|
||||
algorithm: string = 'SHA1',
|
||||
digits: number = 6,
|
||||
period: number = 30
|
||||
) {
|
||||
this.id = this.generateId();
|
||||
this.issuer = issuer;
|
||||
this.accountName = accountName;
|
||||
this.secret = secret;
|
||||
this.algorithm = algorithm;
|
||||
this.digits = digits;
|
||||
this.period = period;
|
||||
this.createdAt = Date.now();
|
||||
this.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
if (this.issuer && this.accountName) {
|
||||
return `${this.issuer} (${this.accountName})`;
|
||||
}
|
||||
return this.issuer || this.accountName || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 对象创建 Account 实例
|
||||
*/
|
||||
static fromJson(json: AccountJson): Account {
|
||||
const account = new Account(
|
||||
json.issuer,
|
||||
json.accountName,
|
||||
json.secret,
|
||||
json.algorithm,
|
||||
json.digits,
|
||||
json.period
|
||||
);
|
||||
account.id = json.id;
|
||||
if (json.icon !== undefined) {
|
||||
account.icon = json.icon;
|
||||
}
|
||||
account.createdAt = json.createdAt;
|
||||
account.updatedAt = json.updatedAt;
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 JSON 对象
|
||||
*/
|
||||
toJson(): AccountJson {
|
||||
const json: AccountJson = {
|
||||
id: this.id,
|
||||
issuer: this.issuer,
|
||||
accountName: this.accountName,
|
||||
secret: this.secret,
|
||||
algorithm: this.algorithm,
|
||||
digits: this.digits,
|
||||
period: this.period,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
};
|
||||
if (this.icon !== undefined) {
|
||||
json.icon = this.icon;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
update(issuer?: string, accountName?: string, secret?: string): void {
|
||||
if (issuer !== undefined) this.issuer = issuer;
|
||||
if (accountName !== undefined) this.accountName = accountName;
|
||||
if (secret !== undefined) this.secret = secret;
|
||||
this.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
248
entry/src/main/ets/pages/AccountDetailPage.ets
Normal file
248
entry/src/main/ets/pages/AccountDetailPage.ets
Normal file
@@ -0,0 +1,248 @@
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { AccountViewModel } from '../viewmodel/AccountViewModel';
|
||||
import { Account } from '../model/Account';
|
||||
import { StorageUtil } from '../common/utils/StorageUtil';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
|
||||
/**
|
||||
* 账户详情/编辑页面
|
||||
*/
|
||||
@Entry
|
||||
@Component
|
||||
struct AccountDetailPage {
|
||||
@State issuer: string = '';
|
||||
@State accountName: string = '';
|
||||
@State secretKey: string = '';
|
||||
@State issuerError: string = '';
|
||||
@State accountNameError: string = '';
|
||||
@State secretKeyError: string = '';
|
||||
@State showSecretKey: boolean = false;
|
||||
private viewModel: AccountViewModel = new AccountViewModel();
|
||||
private accountId: string = '';
|
||||
private account?: Account;
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
// 初始化存储
|
||||
await StorageUtil.init(getContext(this));
|
||||
// 加载账户数据
|
||||
await this.viewModel.loadAccounts();
|
||||
|
||||
// 获取路由参数
|
||||
const params = router.getParams() as Record<string, string>;
|
||||
this.accountId = params['accountId'] || '';
|
||||
|
||||
// 加载账户信息
|
||||
if (this.accountId) {
|
||||
this.account = this.viewModel.getAccountById(this.accountId);
|
||||
if (this.account) {
|
||||
this.issuer = this.account.issuer;
|
||||
this.accountName = this.account.accountName;
|
||||
this.secretKey = this.account.secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入
|
||||
*/
|
||||
private validate(): boolean {
|
||||
let isValid = true;
|
||||
|
||||
// 验证发行者
|
||||
if (!this.issuer.trim()) {
|
||||
this.issuerError = '此字段不能为空';
|
||||
isValid = false;
|
||||
} else {
|
||||
this.issuerError = '';
|
||||
}
|
||||
|
||||
// 验证账户名
|
||||
if (!this.accountName.trim()) {
|
||||
this.accountNameError = '此字段不能为空';
|
||||
isValid = false;
|
||||
} else {
|
||||
this.accountNameError = '';
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存更改
|
||||
*/
|
||||
private async saveChanges(): Promise<void> {
|
||||
if (!this.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.viewModel.updateAccount(
|
||||
this.accountId,
|
||||
this.issuer.trim(),
|
||||
this.accountName.trim()
|
||||
);
|
||||
|
||||
promptAction.showToast({
|
||||
message: '保存成功',
|
||||
duration: 1500
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
router.back();
|
||||
} catch (error) {
|
||||
promptAction.showToast({
|
||||
message: '保存失败',
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 导航栏
|
||||
Row() {
|
||||
Button({ type: ButtonType.Normal }) {
|
||||
Text($r('app.string.cancel'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.primary_color'))
|
||||
}
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
|
||||
Blank()
|
||||
|
||||
Text($r('app.string.edit_account'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
|
||||
Blank()
|
||||
|
||||
Button({ type: ButtonType.Normal }) {
|
||||
Text($r('app.string.save'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.primary_color'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.saveChanges();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding($r('app.float.spacing_large'))
|
||||
|
||||
// 表单内容
|
||||
Column({ space: 24 }) {
|
||||
// 发行者输入
|
||||
Column({ space: 8 }) {
|
||||
Text($r('app.string.issuer'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
TextInput({ text: this.issuer })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.onChange((value: string) => {
|
||||
this.issuer = value;
|
||||
this.issuerError = '';
|
||||
})
|
||||
|
||||
if (this.issuerError) {
|
||||
Text(this.issuerError)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.danger_color'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// 账户名输入
|
||||
Column({ space: 8 }) {
|
||||
Text($r('app.string.account_name'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
TextInput({ text: this.accountName })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.onChange((value: string) => {
|
||||
this.accountName = value;
|
||||
this.accountNameError = '';
|
||||
})
|
||||
|
||||
if (this.accountNameError) {
|
||||
Text(this.accountNameError)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.danger_color'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// 密钥显示(只读)
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text($r('app.string.secret_key'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
|
||||
Blank()
|
||||
|
||||
Button({ type: ButtonType.Normal }) {
|
||||
Text(this.showSecretKey ? '隐藏' : '显示')
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.primary_color'))
|
||||
}
|
||||
.backgroundColor(Color.Transparent)
|
||||
.padding(0)
|
||||
.onClick(() => {
|
||||
this.showSecretKey = !this.showSecretKey;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
TextInput({
|
||||
text: this.showSecretKey ? this.secretKey : '••••••••••••••••'
|
||||
})
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.enabled(false)
|
||||
.fontFamily('monospace')
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
|
||||
Text('出于安全考虑,密钥不支持修改')
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// 创建时间
|
||||
if (this.account) {
|
||||
Column({ space: 8 }) {
|
||||
Text('创建时间')
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
Text(new Date(this.account.createdAt).toLocaleString('zh-CN'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding($r('app.float.spacing_large'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background_color'))
|
||||
}
|
||||
}
|
||||
262
entry/src/main/ets/pages/AccountListPage.ets
Normal file
262
entry/src/main/ets/pages/AccountListPage.ets
Normal file
@@ -0,0 +1,262 @@
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { AccountViewModel } from '../viewmodel/AccountViewModel';
|
||||
import { Account } from '../model/Account';
|
||||
import { AccountCard } from '../view/components/AccountCard';
|
||||
import { TOTPGenerator } from '../common/utils/TOTPGenerator';
|
||||
import { StorageUtil } from '../common/utils/StorageUtil';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { AppConstants } from '../common/constants/AppConstants';
|
||||
import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
|
||||
/**
|
||||
* 账户列表主页面
|
||||
*/
|
||||
@Entry
|
||||
@Component
|
||||
struct AccountListPage {
|
||||
@State accounts: Account[] = [];
|
||||
@State codes: Map<string, string> = new Map();
|
||||
@State progress: number = 0;
|
||||
@State remainingSeconds: number = 30;
|
||||
@State searchText: string = '';
|
||||
private viewModel: AccountViewModel = new AccountViewModel();
|
||||
private timer: number = -1;
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
// 初始化存储
|
||||
try {
|
||||
await StorageUtil.init(getContext(this));
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Storage initialized');
|
||||
} catch (error) {
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to init storage: %{public}s', JSON.stringify(error));
|
||||
}
|
||||
|
||||
// 设置更新回调
|
||||
this.viewModel.setUpdateCallback(() => {
|
||||
this.updateAccounts();
|
||||
});
|
||||
|
||||
// 加载账户
|
||||
await this.viewModel.loadAccounts();
|
||||
this.updateAccounts();
|
||||
|
||||
// 启动定时器更新验证码
|
||||
this.startTimer();
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
this.stopTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面显示时重新加载账户
|
||||
* 处理从添加/编辑页面返回的情况
|
||||
*/
|
||||
onPageShow(): void {
|
||||
// 重新加载账户数据
|
||||
this.viewModel.loadAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户列表
|
||||
*/
|
||||
private updateAccounts(): void {
|
||||
if (this.searchText) {
|
||||
this.accounts = this.viewModel.searchAccounts(this.searchText);
|
||||
} else {
|
||||
this.accounts = this.viewModel.getAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时器
|
||||
*/
|
||||
private startTimer(): void {
|
||||
// 立即生成一次验证码
|
||||
this.generateAllCodes();
|
||||
|
||||
// 每秒更新一次
|
||||
this.timer = setInterval(() => {
|
||||
this.updateProgress();
|
||||
this.generateAllCodes();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时器
|
||||
*/
|
||||
private stopTimer(): void {
|
||||
if (this.timer !== -1) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新进度
|
||||
*/
|
||||
private updateProgress(): void {
|
||||
this.progress = TOTPGenerator.getProgress();
|
||||
this.remainingSeconds = TOTPGenerator.getRemainingSeconds();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有账户的验证码
|
||||
*/
|
||||
private async generateAllCodes(): Promise<void> {
|
||||
for (const account of this.accounts) {
|
||||
try {
|
||||
const code = await this.viewModel.generateCode(account);
|
||||
this.codes.set(account.id, code);
|
||||
} catch (error) {
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to generate code: %{public}s', JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制验证码到剪贴板
|
||||
*/
|
||||
private copyToClipboard(account: Account): void {
|
||||
const code = this.codes.get(account.id) || '------';
|
||||
if (code !== '------') {
|
||||
const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, code);
|
||||
const systemPasteboard = pasteboard.getSystemPasteboard();
|
||||
systemPasteboard.setData(pasteData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
private async deleteAccount(account: Account): Promise<void> {
|
||||
promptAction.showDialog({
|
||||
title: $r('app.string.delete_confirm'),
|
||||
buttons: [
|
||||
{ text: $r('app.string.cancel'), color: $r('app.color.text_secondary') },
|
||||
{ text: $r('app.string.delete'), color: $r('app.color.danger_color') }
|
||||
]
|
||||
}).then(async (result) => {
|
||||
if (result.index === 1) {
|
||||
await this.viewModel.deleteAccount(account.id);
|
||||
this.updateAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑账户
|
||||
*/
|
||||
private editAccount(account: Account): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/AccountDetailPage',
|
||||
params: { accountId: account.id }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户
|
||||
*/
|
||||
private addAccount(): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/AddAccountPage'
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 顶部导航栏
|
||||
Row() {
|
||||
Text($r('app.string.app_name'))
|
||||
.fontSize($r('app.float.title_large'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
|
||||
Blank()
|
||||
|
||||
// 添加按钮
|
||||
Button({ type: ButtonType.Circle }) {
|
||||
Text('+')
|
||||
.fontSize(28)
|
||||
.fontColor($r('app.color.card_background'))
|
||||
}
|
||||
.width(40)
|
||||
.height(40)
|
||||
.backgroundColor($r('app.color.primary_color'))
|
||||
.onClick(() => {
|
||||
this.addAccount();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({
|
||||
left: $r('app.float.spacing_large'),
|
||||
right: $r('app.float.spacing_large'),
|
||||
top: $r('app.float.spacing_large'),
|
||||
bottom: $r('app.float.spacing_medium')
|
||||
})
|
||||
|
||||
// 搜索框
|
||||
if (this.accounts.length > 0) {
|
||||
Search({ placeholder: $r('app.string.search') })
|
||||
.margin({
|
||||
left: $r('app.float.spacing_large'),
|
||||
right: $r('app.float.spacing_large'),
|
||||
bottom: $r('app.float.spacing_medium')
|
||||
})
|
||||
.onChange((value: string) => {
|
||||
this.searchText = value;
|
||||
this.updateAccounts();
|
||||
})
|
||||
}
|
||||
|
||||
// 账户列表
|
||||
if (this.accounts.length === 0) {
|
||||
// 空状态
|
||||
Column({ space: 12 }) {
|
||||
Text($r('app.string.no_accounts'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text($r('app.string.no_accounts_tip'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
// 账户列表
|
||||
List({ space: 12 }) {
|
||||
ForEach(this.accounts, (account: Account) => {
|
||||
ListItem() {
|
||||
AccountCard({
|
||||
account: account,
|
||||
code: this.codes.get(account.id) || '------',
|
||||
progress: this.progress,
|
||||
remainingSeconds: this.remainingSeconds,
|
||||
onCopy: (acc: Account) => {
|
||||
this.copyToClipboard(acc);
|
||||
},
|
||||
onEdit: (acc: Account) => {
|
||||
this.editAccount(acc);
|
||||
},
|
||||
onDelete: (acc: Account) => {
|
||||
this.deleteAccount(acc);
|
||||
}
|
||||
})
|
||||
}
|
||||
}, (account: Account) => account.id)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.padding({
|
||||
left: $r('app.float.spacing_large'),
|
||||
right: $r('app.float.spacing_large')
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background_color'))
|
||||
}
|
||||
}
|
||||
365
entry/src/main/ets/pages/AddAccountPage.ets
Normal file
365
entry/src/main/ets/pages/AddAccountPage.ets
Normal file
@@ -0,0 +1,365 @@
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { AccountViewModel } from '../viewmodel/AccountViewModel';
|
||||
import { Account } from '../model/Account';
|
||||
import { StorageUtil } from '../common/utils/StorageUtil';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
import { scanBarcode, scanCore } from '@kit.ScanKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { TOTPUriParser, TOTPUriData } from '../common/utils/TOTPUriParser';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { AppConstants } from '../common/constants/AppConstants';
|
||||
|
||||
/**
|
||||
* 添加账户页面
|
||||
*/
|
||||
@Entry
|
||||
@Component
|
||||
struct AddAccountPage {
|
||||
@State currentTab: number = 0; // 0: 扫码, 1: 手动输入 (默认显示扫码)
|
||||
@State issuer: string = '';
|
||||
@State accountName: string = '';
|
||||
@State secretKey: string = '';
|
||||
@State issuerError: string = '';
|
||||
@State accountNameError: string = '';
|
||||
@State secretKeyError: string = '';
|
||||
private viewModel: AccountViewModel = new AccountViewModel();
|
||||
|
||||
async aboutToAppear(): Promise<void> {
|
||||
// 初始化存储
|
||||
await StorageUtil.init(getContext(this));
|
||||
// 加载账户数据
|
||||
await this.viewModel.loadAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证输入
|
||||
*/
|
||||
private validate(): boolean {
|
||||
let isValid = true;
|
||||
|
||||
// 验证发行者
|
||||
if (!this.issuer.trim()) {
|
||||
this.issuerError = '此字段不能为空';
|
||||
isValid = false;
|
||||
} else {
|
||||
this.issuerError = '';
|
||||
}
|
||||
|
||||
// 验证账户名
|
||||
if (!this.accountName.trim()) {
|
||||
this.accountNameError = '此字段不能为空';
|
||||
isValid = false;
|
||||
} else {
|
||||
this.accountNameError = '';
|
||||
}
|
||||
|
||||
// 验证密钥
|
||||
if (!this.secretKey.trim()) {
|
||||
this.secretKeyError = '此字段不能为空';
|
||||
isValid = false;
|
||||
} else {
|
||||
// 验证 Base32 格式
|
||||
const base32Regex = /^[A-Z2-7]+=*$/;
|
||||
if (!base32Regex.test(this.secretKey.toUpperCase().replace(/\s/g, ''))) {
|
||||
this.secretKeyError = '密钥格式无效';
|
||||
isValid = false;
|
||||
} else {
|
||||
this.secretKeyError = '';
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始扫码
|
||||
*/
|
||||
private async startScan(): Promise<void> {
|
||||
try {
|
||||
// 配置扫码参数
|
||||
const options: scanBarcode.ScanOptions = {
|
||||
scanTypes: [scanCore.ScanType.QR_CODE], // 只扫描二维码
|
||||
enableMultiMode: false, // 单码模式
|
||||
enableAlbum: true // 支持从相册选择
|
||||
};
|
||||
|
||||
// 调用扫码服务
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Starting QR code scan...');
|
||||
|
||||
const result: scanBarcode.ScanResult = await scanBarcode.startScanForResult(getContext(this), options);
|
||||
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, `Scan result: ${result.originalValue}`);
|
||||
|
||||
// 解析扫码结果
|
||||
this.handleScanResult(result.originalValue);
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, `Scan failed: ${JSON.stringify(error)}`);
|
||||
|
||||
// 如果用户取消扫码,不显示错误提示
|
||||
if (error.code !== 1000500002) { // 1000500002 是用户取消的错误码
|
||||
promptAction.showToast({
|
||||
message: $r('app.string.scan_failed'),
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理扫码结果
|
||||
*/
|
||||
private handleScanResult(scanValue: string): void {
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, `Handling scan result: ${scanValue}`);
|
||||
|
||||
// 验证是否为有效的 TOTP URI
|
||||
if (!TOTPUriParser.isValidTOTPUri(scanValue)) {
|
||||
promptAction.showToast({
|
||||
message: $r('app.string.invalid_qr_format'),
|
||||
duration: 2000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 TOTP URI
|
||||
const uriData: TOTPUriData | null = TOTPUriParser.parse(scanValue);
|
||||
if (!uriData) {
|
||||
promptAction.showToast({
|
||||
message: $r('app.string.invalid_qr_format'),
|
||||
duration: 2000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 填充表单
|
||||
this.issuer = uriData.issuer;
|
||||
this.accountName = uriData.accountName;
|
||||
this.secretKey = uriData.secret;
|
||||
|
||||
// 清除错误提示
|
||||
this.issuerError = '';
|
||||
this.accountNameError = '';
|
||||
this.secretKeyError = '';
|
||||
|
||||
// 切换到手动输入标签页,显示解析结果
|
||||
this.currentTab = 1;
|
||||
|
||||
promptAction.showToast({
|
||||
message: $r('app.string.scan_success'),
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存账户
|
||||
*/
|
||||
private async saveAccount(): Promise<void> {
|
||||
if (!this.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const account = new Account(
|
||||
this.issuer.trim(),
|
||||
this.accountName.trim(),
|
||||
this.secretKey.toUpperCase().replace(/\s/g, '')
|
||||
);
|
||||
|
||||
await this.viewModel.addAccount(account);
|
||||
|
||||
promptAction.showToast({
|
||||
message: '账户添加成功',
|
||||
duration: 1500
|
||||
});
|
||||
|
||||
// 返回上一页
|
||||
router.back();
|
||||
} catch (error) {
|
||||
promptAction.showToast({
|
||||
message: '添加账户失败',
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 导航栏
|
||||
Row() {
|
||||
Button({ type: ButtonType.Normal }) {
|
||||
Text($r('app.string.cancel'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.primary_color'))
|
||||
}
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
|
||||
Blank()
|
||||
|
||||
Text($r('app.string.add_account'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
|
||||
Blank()
|
||||
|
||||
Button({ type: ButtonType.Normal }) {
|
||||
Text($r('app.string.save'))
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.primary_color'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
this.saveAccount();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding($r('app.float.spacing_large'))
|
||||
|
||||
// Tab 切换
|
||||
Tabs({ index: this.currentTab }) {
|
||||
// 扫码标签
|
||||
TabContent() {
|
||||
Column({ space: 32 }) {
|
||||
// 扫码图标
|
||||
Column({ space: 12 }) {
|
||||
Text('📱')
|
||||
.fontSize(80)
|
||||
|
||||
Text($r('app.string.scan_tip'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.margin({ top: 100 })
|
||||
|
||||
// 扫码按钮
|
||||
Button($r('app.string.scan_button'))
|
||||
.width('80%')
|
||||
.height(48)
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.backgroundColor($r('app.color.primary_color'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.onClick(() => {
|
||||
this.startScan();
|
||||
})
|
||||
|
||||
// 提示文字
|
||||
Text('或切换到"手动输入"标签手动添加账户')
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ top: 24 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.padding($r('app.float.spacing_large'))
|
||||
}
|
||||
.tabBar($r('app.string.scan_qr_code'))
|
||||
|
||||
// 手动输入标签
|
||||
TabContent() {
|
||||
Column({ space: 24 }) {
|
||||
// 发行者输入
|
||||
Column({ space: 8 }) {
|
||||
Text($r('app.string.issuer'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: $r('app.string.input_issuer_hint') })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.onChange((value: string) => {
|
||||
this.issuer = value;
|
||||
this.issuerError = '';
|
||||
})
|
||||
|
||||
if (this.issuerError) {
|
||||
Text(this.issuerError)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.danger_color'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// 账户名输入
|
||||
Column({ space: 8 }) {
|
||||
Text($r('app.string.account_name'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: $r('app.string.input_account_hint') })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.onChange((value: string) => {
|
||||
this.accountName = value;
|
||||
this.accountNameError = '';
|
||||
})
|
||||
|
||||
if (this.accountNameError) {
|
||||
Text(this.accountNameError)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.danger_color'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// 密钥输入
|
||||
Column({ space: 8 }) {
|
||||
Text($r('app.string.secret_key'))
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
TextInput({ placeholder: $r('app.string.input_secret_hint') })
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius_small'))
|
||||
.type(InputType.Normal)
|
||||
.fontFamily('monospace')
|
||||
.onChange((value: string) => {
|
||||
this.secretKey = value;
|
||||
this.secretKeyError = '';
|
||||
})
|
||||
|
||||
if (this.secretKeyError) {
|
||||
Text(this.secretKeyError)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.danger_color'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// 提示信息
|
||||
Text('密钥通常由服务提供商以 Base32 格式提供,如: JBSWY3DPEHPK3PXP')
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding($r('app.float.spacing_large'))
|
||||
}
|
||||
.tabBar($r('app.string.manual_entry'))
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.barMode(BarMode.Fixed)
|
||||
.onChange((index: number) => {
|
||||
this.currentTab = index;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.background_color'))
|
||||
}
|
||||
}
|
||||
23
entry/src/main/ets/pages/Index.ets
Normal file
23
entry/src/main/ets/pages/Index.ets
Normal file
@@ -0,0 +1,23 @@
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@State message: string = 'Hello World';
|
||||
|
||||
build() {
|
||||
RelativeContainer() {
|
||||
Text(this.message)
|
||||
.id('HelloWorld')
|
||||
.fontSize($r('app.float.page_text_font_size'))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.alignRules({
|
||||
center: { anchor: '__container__', align: VerticalAlign.Center },
|
||||
middle: { anchor: '__container__', align: HorizontalAlign.Center }
|
||||
})
|
||||
.onClick(() => {
|
||||
this.message = 'Welcome';
|
||||
})
|
||||
}
|
||||
.height('100%')
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
150
entry/src/main/ets/view/components/AccountCard.ets
Normal file
150
entry/src/main/ets/view/components/AccountCard.ets
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Account } from '../../model/Account';
|
||||
import { ProgressRing } from './ProgressRing';
|
||||
import { promptAction } from '@kit.ArkUI';
|
||||
|
||||
/**
|
||||
* 账户卡片组件
|
||||
* 显示账户信息和验证码
|
||||
*/
|
||||
@Component
|
||||
export struct AccountCard {
|
||||
@Prop account: Account = new Account('', '', '');
|
||||
@Prop code: string = '------';
|
||||
@Prop progress: number = 0;
|
||||
@Prop remainingSeconds: number = 30;
|
||||
onCopy?: (account: Account) => void;
|
||||
onEdit?: (account: Account) => void;
|
||||
onDelete?: (account: Account) => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Row() {
|
||||
// 左侧内容区
|
||||
Column({ space: 8 }) {
|
||||
// 账户信息
|
||||
Row({ space: 4 }) {
|
||||
if (this.account.issuer) {
|
||||
Text(this.account.issuer)
|
||||
.fontSize($r('app.float.body_large'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Medium)
|
||||
}
|
||||
if (this.account.issuer && this.account.accountName) {
|
||||
Text('·')
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
if (this.account.accountName) {
|
||||
Text(this.account.accountName)
|
||||
.fontSize($r('app.float.body_medium'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
// 验证码
|
||||
Text(this.formatCode(this.code))
|
||||
.fontSize($r('app.float.code_size'))
|
||||
.fontColor(this.remainingSeconds <= 5 ? $r('app.color.warning_color') : $r('app.color.text_primary'))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontFamily('monospace')
|
||||
.letterSpacing(4)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
|
||||
Blank()
|
||||
|
||||
// 右侧进度环
|
||||
Column({ space: 8 }) {
|
||||
ProgressRing({
|
||||
progress: this.progress,
|
||||
ringSize: 44,
|
||||
strokeWidth: 6
|
||||
})
|
||||
|
||||
Text(`${this.remainingSeconds}s`)
|
||||
.fontSize($r('app.float.body_small'))
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding($r('app.float.spacing_large'))
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_background'))
|
||||
.borderRadius($r('app.float.border_radius'))
|
||||
.shadow({
|
||||
radius: 8,
|
||||
color: '#08000000',
|
||||
offsetX: 0,
|
||||
offsetY: 2
|
||||
})
|
||||
.onClick(() => {
|
||||
// 点击复制验证码
|
||||
this.copyCode();
|
||||
})
|
||||
.gesture(
|
||||
// 长按显示操作菜单
|
||||
LongPressGesture()
|
||||
.onAction(() => {
|
||||
this.showActionSheet();
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化验证码,添加空格分隔
|
||||
*/
|
||||
private formatCode(code: string): string {
|
||||
if (code.length === 6) {
|
||||
return `${code.substring(0, 3)} ${code.substring(3)}`;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制验证码
|
||||
*/
|
||||
private copyCode(): void {
|
||||
if (this.onCopy) {
|
||||
this.onCopy(this.account);
|
||||
}
|
||||
promptAction.showToast({
|
||||
message: $r('app.string.copied'),
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示操作菜单
|
||||
*/
|
||||
private showActionSheet(): void {
|
||||
// 使用 AlertDialog 显示操作菜单
|
||||
AlertDialog.show({
|
||||
title: this.account.getDisplayName(),
|
||||
message: '',
|
||||
autoCancel: true,
|
||||
alignment: DialogAlignment.Bottom,
|
||||
gridCount: 4,
|
||||
primaryButton: {
|
||||
value: '复制',
|
||||
action: () => {
|
||||
this.copyCode();
|
||||
}
|
||||
},
|
||||
secondaryButton: {
|
||||
value: '编辑',
|
||||
action: () => {
|
||||
if (this.onEdit) {
|
||||
this.onEdit(this.account);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
36
entry/src/main/ets/view/components/ProgressRing.ets
Normal file
36
entry/src/main/ets/view/components/ProgressRing.ets
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 圆形进度指示器组件
|
||||
* 用于显示 TOTP 倒计时进度
|
||||
*/
|
||||
@Component
|
||||
export struct ProgressRing {
|
||||
@Prop progress: number = 0; // 进度值 0-1
|
||||
@Prop ringSize: number = 40; // 尺寸
|
||||
@Prop strokeWidth: number = 3; // 线宽
|
||||
@Prop ringColor: ResourceColor = $r('app.color.primary_color'); // 颜色
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
// 背景圆环
|
||||
Circle()
|
||||
.width(this.ringSize)
|
||||
.height(this.ringSize)
|
||||
.fillOpacity(0)
|
||||
.stroke($r('app.color.separator_color'))
|
||||
.strokeWidth(this.strokeWidth)
|
||||
|
||||
// 进度圆环
|
||||
Progress({
|
||||
value: this.progress * 100,
|
||||
total: 100,
|
||||
type: ProgressType.Ring
|
||||
})
|
||||
.width(this.ringSize)
|
||||
.height(this.ringSize)
|
||||
.color(this.ringColor)
|
||||
.style({
|
||||
strokeWidth: this.strokeWidth
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
143
entry/src/main/ets/viewmodel/AccountViewModel.ets
Normal file
143
entry/src/main/ets/viewmodel/AccountViewModel.ets
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Account, AccountJson } from '../model/Account';
|
||||
import { StorageUtil } from '../common/utils/StorageUtil';
|
||||
import { TOTPGenerator } from '../common/utils/TOTPGenerator';
|
||||
import { AppConstants } from '../common/constants/AppConstants';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/**
|
||||
* 账户业务逻辑类
|
||||
*/
|
||||
export class AccountViewModel {
|
||||
private accounts: Account[] = [];
|
||||
private updateCallback?: () => void;
|
||||
|
||||
/**
|
||||
* 设置更新回调
|
||||
*/
|
||||
setUpdateCallback(callback: () => void): void {
|
||||
this.updateCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知数据更新
|
||||
*/
|
||||
private notifyUpdate(): void {
|
||||
if (this.updateCallback) {
|
||||
this.updateCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载所有账户
|
||||
*/
|
||||
async loadAccounts(): Promise<void> {
|
||||
try {
|
||||
const jsonStr = await StorageUtil.getString(AppConstants.KEY_ACCOUNTS, '[]');
|
||||
const jsonArray = JSON.parse(jsonStr) as Array<AccountJson>;
|
||||
this.accounts = jsonArray.map(json => Account.fromJson(json));
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, `Loaded ${this.accounts.length} accounts`);
|
||||
this.notifyUpdate();
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to load accounts: %{public}s', JSON.stringify(error));
|
||||
this.accounts = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存所有账户
|
||||
*/
|
||||
private async saveAccounts(): Promise<void> {
|
||||
try {
|
||||
const jsonArray = this.accounts.map(account => account.toJson());
|
||||
const jsonStr = JSON.stringify(jsonArray);
|
||||
await StorageUtil.putString(AppConstants.KEY_ACCOUNTS, jsonStr);
|
||||
hilog.info(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Accounts saved successfully');
|
||||
} catch (err) {
|
||||
const error = err as BusinessError;
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to save accounts: %{public}s', JSON.stringify(error));
|
||||
throw new Error(`Failed to save accounts: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加账户
|
||||
*/
|
||||
async addAccount(account: Account): Promise<void> {
|
||||
this.accounts.push(account);
|
||||
await this.saveAccounts();
|
||||
this.notifyUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户
|
||||
*/
|
||||
async updateAccount(accountId: string, issuer: string, accountName: string, secret?: string): Promise<void> {
|
||||
const account = this.accounts.find(acc => acc.id === accountId);
|
||||
if (account) {
|
||||
account.update(issuer, accountName, secret);
|
||||
await this.saveAccounts();
|
||||
this.notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账户
|
||||
*/
|
||||
async deleteAccount(accountId: string): Promise<void> {
|
||||
const index = this.accounts.findIndex(acc => acc.id === accountId);
|
||||
if (index !== -1) {
|
||||
this.accounts.splice(index, 1);
|
||||
await this.saveAccounts();
|
||||
this.notifyUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有账户
|
||||
*/
|
||||
getAccounts(): Account[] {
|
||||
return this.accounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取账户
|
||||
*/
|
||||
getAccountById(accountId: string): Account | undefined {
|
||||
return this.accounts.find(acc => acc.id === accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
async generateCode(account: Account): Promise<string> {
|
||||
try {
|
||||
return await TOTPGenerator.generate(account.secret, undefined, account.period, account.digits);
|
||||
} catch (error) {
|
||||
hilog.error(AppConstants.LOG_DOMAIN, AppConstants.LOG_TAG, 'Failed to generate code: %{public}s', JSON.stringify(error));
|
||||
return '------';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索账户
|
||||
*/
|
||||
searchAccounts(keyword: string): Account[] {
|
||||
if (!keyword) {
|
||||
return this.accounts;
|
||||
}
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
return this.accounts.filter(account =>
|
||||
account.issuer.toLowerCase().includes(lowerKeyword) ||
|
||||
account.accountName.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户数量
|
||||
*/
|
||||
getAccountCount(): number {
|
||||
return this.accounts.length;
|
||||
}
|
||||
}
|
||||
62
entry/src/main/module.json5
Normal file
62
entry/src/main/module.json5
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.CAMERA",
|
||||
"reason": "$string:camera_permission_reason",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "$string:EntryAbility_desc",
|
||||
"icon": "$media:icon",
|
||||
"label": "$string:EntryAbility_label",
|
||||
"startWindowIcon": "$media:icon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"ohos.want.action.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"extensionAbilities": [
|
||||
{
|
||||
"name": "EntryBackupAbility",
|
||||
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
|
||||
"type": "backup",
|
||||
"exported": false,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.backup",
|
||||
"resource": "$profile:backup_config"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
48
entry/src/main/resources/base/element/color.json
Normal file
48
entry/src/main/resources/base/element/color.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#F2F2F7"
|
||||
},
|
||||
{
|
||||
"name": "primary_color",
|
||||
"value": "#007AFF"
|
||||
},
|
||||
{
|
||||
"name": "background_color",
|
||||
"value": "#F2F2F7"
|
||||
},
|
||||
{
|
||||
"name": "card_background",
|
||||
"value": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"name": "text_primary",
|
||||
"value": "#000000"
|
||||
},
|
||||
{
|
||||
"name": "text_secondary",
|
||||
"value": "#8E8E93"
|
||||
},
|
||||
{
|
||||
"name": "text_tertiary",
|
||||
"value": "#C7C7CC"
|
||||
},
|
||||
{
|
||||
"name": "separator_color",
|
||||
"value": "#E5E5EA"
|
||||
},
|
||||
{
|
||||
"name": "success_color",
|
||||
"value": "#34C759"
|
||||
},
|
||||
{
|
||||
"name": "danger_color",
|
||||
"value": "#FF3B30"
|
||||
},
|
||||
{
|
||||
"name": "warning_color",
|
||||
"value": "#FF9500"
|
||||
}
|
||||
]
|
||||
}
|
||||
72
entry/src/main/resources/base/element/float.json
Normal file
72
entry/src/main/resources/base/element/float.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"float": [
|
||||
{
|
||||
"name": "page_text_font_size",
|
||||
"value": "50fp"
|
||||
},
|
||||
{
|
||||
"name": "title_large",
|
||||
"value": "32fp"
|
||||
},
|
||||
{
|
||||
"name": "title_medium",
|
||||
"value": "28fp"
|
||||
},
|
||||
{
|
||||
"name": "title_small",
|
||||
"value": "24fp"
|
||||
},
|
||||
{
|
||||
"name": "body_large",
|
||||
"value": "17fp"
|
||||
},
|
||||
{
|
||||
"name": "body_medium",
|
||||
"value": "15fp"
|
||||
},
|
||||
{
|
||||
"name": "body_small",
|
||||
"value": "13fp"
|
||||
},
|
||||
{
|
||||
"name": "code_size",
|
||||
"value": "36fp"
|
||||
},
|
||||
{
|
||||
"name": "border_radius",
|
||||
"value": "16vp"
|
||||
},
|
||||
{
|
||||
"name": "border_radius_small",
|
||||
"value": "12vp"
|
||||
},
|
||||
{
|
||||
"name": "border_radius_medium",
|
||||
"value": "16vp"
|
||||
},
|
||||
{
|
||||
"name": "border_radius_large",
|
||||
"value": "20vp"
|
||||
},
|
||||
{
|
||||
"name": "spacing_xs",
|
||||
"value": "4vp"
|
||||
},
|
||||
{
|
||||
"name": "spacing_small",
|
||||
"value": "8vp"
|
||||
},
|
||||
{
|
||||
"name": "spacing_medium",
|
||||
"value": "12vp"
|
||||
},
|
||||
{
|
||||
"name": "spacing_large",
|
||||
"value": "16vp"
|
||||
},
|
||||
{
|
||||
"name": "spacing_xl",
|
||||
"value": "24vp"
|
||||
}
|
||||
]
|
||||
}
|
||||
148
entry/src/main/resources/base/element/string.json
Normal file
148
entry/src/main/resources/base/element/string.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "module_desc",
|
||||
"value": "MFA Authenticator"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "安全的多因素身份验证器"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "身份验证器"
|
||||
},
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "身份验证器"
|
||||
},
|
||||
{
|
||||
"name": "accounts",
|
||||
"value": "账户"
|
||||
},
|
||||
{
|
||||
"name": "add_account",
|
||||
"value": "添加账户"
|
||||
},
|
||||
{
|
||||
"name": "edit_account",
|
||||
"value": "编辑账户"
|
||||
},
|
||||
{
|
||||
"name": "delete_account",
|
||||
"value": "删除账户"
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"value": "搜索"
|
||||
},
|
||||
{
|
||||
"name": "scan_qr_code",
|
||||
"value": "扫描二维码"
|
||||
},
|
||||
{
|
||||
"name": "manual_entry",
|
||||
"value": "手动输入"
|
||||
},
|
||||
{
|
||||
"name": "issuer",
|
||||
"value": "发行者"
|
||||
},
|
||||
{
|
||||
"name": "account_name",
|
||||
"value": "账户名称"
|
||||
},
|
||||
{
|
||||
"name": "secret_key",
|
||||
"value": "密钥"
|
||||
},
|
||||
{
|
||||
"name": "save",
|
||||
"value": "保存"
|
||||
},
|
||||
{
|
||||
"name": "cancel",
|
||||
"value": "取消"
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"value": "删除"
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"value": "编辑"
|
||||
},
|
||||
{
|
||||
"name": "copy",
|
||||
"value": "复制"
|
||||
},
|
||||
{
|
||||
"name": "copied",
|
||||
"value": "已复制"
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"value": "设置"
|
||||
},
|
||||
{
|
||||
"name": "no_accounts",
|
||||
"value": "暂无账户"
|
||||
},
|
||||
{
|
||||
"name": "no_accounts_tip",
|
||||
"value": "点击右上角添加您的第一个账户"
|
||||
},
|
||||
{
|
||||
"name": "delete_confirm",
|
||||
"value": "确定要删除此账户吗?"
|
||||
},
|
||||
{
|
||||
"name": "confirm",
|
||||
"value": "确定"
|
||||
},
|
||||
{
|
||||
"name": "input_issuer_hint",
|
||||
"value": "如: Google, GitHub"
|
||||
},
|
||||
{
|
||||
"name": "input_account_hint",
|
||||
"value": "如: example@email.com"
|
||||
},
|
||||
{
|
||||
"name": "input_secret_hint",
|
||||
"value": "输入 Base32 格式的密钥"
|
||||
},
|
||||
{
|
||||
"name": "invalid_secret",
|
||||
"value": "密钥格式无效"
|
||||
},
|
||||
{
|
||||
"name": "field_required",
|
||||
"value": "此字段不能为空"
|
||||
},
|
||||
{
|
||||
"name": "scan_button",
|
||||
"value": "开始扫码"
|
||||
},
|
||||
{
|
||||
"name": "scan_success",
|
||||
"value": "扫码成功"
|
||||
},
|
||||
{
|
||||
"name": "scan_failed",
|
||||
"value": "扫码失败"
|
||||
},
|
||||
{
|
||||
"name": "invalid_qr_format",
|
||||
"value": "无效的二维码格式"
|
||||
},
|
||||
{
|
||||
"name": "camera_permission_reason",
|
||||
"value": "用于扫描二维码添加账户"
|
||||
},
|
||||
{
|
||||
"name": "scan_tip",
|
||||
"value": "请将二维码放入扫描框内"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
entry/src/main/resources/base/media/background.png
Normal file
BIN
entry/src/main/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
entry/src/main/resources/base/media/foreground.png
Normal file
BIN
entry/src/main/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
7
entry/src/main/resources/base/media/layered_image.json
Normal file
7
entry/src/main/resources/base/media/layered_image.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
BIN
entry/src/main/resources/base/media/startIcon.png
Normal file
BIN
entry/src/main/resources/base/media/startIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
3
entry/src/main/resources/base/profile/backup_config.json
Normal file
3
entry/src/main/resources/base/profile/backup_config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowToBackupRestore": true
|
||||
}
|
||||
7
entry/src/main/resources/base/profile/main_pages.json
Normal file
7
entry/src/main/resources/base/profile/main_pages.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/AccountListPage",
|
||||
"pages/AddAccountPage",
|
||||
"pages/AccountDetailPage"
|
||||
]
|
||||
}
|
||||
48
entry/src/main/resources/dark/element/color.json
Normal file
48
entry/src/main/resources/dark/element/color.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#000000"
|
||||
},
|
||||
{
|
||||
"name": "primary_color",
|
||||
"value": "#0A84FF"
|
||||
},
|
||||
{
|
||||
"name": "background_color",
|
||||
"value": "#000000"
|
||||
},
|
||||
{
|
||||
"name": "card_background",
|
||||
"value": "#1C1C1E"
|
||||
},
|
||||
{
|
||||
"name": "text_primary",
|
||||
"value": "#FFFFFF"
|
||||
},
|
||||
{
|
||||
"name": "text_secondary",
|
||||
"value": "#8E8E93"
|
||||
},
|
||||
{
|
||||
"name": "text_tertiary",
|
||||
"value": "#48484A"
|
||||
},
|
||||
{
|
||||
"name": "separator_color",
|
||||
"value": "#38383A"
|
||||
},
|
||||
{
|
||||
"name": "success_color",
|
||||
"value": "#32D74B"
|
||||
},
|
||||
{
|
||||
"name": "danger_color",
|
||||
"value": "#FF453A"
|
||||
},
|
||||
{
|
||||
"name": "warning_color",
|
||||
"value": "#FF9F0A"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
entry/src/mock/mock-config.json5
Normal file
2
entry/src/mock/mock-config.json5
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
35
entry/src/ohosTest/ets/test/Ability.test.ets
Normal file
35
entry/src/ohosTest/ets/test/Ability.test.ets
Normal file
@@ -0,0 +1,35 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';
|
||||
|
||||
export default function abilityTest() {
|
||||
describe('ActsAbilityTest', () => {
|
||||
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
|
||||
beforeAll(() => {
|
||||
// Presets an action, which is performed only once before all test cases of the test suite start.
|
||||
// This API supports only one parameter: preset action function.
|
||||
})
|
||||
beforeEach(() => {
|
||||
// Presets an action, which is performed before each unit test case starts.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: preset action function.
|
||||
})
|
||||
afterEach(() => {
|
||||
// Presets a clear action, which is performed after each unit test case ends.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: clear action function.
|
||||
})
|
||||
afterAll(() => {
|
||||
// Presets a clear action, which is performed after all test cases of the test suite end.
|
||||
// This API supports only one parameter: clear action function.
|
||||
})
|
||||
it('assertContain', 0, () => {
|
||||
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
|
||||
hilog.info(0x0000, 'testTag', '%{public}s', 'it begin');
|
||||
let a = 'abc';
|
||||
let b = 'b';
|
||||
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
|
||||
expect(a).assertContain(b);
|
||||
expect(a).assertEqual(a);
|
||||
})
|
||||
})
|
||||
}
|
||||
5
entry/src/ohosTest/ets/test/List.test.ets
Normal file
5
entry/src/ohosTest/ets/test/List.test.ets
Normal file
@@ -0,0 +1,5 @@
|
||||
import abilityTest from './Ability.test';
|
||||
|
||||
export default function testsuite() {
|
||||
abilityTest();
|
||||
}
|
||||
11
entry/src/ohosTest/module.json5
Normal file
11
entry/src/ohosTest/module.json5
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry_test",
|
||||
"type": "feature",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false
|
||||
}
|
||||
}
|
||||
5
entry/src/test/List.test.ets
Normal file
5
entry/src/test/List.test.ets
Normal file
@@ -0,0 +1,5 @@
|
||||
import localUnitTest from './LocalUnit.test';
|
||||
|
||||
export default function testsuite() {
|
||||
localUnitTest();
|
||||
}
|
||||
33
entry/src/test/LocalUnit.test.ets
Normal file
33
entry/src/test/LocalUnit.test.ets
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';
|
||||
|
||||
export default function localUnitTest() {
|
||||
describe('localUnitTest', () => {
|
||||
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
|
||||
beforeAll(() => {
|
||||
// Presets an action, which is performed only once before all test cases of the test suite start.
|
||||
// This API supports only one parameter: preset action function.
|
||||
});
|
||||
beforeEach(() => {
|
||||
// Presets an action, which is performed before each unit test case starts.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: preset action function.
|
||||
});
|
||||
afterEach(() => {
|
||||
// Presets a clear action, which is performed after each unit test case ends.
|
||||
// The number of execution times is the same as the number of test cases defined by **it**.
|
||||
// This API supports only one parameter: clear action function.
|
||||
});
|
||||
afterAll(() => {
|
||||
// Presets a clear action, which is performed after all test cases of the test suite end.
|
||||
// This API supports only one parameter: clear action function.
|
||||
});
|
||||
it('assertContain', 0, () => {
|
||||
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
|
||||
let a = 'abc';
|
||||
let b = 'b';
|
||||
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
|
||||
expect(a).assertContain(b);
|
||||
expect(a).assertEqual(a);
|
||||
});
|
||||
});
|
||||
}
|
||||
23
hvigor/hvigor-config.json5
Normal file
23
hvigor/hvigor-config.json5
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"modelVersion": "6.0.0",
|
||||
"dependencies": {
|
||||
},
|
||||
"execution": {
|
||||
// "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */
|
||||
// "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */
|
||||
// "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */
|
||||
// "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */
|
||||
// "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */
|
||||
// "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */
|
||||
},
|
||||
"logging": {
|
||||
// "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */
|
||||
},
|
||||
"debugging": {
|
||||
// "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */
|
||||
},
|
||||
"nodeOptions": {
|
||||
// "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/
|
||||
// "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/
|
||||
}
|
||||
}
|
||||
6
hvigorfile.ts
Normal file
6
hvigorfile.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
28
oh-package-lock.json5
Normal file
28
oh-package-lock.json5
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0",
|
||||
"@ohos/hypium@1.0.24": "@ohos/hypium@1.0.24"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hamock@1.0.0": {
|
||||
"name": "",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ohos/hypium@1.0.24": {
|
||||
"name": "",
|
||||
"version": "1.0.24",
|
||||
"integrity": "sha512-3dCqc+BAR5LqEGG2Vtzi8O3r7ci/3fYU+FWjwvUobbfko7DUnXGOccaror0yYuUhJfXzFK0aZNMGSnXaTwEnbw==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.24.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
oh-package.json5
Normal file
10
oh-package.json5
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"modelVersion": "6.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.24",
|
||||
"@ohos/hamock": "1.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user