commit 21c23d9400d4c6b3e0e46e901f796249233ab22f Author: YANG JIANKUAN Date: Sun Oct 19 12:32:16 2025 +0800 init: init proj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2ff201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000..108c07d --- /dev/null +++ b/AppScope/app.json5 @@ -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" + } +} diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..2ae5954 --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "Authenticator" + } + ] +} diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000..923f2b3 Binary files /dev/null and b/AppScope/resources/base/media/background.png differ diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000..eb94275 Binary files /dev/null and b/AppScope/resources/base/media/foreground.png differ diff --git a/AppScope/resources/base/media/icon.png b/AppScope/resources/base/media/icon.png new file mode 100644 index 0000000..f8932bd Binary files /dev/null and b/AppScope/resources/base/media/icon.png differ diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0ee8f84 --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0988f8 --- /dev/null +++ b/README.md @@ -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! diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000..ce218f4 --- /dev/null +++ b/build-profile.json5 @@ -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" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/code-linter.json5 b/code-linter.json5 new file mode 100644 index 0000000..073990f --- /dev/null +++ b/code-linter.json5 @@ -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" + } +} \ No newline at end of file diff --git a/entry/.gitignore b/entry/.gitignore new file mode 100644 index 0000000..e2713a2 --- /dev/null +++ b/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000..6bd6457 --- /dev/null +++ b/entry/build-profile.json5 @@ -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", + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000..b0e3a1a --- /dev/null +++ b/entry/hvigorfile.ts @@ -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. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000..272efb6 --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -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 \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000..677c925 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "authenticator", + "version": "1.0.0", + "description": "MFA/2FA Authenticator App", + "main": "", + "author": "", + "license": "", + "dependencies": {} +} + diff --git a/entry/src/main/ets/common/constants/AppConstants.ets b/entry/src/main/ets/common/constants/AppConstants.ets new file mode 100644 index 0000000..b495821 --- /dev/null +++ b/entry/src/main/ets/common/constants/AppConstants.ets @@ -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; +} diff --git a/entry/src/main/ets/common/utils/Base32.ets b/entry/src/main/ets/common/utils/Base32.ets new file mode 100644 index 0000000..d2aeab2 --- /dev/null +++ b/entry/src/main/ets/common/utils/Base32.ets @@ -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(); diff --git a/entry/src/main/ets/common/utils/StorageUtil.ets b/entry/src/main/ets/common/utils/StorageUtil.ets new file mode 100644 index 0000000..bbe730d --- /dev/null +++ b/entry/src/main/ets/common/utils/StorageUtil.ets @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/entry/src/main/ets/common/utils/TOTPGenerator.ets b/entry/src/main/ets/common/utils/TOTPGenerator.ets new file mode 100644 index 0000000..494f8cb --- /dev/null +++ b/entry/src/main/ets/common/utils/TOTPGenerator.ets @@ -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 { + // 使用当前时间或指定时间 + 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 { + // 将计数器转换为 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(); diff --git a/entry/src/main/ets/common/utils/TOTPUriParser.ets b/entry/src/main/ets/common/utils/TOTPUriParser.ets new file mode 100644 index 0000000..25c8f2e --- /dev/null +++ b/entry/src/main/ets/common/utils/TOTPUriParser.ets @@ -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 { + const params: Record = {}; + 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(); diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..e5099e4 --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -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'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 0000000..8e4de99 --- /dev/null +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -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(); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/model/Account.ets b/entry/src/main/ets/model/Account.ets new file mode 100644 index 0000000..8032e3f --- /dev/null +++ b/entry/src/main/ets/model/Account.ets @@ -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(); + } +} diff --git a/entry/src/main/ets/pages/AccountDetailPage.ets b/entry/src/main/ets/pages/AccountDetailPage.ets new file mode 100644 index 0000000..881d7ad --- /dev/null +++ b/entry/src/main/ets/pages/AccountDetailPage.ets @@ -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 { + // 初始化存储 + await StorageUtil.init(getContext(this)); + // 加载账户数据 + await this.viewModel.loadAccounts(); + + // 获取路由参数 + const params = router.getParams() as Record; + 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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/AccountListPage.ets b/entry/src/main/ets/pages/AccountListPage.ets new file mode 100644 index 0000000..03366df --- /dev/null +++ b/entry/src/main/ets/pages/AccountListPage.ets @@ -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 = 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 { + // 初始化存储 + 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 { + 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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/AddAccountPage.ets b/entry/src/main/ets/pages/AddAccountPage.ets new file mode 100644 index 0000000..77c5661 --- /dev/null +++ b/entry/src/main/ets/pages/AddAccountPage.ets @@ -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 { + // 初始化存储 + 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 { + 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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..8e2d24a --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -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%') + } +} \ No newline at end of file diff --git a/entry/src/main/ets/view/components/AccountCard.ets b/entry/src/main/ets/view/components/AccountCard.ets new file mode 100644 index 0000000..d371dee --- /dev/null +++ b/entry/src/main/ets/view/components/AccountCard.ets @@ -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); + } + } + } + }); + } +} diff --git a/entry/src/main/ets/view/components/ProgressRing.ets b/entry/src/main/ets/view/components/ProgressRing.ets new file mode 100644 index 0000000..5670ae2 --- /dev/null +++ b/entry/src/main/ets/view/components/ProgressRing.ets @@ -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 + }) + } + } +} diff --git a/entry/src/main/ets/viewmodel/AccountViewModel.ets b/entry/src/main/ets/viewmodel/AccountViewModel.ets new file mode 100644 index 0000000..d625996 --- /dev/null +++ b/entry/src/main/ets/viewmodel/AccountViewModel.ets @@ -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 { + try { + const jsonStr = await StorageUtil.getString(AppConstants.KEY_ACCOUNTS, '[]'); + const jsonArray = JSON.parse(jsonStr) as Array; + 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 { + 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 { + this.accounts.push(account); + await this.saveAccounts(); + this.notifyUpdate(); + } + + /** + * 更新账户 + */ + async updateAccount(accountId: string, issuer: string, accountName: string, secret?: string): Promise { + 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 { + 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 { + 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; + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000..739aed5 --- /dev/null +++ b/entry/src/main/module.json5 @@ -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" + } + ], + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..fb32aba --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..b876939 --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -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" + } + ] +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..9326a4c --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -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": "请将二维码放入扫描框内" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/background.png b/entry/src/main/resources/base/media/background.png new file mode 100644 index 0000000..923f2b3 Binary files /dev/null and b/entry/src/main/resources/base/media/background.png differ diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000..97014d3 Binary files /dev/null and b/entry/src/main/resources/base/media/foreground.png differ diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000..205ad8b Binary files /dev/null and b/entry/src/main/resources/base/media/startIcon.png differ diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000..78f40ae --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..aceb884 --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,7 @@ +{ + "src": [ + "pages/AccountListPage", + "pages/AddAccountPage", + "pages/AccountDetailPage" + ] +} diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..1732a2b --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/entry/src/mock/mock-config.json5 b/entry/src/mock/mock-config.json5 new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/entry/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/Ability.test.ets b/entry/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 0000000..85c78f6 --- /dev/null +++ b/entry/src/ohosTest/ets/test/Ability.test.ets @@ -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); + }) + }) +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 0000000..794c7dc --- /dev/null +++ b/entry/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/entry/src/ohosTest/module.json5 b/entry/src/ohosTest/module.json5 new file mode 100644 index 0000000..509a3a2 --- /dev/null +++ b/entry/src/ohosTest/module.json5 @@ -0,0 +1,11 @@ +{ + "module": { + "name": "entry_test", + "type": "feature", + "deviceTypes": [ + "phone" + ], + "deliveryWithInstall": true, + "installationFree": false + } +} diff --git a/entry/src/test/List.test.ets b/entry/src/test/List.test.ets new file mode 100644 index 0000000..bb5b5c3 --- /dev/null +++ b/entry/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/entry/src/test/LocalUnit.test.ets b/entry/src/test/LocalUnit.test.ets new file mode 100644 index 0000000..165fc16 --- /dev/null +++ b/entry/src/test/LocalUnit.test.ets @@ -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); + }); + }); +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..7a7ab89 --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -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*/ + } +} diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..47113e2 --- /dev/null +++ b/hvigorfile.ts @@ -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. */ +} \ No newline at end of file diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..6b264af --- /dev/null +++ b/oh-package-lock.json5 @@ -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" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..c72aa05 --- /dev/null +++ b/oh-package.json5 @@ -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" + } +}