diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 22b649130f..57889ae10f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -611,12 +611,16 @@ export const EN_US_MESSAGES: [string, string][] = [ ['watchProvision.approve', 'Allow'], ['watchProvision.reject', 'Deny'], ['watchProvision.gotIt', 'Got it'], - ['watchProvision.working', 'Requesting authorization from the desktop...'], + ['watchProvision.working', 'Setting up the watch account...'], + ['watchProvision.passwordBody', 'Confirm your account password once more to create a separate sign-in for this watch. The password is used only for this verification.'], + ['watchProvision.passwordPlaceholder', 'Account password'], + ['watchProvision.passwordConfirm', 'Confirm'], ['watchProvision.doneBody', '{0} was added to the account and can be used on the watch now.'], ['watchProvision.rejected', 'Denied on the phone.'], ['watchProvision.busy', 'The phone is handling another device request. Try again later.'], ['watchProvision.errors.noDesktop', 'Scan and connect a desktop on the phone before authorizing a watch.'], ['watchProvision.errors.desktopUnreachable', 'The desktop is offline or too old. Update it and retry.'], + ['watchProvision.errors.passwordFailed', 'Account verification failed. Check the password or network and retry.'], ['watchProvision.errors.handoffFailed', 'Authorization finished, but the credential could not be sent to the watch. Retry on the watch.'], ['ui.emptyMessage', '(empty message)'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 1db9c6fb82..71a9d1df16 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -611,12 +611,16 @@ export const ZH_CN_MESSAGES: [string, string][] = [ ['watchProvision.approve', '允许'], ['watchProvision.reject', '拒绝'], ['watchProvision.gotIt', '知道了'], - ['watchProvision.working', '正在从桌面端申请授权...'], + ['watchProvision.working', '正在为手表开通账号...'], + ['watchProvision.passwordBody', '需要再确认一次账号密码,才能给这块手表创建独立的登录凭证。密码只会用于本次验证。'], + ['watchProvision.passwordPlaceholder', '账号密码'], + ['watchProvision.passwordConfirm', '确认'], ['watchProvision.doneBody', '{0} 已加入账号,手表上可以直接使用了。'], ['watchProvision.rejected', '已在手机上拒绝。'], ['watchProvision.busy', '手机正在处理另一台设备的请求,请稍后再试。'], ['watchProvision.errors.noDesktop', '需要先在手机上扫码连接桌面端,才能给手表授权。'], ['watchProvision.errors.desktopUnreachable', '桌面端未在线或版本过旧,请更新桌面端后重试。'], + ['watchProvision.errors.passwordFailed', '账号验证失败,请检查密码或网络后重试。'], ['watchProvision.errors.handoffFailed', '授权已完成,但没能把凭证发给手表,请在手表上重试一次。'], ['ui.emptyMessage', '(空消息)'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index 1c14aecdf8..638d04fee2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -52,8 +52,8 @@ struct AppRoot { // not be hidden behind whatever screen the phone happens to be on. WatchProvisionCard({ state: this.runtime.watchProvisionState, - onApprove: () => { - void this.runtime.watchProvisionController.approve(); + onApprove: (password: string) => { + void this.runtime.watchProvisionController.approve(password); }, onReject: () => { void this.runtime.watchProvisionController.reject(); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WatchProvisionCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WatchProvisionCard.ets index 78bc8f3b4b..e627539e1f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WatchProvisionCard.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WatchProvisionCard.ets @@ -1,7 +1,8 @@ import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { WatchProvisionPhase, WatchProvisionState } from '../state/WatchProvisionState'; -import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT } from './Theme'; +import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACTION_TEXT, RED, SOFT, + SUBTLE } from './Theme'; /** * The one place a watch can be added to the account. It is deliberately modal: @@ -11,9 +12,10 @@ import { CARD, GREEN, INK, LINE, MODAL_SCRIM, MUTED, PRIMARY_ACTION, PRIMARY_ACT @ComponentV2 export struct WatchProvisionCard { @Param state: WatchProvisionState = new WatchProvisionState(); - @Event onApprove: () => void = () => {}; + @Event onApprove: (password: string) => void = (_password: string) => {}; @Event onReject: () => void = () => {}; @Event onDismiss: () => void = () => {}; + @Local password: string = ''; build() { if (this.state.visible()) { @@ -61,6 +63,36 @@ export struct WatchProvisionCard { .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) .fontColor(MUTED) .width('100%') + } else if (this.state.phase === WatchProvisionPhase.Password) { + Column({ space: 12 }) { + Text(RemoteI18n.t('watchProvision.passwordBody')) + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontColor(MUTED) + .width('100%') + TextInput({ + placeholder: RemoteI18n.t('watchProvision.passwordPlaceholder'), + text: this.password + }) + .height(54) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontColor(INK) + .placeholderColor(SUBTLE) + .backgroundColor(SOFT) + .borderRadius(18) + .padding({ left: 18, right: 18 }) + .type(InputType.Password) + .showPasswordIcon(true) + .onChange((value: string) => { this.password = value; }) + if (this.state.message.length > 0) { + Text(this.state.message) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(RED) + .width('100%') + } + } + .width('100%') } else if (this.state.phase === WatchProvisionPhase.Working) { Row({ space: 10 }) { LoadingProgress() @@ -101,7 +133,38 @@ export struct WatchProvisionCard { .fontColor(PRIMARY_ACTION_TEXT) .backgroundColor(PRIMARY_ACTION) .borderRadius(28) - .onClick(this.onApprove) + .onClick(() => { this.onApprove(''); }) + } + .width('100%') + } else if (this.state.phase === WatchProvisionPhase.Password) { + Row({ space: 12 }) { + Button(RemoteI18n.t('watchProvision.reject')) + .layoutWeight(1) + .height(56) + .fontSize(MobileDesignTypography.labelLarge.size) + .fontWeight(FontWeight.Bold) + .fontColor(INK) + .backgroundColor(SOFT) + .borderRadius(28) + .onClick(() => { + this.password = ''; + this.onReject(); + }) + Button(RemoteI18n.t('watchProvision.passwordConfirm')) + .layoutWeight(1) + .height(56) + .fontSize(MobileDesignTypography.labelLarge.size) + .fontWeight(FontWeight.Bold) + .fontColor(PRIMARY_ACTION_TEXT) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(28) + .enabled(this.password.length > 0) + .opacity(this.password.length > 0 ? 1 : 0.45) + .onClick(() => { + const password = this.password; + this.password = ''; + this.onApprove(password); + }) } .width('100%') } else if (this.state.phase !== WatchProvisionPhase.Working) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 03b3f6b5a1..3e7b3301f5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -180,8 +180,8 @@ export abstract class AppRootRuntimeComposition { // account of its own to mint from. canProvision: (): boolean => this.settingsController.canMintWatchCredential() || this.canProvisionViaDesktop(), - provision: (deviceId: string, deviceName: string, requestId: string): Promise => - this.provisionWatchDevice(deviceId, deviceName, requestId) + provision: (deviceId: string, deviceName: string, requestId: string, password: string): Promise => + this.provisionWatchDevice(deviceId, deviceName, requestId, password) }; readonly watchProvisionController: WatchProvisionController = new WatchProvisionController(this.watchProvisionState, this.watchProvisionPort); @@ -208,9 +208,10 @@ export abstract class AppRootRuntimeComposition { private async provisionWatchDevice( deviceId: string, deviceName: string, - requestId: string + requestId: string, + password: string ): Promise { - const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId); + const minted = await this.settingsController.provisionWatchCredential(deviceId, deviceName, requestId, password); if (minted) { return minted; } @@ -220,6 +221,7 @@ export abstract class AppRootRuntimeComposition { const outcome = await this.sessionManager.provisionPeerDevice(deviceId, deviceName, requestId); return { ok: outcome.ok, + passwordRequired: false, // The desktop mints against the relay its room lives on, which is not // necessarily the one this phone's account is on. relayUrl: outcome.ok ? this.sessionManager.roomRelayEndpoint() : '', @@ -234,7 +236,7 @@ export abstract class AppRootRuntimeComposition { private static provisionUnavailable(): WatchProvisionOutcome { return { - ok: false, relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', + ok: false, passwordRequired: false, relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', failure: '', desktopReported: false }; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets index d2ddfcfd01..a186e538b6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/WatchProvisionState.ets @@ -6,7 +6,9 @@ export enum WatchProvisionPhase { Hidden = 'hidden', /** A request arrived and its owner has not answered yet. */ Asking = 'asking', - /** Approved; the desktop is minting the credential. */ + /** The relay needs a normal account login to add this watch. */ + Password = 'password', + /** Approved; the phone is obtaining and handing off the credential. */ Working = 'working', Done = 'done', Failed = 'failed' @@ -31,6 +33,11 @@ export class WatchProvisionState implements WatchProvisionDisplay { this.message = ''; } + requirePassword(message: string): void { + this.phase = WatchProvisionPhase.Password; + this.message = message; + } + done(message: string): void { this.phase = WatchProvisionPhase.Done; this.message = message; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index f1f040d227..dc18f81c97 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -206,7 +206,8 @@ export class SettingsController { async provisionWatchCredential( deviceId: string, deviceName: string, - requestId: string + requestId: string, + password: string = '' ): Promise { const session = this.cloudSession; if (!session || this.cloudRelayUrl.length === 0) { @@ -214,11 +215,32 @@ export class SettingsController { } const cloud = this.requireCloud(); try { + if (password.length > 0) { + const username = cloud.remoteState.accountUsername.trim(); + if (username.length === 0) { + throw new Error('The signed-in account name is unavailable.'); + } + const provisioned = await cloud.client.loginWatch( + this.cloudRelayUrl, username, password, session, deviceId, deviceName, requestId); + RemoteLogger.info(`watch credential obtained through account login device=${provisioned.deviceId}`); + return { + ok: true, + passwordRequired: false, + relayUrl: this.cloudRelayUrl, + token: provisioned.token, + userId: provisioned.userId, + masterKeyBase64: Encoding.bytesToBase64(session.masterKey), + deviceId: provisioned.deviceId, + failure: '', + desktopReported: false + }; + } const provisioned = await cloud.client.provisionDevice( this.cloudRelayUrl, session, deviceId, deviceName, requestId); RemoteLogger.info(`watch credential minted from the phone account device=${provisioned.deviceId}`); return { ok: true, + passwordRequired: false, relayUrl: this.cloudRelayUrl, token: provisioned.token, userId: provisioned.userId, @@ -228,10 +250,26 @@ export class SettingsController { desktopReported: false }; } catch (err) { + // Once the user is confirming the account, every failure belongs to the + // login attempt itself. Do not reinterpret a rejected password or an old + // relay response as a cue to ask the desktop. + if (password.length > 0) { + throw err instanceof Error ? err : new Error('Watch account verification failed.'); + } if (err instanceof CloudAccountRequestError && (err.statusCode === 401 || err.statusCode === 403)) { RemoteLogger.info('phone account may not mint a device credential; deferring to the desktop'); return undefined; } + if (err instanceof CloudAccountRequestError && (err.statusCode === 404 || err.statusCode >= 500) && + cloud.remoteState.accountUsername.trim().length > 0) { + RemoteLogger.info('relay device provisioning unavailable; requesting account confirmation'); + return { + ok: false, + passwordRequired: true, + relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', failure: '', + desktopReported: false + }; + } throw err instanceof Error ? err : new Error('Watch credential provisioning failed.'); } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index de22403d8d..601489db1f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -26,6 +26,7 @@ interface LoginRequest { device_id: string; device_name: string; device_kind: string; + request_id?: string; } interface RelayErrorResponse { error?: string; } @@ -146,10 +147,49 @@ export class CloudAccountClient { private readonly cipher: HarmonyRemoteCryptoCipher = new HarmonyRemoteCryptoCipher(); async login(relayUrl: string, username: string, password: string, deviceId: string): Promise { + return this.loginDevice( + relayUrl, username, password, deviceId, 'HarmonyOS Phone', DEVICE_KIND_MOBILE); + } + + /** + * Compatibility path for relays whose dedicated device-provisioning route is + * present but cannot mint against their existing account database. + * + * This is still a real device login: the watch receives its own token and + * device row. Only the password proof travels to the relay, while the + * plaintext password and the account master key stay on this phone. + */ + async loginWatch( + relayUrl: string, + username: string, + password: string, + currentSession: CloudAccountSession, + deviceId: string, + deviceName: string, + requestId: string + ): Promise { + const watchSession = await this.loginDevice( + relayUrl, username, password, deviceId, deviceName, DEVICE_KIND_WATCH, requestId); + if (watchSession.userId !== currentSession.userId || + !CloudAccountClient.sameBytes(watchSession.masterKey, currentSession.masterKey)) { + throw new Error('The confirmed account does not match the signed-in account.'); + } + return { token: watchSession.token, userId: watchSession.userId, deviceId }; + } + + private async loginDevice( + relayUrl: string, + username: string, + password: string, + deviceId: string, + deviceName: string, + deviceKind: string, + requestId: string = '' + ): Promise { const normalizedRelayUrl = relayUrl.trim() || DEFAULT_CLOUD_RELAY_URL; const normalizedUser = username.trim(); const startedAt = Date.now(); - RemoteLogger.info(`cloud login start relay=${normalizedRelayUrl}`); + RemoteLogger.info(`cloud device login start relay=${normalizedRelayUrl} kind=${deviceKind}`); if (normalizedUser.length === 0 || normalizedUser.length > 128 || password.length === 0 || password.length > 1024) { throw new Error('Invalid account credentials.'); } @@ -169,14 +209,28 @@ export class CloudAccountClient { username: normalizedUser, password_hash: Encoding.bytesToBase64(passwordHash), device_id: deviceId, - device_name: 'HarmonyOS Phone', - device_kind: DEVICE_KIND_MOBILE + device_name: deviceName, + device_kind: deviceKind }; + if (requestId.length > 0) { + loginRequest.request_id = requestId; + } const auth = await this.post(normalizedRelayUrl, '/api/auth/login', loginRequest); RemoteLogger.info(`cloud login authenticated elapsed_ms=${Date.now() - startedAt}`); return { token: auth.token, userId: auth.user_id, masterKey }; } + private static sameBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) { + return false; + } + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left[index] ^ right[index]; + } + return difference === 0; + } + /** * Adds another device to this account and returns its own credential. * diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets index b7faada547..55428e79aa 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionController.ets @@ -23,6 +23,8 @@ const DATASYNC_PERMISSION: Permissions = 'ohos.permission.DISTRIBUTED_DATASYNC'; */ export interface WatchProvisionOutcome { ok: boolean; + /** The relay requires the account's normal login path for this device. */ + passwordRequired: boolean; relayUrl: string; token: string; userId: string; @@ -40,7 +42,8 @@ export interface WatchProvisionPort { readonly provision: ( deviceId: string, deviceName: string, - requestId: string + requestId: string, + password: string ) => Promise; } @@ -154,18 +157,21 @@ export class WatchProvisionController { this.state.ask(request.deviceName, request.deviceId); } - async approve(): Promise { + async approve(password: string = ''): Promise { const request = this.pending; if (!request || this.inFlight) { return; } this.inFlight = true; this.state.working(); + let settled = false; try { - await this.runProvisioning(request); + settled = await this.runProvisioning(request, password); } finally { - this.answered.add(request.requestId); - this.pending = undefined; + if (settled) { + this.answered.add(request.requestId); + this.pending = undefined; + } this.inFlight = false; } } @@ -193,18 +199,26 @@ export class WatchProvisionController { this.state.hide(); } - private async runProvisioning(request: WatchProvisionRequest): Promise { + private async runProvisioning(request: WatchProvisionRequest, password: string): Promise { if (!this.port.canProvision()) { await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.noDesktop')); - return; + return true; } let outcome: WatchProvisionOutcome; try { - outcome = await this.port.provision(request.deviceId, request.deviceName, request.requestId); + outcome = await this.port.provision(request.deviceId, request.deviceName, request.requestId, password); } catch (err) { RemoteLogger.error(`watch provisioning failed: ${WatchProvisionController.errorText(err)}`); + if (password.length > 0) { + this.state.requirePassword(RemoteI18n.t('watchProvision.errors.passwordFailed')); + return false; + } await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.desktopUnreachable')); - return; + return true; + } + if (outcome.passwordRequired) { + this.state.requirePassword(''); + return false; } if (!outcome.ok) { // A desktop that answered and refused knows why; a desktop that never @@ -213,7 +227,7 @@ export class WatchProvisionController { const message = outcome.desktopReported && outcome.failure.length > 0 ? outcome.failure : RemoteI18n.t('watchProvision.errors.desktopUnreachable'); await this.failAttempt(request.requestId, message); - return; + return true; } if (outcome.relayUrl.trim().length === 0) { @@ -221,7 +235,7 @@ export class WatchProvisionController { // would only discover that after signing in. Stop here instead. RemoteLogger.error('watch provisioning produced a credential without a relay url'); await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.handoffFailed')); - return; + return true; } const credential: WatchProvisionCredential = { relay_url: outcome.relayUrl, @@ -246,10 +260,11 @@ export class WatchProvisionController { // already succeeded — so say so rather than implying nothing happened. RemoteLogger.error(`watch provisioning handoff failed: ${WatchProvisionController.errorText(err)}`); await this.failAttempt(request.requestId, RemoteI18n.t('watchProvision.errors.handoffFailed')); - return; + return true; } RemoteLogger.info('watch provisioning credential handed off'); this.state.done(RemoteI18n.f('watchProvision.doneBody', request.deviceName)); + return true; } private async failAttempt(requestId: string, message: string): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets index e3db25bea0..fb9197b3dc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WatchProvisionDisplay.ets @@ -2,6 +2,7 @@ export interface WatchProvisionDisplay { ask(deviceName: string, deviceId: string): void; working(): void; + requirePassword(message: string): void; done(message: string): void; fail(message: string): void; hide(): void; diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index a9f99a8db9..609b4f895f 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -8,9 +8,11 @@ import appRootRuntimeStartupUnitTest from './AppRootRuntimeStartupUnit.test'; import deviceDirectoryUnitTest from './DeviceDirectoryUnit.test'; import architectureUnitTest from './ArchitectureUnit.test'; import i18nUnitTest from './I18nUnit.test'; +import watchProvisionUnitTest from './WatchProvisionUnit.test'; export default function localUnitTest() { i18nUnitTest(); + watchProvisionUnitTest(); architectureUnitTest(); transportAndGeneralChatUnitTest(); conversationStateUnitTest(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets index e1d76fc5cd..b4bc631155 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/TransportAndGeneralChatUnit.test.ets @@ -94,6 +94,7 @@ import { import { CloudAccountClient, CloudAccountDevice, + CloudProvisionedDevice, CloudAccountRequestError, CloudAccountSession } from '../main/ets/services/CloudAccountClient'; @@ -355,6 +356,39 @@ class ExpiringAccountClient extends CloudAccountClient { } } +class ProvisioningFailureAccountClient extends CloudAccountClient { + readonly statusCode: number; + + constructor(statusCode: number) { + super(); + this.statusCode = statusCode; + } + + async provisionDevice( + _relayUrl: string, + _session: CloudAccountSession, + _deviceId: string, + _deviceName: string, + _requestId: string + ): Promise { + throw new CloudAccountRequestError('provisioning failed', this.statusCode); + } +} + +class WatchLoginFailureAccountClient extends CloudAccountClient { + async loginWatch( + _relayUrl: string, + _username: string, + _password: string, + _currentSession: CloudAccountSession, + _deviceId: string, + _deviceName: string, + _requestId: string + ): Promise { + throw new CloudAccountRequestError('login rejected', 401); + } +} + class AccountDeviceSwitchHarness { readonly remoteState: RemotePageState = new RemotePageState(); readonly sessionManager: ScriptedAccountSessionManager = new ScriptedAccountSessionManager(); @@ -1021,6 +1055,51 @@ export default function transportAndGeneralChatUnitTest() { }); describe('SettingsController', () => { + it('asks for account confirmation when a legacy relay has no provisioning route', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(404)); + + const outcome = await harness.controller.provisionWatchCredential( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'HarmonyOS Watch', + '12345678-1234-4234-8234-123456789abc' + ); + + expect(outcome !== undefined).assertTrue(); + expect(outcome?.passwordRequired).assertTrue(); + }); + + it('does not treat an authorization refusal as a legacy relay', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new ProvisioningFailureAccountClient(403)); + + const outcome = await harness.controller.provisionWatchCredential( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'HarmonyOS Watch', + '12345678-1234-4234-8234-123456789abc' + ); + + expect(outcome === undefined).assertTrue(); + }); + + it('reports a rejected compatibility login instead of deferring to the desktop', 0, async () => { + const harness = new AccountDeviceSwitchHarness(new WatchLoginFailureAccountClient()); + let statusCode = 0; + + try { + await harness.controller.provisionWatchCredential( + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'HarmonyOS Watch', + '12345678-1234-4234-8234-123456789abc', + 'wrong-password' + ); + } catch (err) { + if (err instanceof CloudAccountRequestError) { + statusCode = err.statusCode; + } + } + + expect(statusCode).assertEqual(401); + }); + it('tests model configuration with the stored key when the form keeps it unchanged', 0, async () => { const store = new InMemorySettingsConfigStore(); store.snapshotResult = { diff --git a/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets new file mode 100644 index 0000000000..829844e2b1 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/WatchProvisionUnit.test.ets @@ -0,0 +1,116 @@ +import { describe, expect, it } from '@ohos/hypium'; +import { + WatchProvisionController, + WatchProvisionOutcome, + WatchProvisionPort +} from '../main/ets/services/WatchProvisionController'; +import { WatchProvisionDisplay } from '../main/ets/services/WatchProvisionDisplay'; + +class FakeWatchProvisionDisplay implements WatchProvisionDisplay { + phase: string = 'hidden'; + message: string = ''; + + ask(_deviceName: string, _deviceId: string): void { + this.phase = 'asking'; + this.message = ''; + } + + working(): void { + this.phase = 'working'; + this.message = ''; + } + + requirePassword(message: string): void { + this.phase = 'password'; + this.message = message; + } + + done(message: string): void { + this.phase = 'done'; + this.message = message; + } + + fail(message: string): void { + this.phase = 'failed'; + this.message = message; + } + + hide(): void { + this.phase = 'hidden'; + this.message = ''; + } +} + +function watchRequest(): string { + return JSON.stringify({ + v: 1, + request_id: '12345678-1234-4234-8234-123456789abc', + device_id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + device_name: 'HarmonyOS Watch', + public_key: 'test-public-key', + created_ms: Date.now() + }); +} + +function passwordRequired(): WatchProvisionOutcome { + return { + ok: false, + passwordRequired: true, + relayUrl: '', token: '', userId: '', masterKeyBase64: '', deviceId: '', failure: '', + desktopReported: false + }; +} + +export default function watchProvisionUnitTest() { + describe('WatchProvisionController', () => { + it('keeps the watch request pending while account confirmation is needed', 0, async () => { + const display = new FakeWatchProvisionDisplay(); + const passwords: string[] = []; + const port: WatchProvisionPort = { + canProvision: (): boolean => true, + provision: async ( + _deviceId: string, + _deviceName: string, + _requestId: string, + password: string + ): Promise => { + passwords.push(password); + return passwordRequired(); + } + }; + const controller = new WatchProvisionController(display, port); + controller.onRequestPayload(watchRequest()); + + await controller.approve(); + expect(display.phase).assertEqual('password'); + await controller.approve('test-password'); + + expect(passwords.length).assertEqual(2); + expect(passwords[0]).assertEqual(''); + expect(passwords[1]).assertEqual('test-password'); + expect(display.phase).assertEqual('password'); + }); + + it('returns to password confirmation when the compatibility login fails', 0, async () => { + const display = new FakeWatchProvisionDisplay(); + const port: WatchProvisionPort = { + canProvision: (): boolean => true, + provision: async ( + _deviceId: string, + _deviceName: string, + _requestId: string, + _password: string + ): Promise => { + throw new Error('login rejected'); + } + }; + const controller = new WatchProvisionController(display, port); + controller.onRequestPayload(watchRequest()); + + await controller.approve('wrong-password'); + + expect(display.phase).assertEqual('password'); + expect(display.message.length > 0).assertTrue(); + }); + }); +}