Skip to content

Commit 3767299

Browse files
committed
feat: support otp uri import
1 parent 83f15fc commit 3767299

File tree

9 files changed

+206
-17
lines changed

9 files changed

+206
-17
lines changed

AppScope/app.json5

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"app": {
33
"bundleName": "smartcityshenzhen.yylx.totptoken",
44
"vendor": "example",
5-
"versionCode": 1000003,
6-
"versionName": "1.0.3",
5+
"versionCode": 1000004,
6+
"versionName": "1.0.4",
77
"icon": "$media:app_icon",
88
"label": "$string:app_name"
99
}

entry/src/main/ets/components/TokenItem.ets

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { systemDateTime } from '@kit.BasicServicesKit';
22
import { otpType, TokenConfig } from '../utils/TokenConfig';
3-
import { generateOTP } from '../utils/TokenUtils';
3+
import { copyText, generateOTP } from '../utils/TokenUtils';
44
import { TokenIcon } from './TokenIcon';
5-
import { pasteboard } from '@kit.BasicServicesKit';
6-
import { promptAction } from '@kit.ArkUI';
75
import { SteamUtils } from '../utils/SteamUtils';
86

97
let current_token_hide_map: Map<string, boolean> = new Map();
@@ -107,7 +105,7 @@ export struct TokenItem {
107105
.gesture(
108106
LongPressGesture()
109107
.onAction(() => {
110-
copyText(this.TokenNumber);
108+
copyText(this.TokenNumber, `token ${this.TokenNumber} copied`);
111109
})
112110
)
113111
if (this.Config.TokenType == otpType.HOTP) {
@@ -159,10 +157,3 @@ export struct TokenItem {
159157
.backgroundColor($r("app.color.item_bg"))
160158
}
161159
}
162-
163-
function copyText(text: string) {
164-
const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
165-
const systemPasteboard = pasteboard.getSystemPasteboard();
166-
systemPasteboard.setData(pasteboardData); // 将数据放入剪切板
167-
promptAction.showToast({ message: 'Token copied: ' + text });
168-
}

entry/src/main/ets/dialogs/OTPConfigDialog.ets

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { otpType, TokenConfig } from '../utils/TokenConfig';
22
import { url } from '@kit.ArkTS';
3-
import { getIconPathByIssuer, HMACAlgorithm, ScanBarCode } from '../utils/TokenUtils';
3+
import { HMACAlgorithm, ScanBarCode } from '../utils/TokenUtils';
44
import { TokenIcon } from '../components/TokenIcon';
55

66
@Entry

entry/src/main/ets/dialogs/QRCodeDialog.ets

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { scanCore, generateBarcode } from '@kit.ScanKit';
22
import { BusinessError } from '@kit.BasicServicesKit';
33
import { image } from '@kit.ImageKit';
44
import { hilog } from '@kit.PerformanceAnalysisKit';
5+
import { copyText } from '../utils/TokenUtils';
56

67
@Preview
78
@CustomDialog
@@ -33,10 +34,18 @@ export struct QRCodeDialog {
3334

3435
build() {
3536
Flex({ justifyContent: FlexAlign.Center }) {
36-
if (this.pixelMap) {
37-
Image(this.pixelMap).width(300).height(300).objectFit(ImageFit.Contain)
37+
Column({ space: 10 }){
38+
if (this.pixelMap) {
39+
Image(this.pixelMap).width(300).height(300).objectFit(ImageFit.Contain)
40+
}
41+
Text(this.content)
42+
.fontSize(10)
43+
.fontColor($r('app.color.str_gray'))
44+
.onClick(() => {
45+
copyText(this.content, "uri copied");
46+
})
3847
}
3948
}
4049
.padding(20)
4150
}
42-
}
51+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { ScanBarCode } from "../utils/TokenUtils";
2+
import { url } from "@kit.ArkTS";
3+
import { otpType, TokenConfig } from "../utils/TokenConfig";
4+
import { readFileContent, showSelectFilePicker } from "../utils/FileUtils";
5+
6+
@Entry
7+
@Preview
8+
@CustomDialog
9+
export struct URIConfigDialog {
10+
controller?: CustomDialogController;
11+
@State otp_uris: Array<string> = [];
12+
@State btn_camera_clicked: number = 0;
13+
@State btn_folder_clicked: number = 0;
14+
15+
cancel?: () => void;
16+
confirm: (tokens: Array<TokenConfig>) => void = () => {};
17+
18+
createTokens(uris: Array<string>): Array<TokenConfig> {
19+
let tokens: Array<TokenConfig> = [];
20+
let otp_url: url.URL;
21+
22+
uris.forEach((uri) => {
23+
let token = new TokenConfig();
24+
try {
25+
otp_url = url.URL.parseURL(uri)
26+
} catch (error) {
27+
console.error('Invalid OTPAuth URL:', error);
28+
return;
29+
}
30+
31+
if (otp_url.protocol !== 'otpauth:') {
32+
console.error('Invalid protocol');
33+
return;
34+
}
35+
36+
const type = otp_url.host.toLowerCase();
37+
38+
if (type === 'totp') {
39+
token.TokenType = otpType.TOTP;
40+
} else if (type == 'hotp') {
41+
token.TokenType = otpType.HOTP;
42+
} else {
43+
console.error('Invalid type');
44+
return;
45+
}
46+
47+
const pathParts = otp_url.pathname.slice(1).split('/');
48+
const labelParts = decodeURIComponent(pathParts[0]).split(':');
49+
token.TokenIssuer = labelParts[0];
50+
token.TokenName = labelParts[1];
51+
52+
const parameters: Record<string, string> = {};
53+
const query = otp_url.href.split('?')[1].toLowerCase();
54+
if (query) {
55+
query.split('&').forEach(part => {
56+
const kv: string[] = part.split('=');
57+
parameters[kv[0]] = kv[1];
58+
});
59+
}
60+
token.TokenSecret = parameters['secret'];
61+
token.TokenPeriod = parseInt(parameters['period'] ?? '30');
62+
token.TokenCounter = parseInt(parameters['counter'] ?? '0');
63+
token.TokenDigits = parseInt(parameters['digits'] ?? '6');
64+
65+
tokens.push(token);
66+
});
67+
68+
return tokens;
69+
}
70+
71+
build() {
72+
Column({ space: 10 }) {
73+
Row({ space: 10 }) {
74+
Text($r('app.string.tab_token_add_uri_title'))
75+
.fontSize(20)
76+
.fontWeight(FontWeight.Bold)
77+
Blank()
78+
SymbolGlyph($r('sys.symbol.camera'))
79+
.fontSize(30)
80+
.fontColor([$r('app.color.item_fg')])
81+
.fontWeight(FontWeight.Medium)
82+
.symbolEffect(new BounceSymbolEffect(EffectScope.WHOLE, EffectDirection.UP),
83+
this.btn_camera_clicked)
84+
.onClick(() => {
85+
this.btn_camera_clicked++
86+
ScanBarCode().then((code) => {
87+
this.otp_uris.push(code);
88+
});
89+
})
90+
SymbolGlyph($r('sys.symbol.folder'))
91+
.fontSize(30)
92+
.fontColor([$r('app.color.item_fg')])
93+
.fontWeight(FontWeight.Medium)
94+
.symbolEffect(new BounceSymbolEffect(EffectScope.WHOLE, EffectDirection.UP),
95+
this.btn_folder_clicked)
96+
.onClick(() => {
97+
this.btn_folder_clicked++
98+
showSelectFilePicker(1, ['Text File|.txt,.json']).then((uris) => {
99+
readFileContent(uris[0]).then((str) => {
100+
this.otp_uris = this.otp_uris.concat(str.split('\n'))
101+
})
102+
});
103+
})
104+
}
105+
.margin({ top: 10, left: 10, right: 10 })
106+
.width('100%')
107+
.justifyContent(FlexAlign.SpaceAround)
108+
109+
TextArea({ placeholder: $r('app.string.tab_token_add_uri_desc'), text: this.otp_uris.join('\n') })
110+
.width('100%')
111+
.layoutWeight(1)
112+
.onChange((value: string) => {
113+
this.otp_uris = value.split('\n')
114+
})
115+
Flex({ justifyContent: FlexAlign.SpaceAround }) {
116+
Button($r('app.string.dialog_btn_cancel'))
117+
.fontColor($r('app.color.item_fg'))
118+
.backgroundColor(Color.Transparent)
119+
.onClick(() => {
120+
if (this.controller != undefined) {
121+
this.controller.close()
122+
}
123+
})
124+
.width('100%')
125+
Button($r('app.string.dialog_btn_confirm'))
126+
.fontColor(Color.Red)
127+
.backgroundColor(Color.Transparent)
128+
.onClick(() => {
129+
if (this.controller != undefined) {
130+
this.confirm(this.createTokens(this.otp_uris))
131+
this.controller.close()
132+
}
133+
})
134+
.width('100%')
135+
}
136+
}
137+
.height('50%')
138+
.margin(10)
139+
}
140+
}

entry/src/main/ets/pages/Index.ets

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { EncryptionPassWordDialog } from '../dialogs/EncryptionPassWordDialog';
2424
import { restoreFromBackup } from '../utils/TokenBackup';
2525
import { ScanBarCode } from '../utils/TokenUtils';
2626
import { decodeProtobuf } from '../utils/GoogleAuthUtils';
27+
import { URIConfigDialog } from '../dialogs/URIConfigDialog';
2728

2829
const TAG = 'PrivacySubscribe';
2930

@@ -43,6 +44,7 @@ struct Index {
4344
private appBottomAvoidHeight = AppStorage.get("appBottomAvoidHeight") as number;
4445
private appTopAvoidHeight = AppStorage.get("appTopAvoidHeight") as number;
4546

47+
private dialog_uri_config?: CustomDialogController;
4648
private dialog_totp_config?: CustomDialogController;
4749
private dialog_forti_config?: CustomDialogController;
4850
private dialog_steam_config?: CustomDialogController;
@@ -329,6 +331,18 @@ struct Index {
329331
this.dialog_totp_config.open()
330332
})
331333
.accessibilityText($r('app.string.tab_token_add_key'))
334+
MenuItem({ content: $r('app.string.tab_token_add_uri') })
335+
.onClick(() => {
336+
this.dialog_uri_config = new CustomDialogController({
337+
builder: URIConfigDialog({
338+
confirm: (tokens) => {
339+
this.updateTokenConfigs(tokens);
340+
}
341+
})
342+
})
343+
this.dialog_uri_config.open()
344+
})
345+
.accessibilityText($r('app.string.tab_token_add_uri'))
332346
MenuItem({ content: $r('app.string.tab_token_add_forti_key') })
333347
.onClick(() => {
334348
this.dialog_forti_config = new CustomDialogController({

entry/src/main/ets/utils/TokenUtils.ets

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { buffer, HashSet } from '@kit.ArkTS';
33
import { common } from '@kit.AbilityKit';
44
import { TokenConfig } from './TokenConfig';
55
import { scanBarcode, scanCore } from '@kit.ScanKit';
6+
import { pasteboard } from '@kit.BasicServicesKit';
7+
import { promptAction } from '@kit.ArkUI';
68

79
const RFC4648 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
810
const RFC4648_HEX = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
@@ -200,6 +202,15 @@ export function base32Decode(input: string, variant: Base32Variant = 'RFC4648'):
200202
return output;
201203
}
202204

205+
export function copyText(text: string, msg: string = '') {
206+
const pasteboardData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
207+
const systemPasteboard = pasteboard.getSystemPasteboard();
208+
systemPasteboard.setData(pasteboardData);
209+
if (msg !== undefined && msg !== '') {
210+
promptAction.showToast({ message: msg });
211+
}
212+
}
213+
203214
const iconsArray: Array<string> =
204215
['1and1', '1password', '23andme', 'adafruit', 'adguard', 'adobe', 'airbnb', 'airbrake', 'airtable', 'allegropl',
205216
'alwaysdata', 'amazon', 'amazonwebservices', 'angellist', 'animebytes', 'anonaddy', 'apache', 'apple', 'appveyor',

entry/src/main/resources/base/element/string.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@
275275
{
276276
"name": "setting_safety_hide_token_des",
277277
"value": "Hiding user tokens enhances security."
278+
},
279+
{
280+
"name": "tab_token_add_uri",
281+
"value": "Text URI"
282+
},
283+
{
284+
"name": "tab_token_add_uri_title",
285+
"value": "Input OTP URI"
286+
},
287+
{
288+
"name": "tab_token_add_uri_desc",
289+
"value": "An OTP URI is a text that starts with otpauth:// and supports multiple lines."
278290
}
279291
]
280292
}

entry/src/main/resources/zh_CN/element/string.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@
275275
{
276276
"name": "setting_safety_hide_token_des",
277277
"value": "默认不显示令牌以提高安全性。"
278+
},
279+
{
280+
"name": "tab_token_add_uri",
281+
"value": "文本链接"
282+
},
283+
{
284+
"name": "tab_token_add_uri_title",
285+
"value": "输入OTP URI"
286+
},
287+
{
288+
"name": "tab_token_add_uri_desc",
289+
"value": "OTP URI是以otpauth://开头的文本,支持多行。"
278290
}
279291
]
280292
}

0 commit comments

Comments
 (0)