AI_UIAutomation/utils/common/auth.helper.ts

514 lines
29 KiB
TypeScript

/**
* 平台账号 helper —— 登录 / 登出。Android 实测流程(2026-06):
* 登出: Profile tab → "Sign Out" → 确认框 "OK"
* 登录: 已登出 landing 顶部 "Sign in" → 表单(2 个 EditText:邮箱/密码)→ 填 → "Sign in"
* 表单上还有 "Sign up"(注册)、"Forgot Password"(忘记密码)入口。
*
* 主账号凭据走 config/account.config(分平台,env 可覆盖)。
* 注册/忘记密码/注销用临时邮箱(account-code.helper)。
*/
import { DeviceDriver } from '../../drivers/types';
import { AccountCredentials, getAccountCredentials } from '../../config/account.config';
import { sleep } from './element.helper';
async function tapText(driver: DeviceDriver, t: string): Promise<boolean> {
const el = driver.platform === 'android'
? await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`)
: await driver.findElementRaw('name', t);
if (el) { await driver.tapElement(el); return true; }
return false;
}
async function tapId(driver: DeviceDriver, id: string): Promise<boolean> {
const el = await driver.findElementRaw('-android uiautomator', `new UiSelector().resourceId("com.theswitchbot.switchbot:id/${id}")`);
if (el) { await driver.tapElement(el); return true; }
return false;
}
async function sourceHas(driver: DeviceDriver, kw: string): Promise<boolean> {
return (await driver.getSource()).includes(kw);
}
/** 轮询等待某文本出现(应对 Loading 遮罩/页面过渡),最多 timeoutMs。 */
async function waitForText(driver: DeviceDriver, kw: string, timeoutMs = 8000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await sourceHas(driver, kw)) return true;
await sleep(800);
}
return false;
}
/** 进入 Profile tab(底部导航 content-desc="Profile")。 */
export async function goToProfile(driver: DeviceDriver): Promise<void> {
// Profile 是底部 tab,仅在主页(底部导航可见)时点得到。控制/场景等子页残留时直接找 Profile 会落空,
// 导致登录态误判 → signInWithEmail 找不到表单(EditText=0)。故登录态子页先复位主页。
// 但登出态/登录相关页(landing/表单)绝不能 goBack(BACK 会退出 App → 黑屏),此时跳过复位。
const src0 = await driver.getSource().catch(() => '');
const authOrLanding = /Forgot Password|Sign ?up|Create Account|Log ?in|Welcome|Continue with|Sign ?in/i.test(src0)
&& !/Sign Out|Manage Accounts/.test(src0);
if (!authOrLanding) {
await driver.goBackToHomepage().catch(() => {});
await sleep(700);
await driver.dismissPopupIfPresent().catch(() => {});
}
const prof = await driver.findElementRaw('-android uiautomator', 'new UiSelector().descriptionContains("Profile")');
if (prof) { await driver.tapElement(prof); await sleep(2000); }
// 清 Profile 页上覆盖的 Attention 框(安全等级低提示),否则挡住 Sign Out / 后续操作(EU 低安全账号登录后常驻)
await dismissEmailHintDialog(driver);
}
/** 是否已登录:Profile 出现 Sign Out/Manage Accounts。注册/登录后常有 "Loading..." 遮罩致 source 暂空,需等其结算。 */
export async function isLoggedIn(driver: DeviceDriver): Promise<boolean> {
for (let i = 0; i < 10; i++) {
await driver.dismissPopupIfPresent().catch(() => {});
let src = await driver.getSource();
// 已在 Profile(登录态)
if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true;
// 在登录表单/landing(登出态)
if (src.includes('Forgot Password') || (src.includes('Sign in') && src.includes('Sign up'))) return false;
// Loading 遮罩 / 空页 / 过渡:等一下重试,不急着导航
if (src.trim().length < 80 || src.includes('Loading') || src.includes('加载')) { await sleep(2500); continue; }
// 其它页(如首页):去 Profile 确认
await goToProfile(driver);
src = await driver.getSource();
if (src.includes('Sign Out') || src.includes('Manage Accounts')) return true;
await sleep(2000);
}
return false;
}
/** 登出:Profile → Sign Out → OK 确认。已登出则直接返回。 */
export async function signOut(driver: DeviceDriver): Promise<void> {
await goToProfile(driver);
// iOS:"Sign Out" 在 Profile 页**底部**,需先滚到底让按钮进可视区再点。否则 sourceHas 虽能在树里看到文字(离屏元素也在树),
// 但 tapText 点的是屏幕外坐标→没真正登出却误判"已登出"(实测:登出后页面无 "Sign in"、断言失败)。
if (driver.platform === 'ios') {
const sz = await driver.getWindowSize().catch(() => ({ width: 390, height: 844 }));
const cx = Math.round(sz.width / 2);
// 实测向下滑**一次**即可露出底部 "Sign Out"(原 6 次过多)。
await driver.swipe(cx, Math.round(sz.height * 0.8), cx, Math.round(sz.height * 0.25), 0.1); await sleep(500);
}
if (!(await sourceHas(driver, 'Sign Out'))) { console.log('已是登出态,跳过 Sign Out'); return; }
await tapText(driver, 'Sign Out');
await sleep(1500);
// 确认框:Are you sure you want to sign out? → OK(勿点 Cancel)
await tapText(driver, 'OK');
await sleep(4000);
console.log('已登出');
}
/**
* 邮箱登录。从任意页进入:先到 Profile,点登出态 landing 的 "Sign in" 进表单,填邮箱/密码,提交。
* @param creds 缺省用 config/account.config 的主账号
*/
export async function signInWithEmail(driver: DeviceDriver, creds?: AccountCredentials): Promise<void> {
const { email, password } = creds || getAccountCredentials();
await goToLoginForm(driver); // 到登录表单(等 Forgot Password 出现)
if (driver.platform === 'ios') {
// iOS:邮箱=XCUIElementTypeTextField,密码=XCUIElementTypeSecureTextField(Android 的 EditText className 在此不适用)
const TF = 'type == "XCUIElementTypeTextField"';
const SF = 'type == "XCUIElementTypeSecureTextField"';
let emailEl = await driver.findElementRaw('predicate string', TF).catch(() => null);
for (let i = 0; i < 8 && !emailEl; i++) { await sleep(1000); emailEl = await driver.findElementRaw('predicate string', TF).catch(() => null); }
if (!emailEl) throw new Error('iOS 登录表单未找到邮箱输入框(TextField)');
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
await driver.clearText(emailEl).catch(() => {});
await driver.typeText(emailEl, email); await sleep(400);
const pwdEl = await driver.findElementRaw('predicate string', SF).catch(() => null);
if (!pwdEl) throw new Error('iOS 登录表单未找到密码输入框(SecureTextField)');
await driver.tapElement(pwdEl).catch(() => {}); await sleep(300);
await driver.typeText(pwdEl, password); await sleep(400);
// 收键盘:iOS 点键盘 Done/Return(绝不 goBack——会退出表单)
for (const k of ['Done', 'Return', 'Go', 'Join', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
await sleep(500);
} else {
// 表单:2 个 EditText(邮箱[0]、密码[1])。Loading/过渡可能致暂时取不到 → 重试
let eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
for (let i = 0; i < 8 && eds.length < 2; i++) {
await sleep(1000);
eds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
}
if (eds.length < 2) throw new Error(`登录表单未找到邮箱/密码输入框(EditText=${eds.length})`);
await driver.clearText(eds[0]).catch(() => {});
await driver.typeText(eds[0], email);
await sleep(400);
await driver.typeText(eds[1], password);
await sleep(400);
try { await driver.goBack(); } catch { /* 收起键盘 */ }
await sleep(500);
}
// 提交(表单的 "Sign in" 按钮)
await tapText(driver, 'Sign in');
await sleep(8000);
// 登录后可能弹 Attention 框(欧区区域提示 / 安全等级低)→ 有则点 Cancel 关掉,避免污染下个用例
await dismissEmailHintDialog(driver);
console.log(`已登录: ${email}`);
}
/** 从已登出态进入 Sign up(注册)页:landing → "Sign in"(进表单)→ "Sign up"(展开注册区)。已登录则先登出。 */
export async function goToSignUpPage(driver: DeviceDriver): Promise<void> {
await goToProfile(driver);
if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); }
// landing(无 Forgot Password)→ 点 Sign in 进登录表单
if (!(await sourceHas(driver, 'Forgot Password'))) {
await tapText(driver, 'Sign in');
await waitForText(driver, 'Forgot Password', 8000);
}
// 登录表单 → 点 Sign up 展开注册区(出现 Enter email address)
for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) {
await tapText(driver, 'Sign up');
await waitForText(driver, 'Enter email address', 4000);
}
}
/**
* 注册新账号(临时邮箱)。Android 实测流程(2026-06):
* Sign up 页 → 填邮箱 → 勾 3 个协议(cbAgreement/llAdult/cbSubEmail)→ Get Verification Code
* → (Terms 弹框 Agree)→ "Enter verification code" 页(单框)输入码 → 设密码页(2 框)→ Confirm。
* 注:必须勾全 3 个协议,否则 Get Verification Code 不发码。验证码框是单个 EditText(非 6 格)。
* @returns 新账号 { email, password }
*/
export async function registerAccount(
driver: DeviceDriver,
opts: { password?: string } = {}
): Promise<{ email: string; password: string; token: string }> {
const { createTempMailbox, getVerificationCode } = await import('./account-code.helper');
const password = opts.password || 'Test1234a';
const box = await createTempMailbox();
await goToSignUpPage(driver);
// 填注册邮箱(hint=Enter email address)+ 勾全 3 协议(缺一不发码)
if (driver.platform === 'ios') {
// iOS:邮箱框=TextField;3 协议勾选框**无标签**,在各协议文字**左侧**(点文字左 ~x22)。键盘用 Done 收(绝不 goBack)。
const emailEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
if (!emailEl) throw new Error('iOS Sign up 页未找到邮箱输入框(TextField)');
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
await driver.clearText(emailEl).catch(() => {});
await driver.typeText(emailEl, box.address); await sleep(400);
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
await sleep(500);
for (const kw of ['I agree to the', 'I am over 18', 'Sign me up']) {
const el = await driver.findElementRaw('predicate string', `label CONTAINS "${kw}" OR name CONTAINS "${kw}"`).catch(() => null);
if (el) { const r = await driver.getElementRect(el).catch(() => null); if (r) { await driver.tap(Math.max(22, r.x - 22), Math.round(r.y + r.height / 2)); await sleep(300); } }
else console.log(`WARN: iOS 未找到协议行 "${kw}"`);
}
} else {
const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")');
if (!emailEd) throw new Error('Sign up 页未找到邮箱输入框');
await driver.clearText(emailEd).catch(() => {});
await driver.typeText(emailEd, box.address);
await sleep(400);
try { await driver.goBack(); } catch { /* 收键盘 */ }
// 勾全 3 个协议(缺一不发码)
for (const id of ['cbAgreement', 'llAdult', 'cbSubEmail']) {
const ok = await tapId(driver, id);
if (!ok) console.log(`WARN: 未勾到协议 ${id}`);
await sleep(300);
}
}
// 发码 + 同意条款弹框
await tapText(driver, 'Get Verification Code');
await sleep(2000);
await tapText(driver, 'Agree'); // 首次会弹 Terms 弹框,点 Agree;无则忽略
await sleep(2500);
// 取码 + 输入(单框 editText)
const code = await getVerificationCode(box.token, { timeoutMs: 60000 });
console.log(`注册 ${box.address} 验证码=${code}`);
const codeEd = driver.platform === 'ios'
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
: (await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")')
|| (await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")'))[0]);
if (!codeEd) throw new Error('验证码输入框未找到');
await driver.tapElement(codeEd).catch(() => {});
await driver.typeText(codeEd, code);
await sleep(2000);
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
// 验证码正确后通常自动进设密码页;若有推进按钮先点
for (const b of ['Next', 'Confirm', 'Continue', '下一步']) {
if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; }
}
// 设密码页:新密码 / 确认密码 → Confirm
await sleep(1500);
if (driver.platform === 'ios') {
// iOS:2 个 SecureTextField(新密码/确认密码)
let sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]);
for (let i = 0; i < 6 && sfs.length < 2; i++) { await sleep(1000); sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]); }
if (sfs.length < 2) throw new Error(`iOS 设密码页 SecureTextField <2(实际 ${sfs.length})`);
await driver.tapElement(sfs[0]).catch(() => {}); await driver.typeText(sfs[0], password); await sleep(300);
await driver.tapElement(sfs[1]).catch(() => {}); await driver.typeText(sfs[1], password); await sleep(300);
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
} else {
const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
if (pwds.length < 2) throw new Error(`设密码页输入框 <2(实际 ${pwds.length}),需查看该页结构`);
await driver.typeText(pwds[0], password);
await sleep(300);
await driver.typeText(pwds[1], password);
await sleep(300);
try { await driver.goBack(); } catch { /* 收键盘 */ }
}
if (driver.platform === 'ios') {
// iOS 设密码页**保存按钮是 "OK"**(非 Confirm),点它才进"登录完成"页。显式重试点 OK,
// 不走下面通用循环——循环里 'Done' 在 'OK' 之前,若键盘未收会先点到键盘 Done 而 break、轮不到 OK。
let okTapped = false;
for (let i = 0; i < 6 && !okTapped; i++) { if (await tapText(driver, 'OK')) { okTapped = true; } else await sleep(1000); }
if (!okTapped) console.log('WARN: iOS 设密码页未点到 OK 保存按钮');
} else {
for (const b of ['Confirm', 'Done', 'OK', 'Sign up', 'Submit', '确定', '完成']) {
if (await tapText(driver, b)) break;
}
}
await sleep(6000);
await driver.dismissPopupIfPresent();
// 等注册后的 "Loading..." 遮罩结算到稳定登录态(Profile 有 Sign Out / 或到首页)
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
if (src.includes('Sign Out') || src.includes('Manage Accounts') || src.includes('Add a Device') || src.includes('content-desc="Home"')) break;
await driver.dismissPopupIfPresent().catch(() => {});
await sleep(2500);
}
console.log(`注册完成: ${box.address} / ${password}`);
return { email: box.address, password, token: box.token };
}
/** 进入登录表单(已登录则先登出)。等待表单出现(Forgot Password)。 */
async function goToLoginForm(driver: DeviceDriver): Promise<void> {
await goToProfile(driver);
if (await sourceHas(driver, 'Sign Out')) { await signOut(driver); await goToProfile(driver); }
// 失败注册/已存邮箱会弹 "Attention: Would you like to sign in with this email address?" → Cancel 回手动表单
await dismissEmailHintDialog(driver);
if (!(await sourceHas(driver, 'Forgot Password'))) {
await tapText(driver, 'Sign in');
await sleep(800);
await dismissEmailHintDialog(driver);
await waitForText(driver, 'Forgot Password', 8000);
}
}
/** 关闭登录前后的 "Attention" 提示弹框(统一点 Cancel,避免挡住表单)。覆盖:
* ① "Would you like to sign in with this email address?"(已存邮箱/失败注册残留)
* ② "Your account's security level is low ... link another login method"(安全等级低提示,EU/低安全账号登录后弹) */
async function dismissEmailHintDialog(driver: DeviceDriver): Promise<void> {
const src = await driver.getSource();
const hit = /sign in with this email|this email address|security level|Manage Accounts page|link another login/i.test(src);
if (hit) {
await tapText(driver, 'Cancel');
await sleep(800);
}
}
/**
* 忘记密码:登录表单 → "Forgot Password" → 输入邮箱 → Get Verification Code → 收码(单框)→ 设新密码(2框)→ Confirm。
* Forgot Password 页无 3 个协议勾选(与注册不同)。用注册返回的 { email, token } 收重置码。
* @returns 新密码
*/
export async function forgotPassword(
driver: DeviceDriver,
params: { email: string; token: string; newPassword?: string }
): Promise<string> {
const { getVerificationCode } = await import('./account-code.helper');
const newPassword = params.newPassword || 'Reset1234a';
await goToLoginForm(driver);
// 点 Forgot Password 展开重置区,等 "Enter email address" 出现(链式登出后有 Loading,需重试)
for (let i = 0; i < 4 && !(await sourceHas(driver, 'Enter email address')); i++) {
await tapText(driver, 'Forgot Password');
await waitForText(driver, 'Enter email address', 4000);
}
// 输入邮箱
if (driver.platform === 'ios') {
const emailEl = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null);
if (!emailEl) throw new Error('iOS Forgot Password 页未找到邮箱输入框(TextField)');
await driver.tapElement(emailEl).catch(() => {}); await sleep(300);
await driver.clearText(emailEl).catch(() => {});
await driver.typeText(emailEl, params.email); await sleep(400);
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
} else {
const emailEd = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("Enter email")');
if (!emailEd) throw new Error('Forgot Password 页未找到邮箱输入框');
await driver.clearText(emailEd).catch(() => {});
await driver.typeText(emailEd, params.email);
await sleep(400);
try { await driver.goBack(); } catch { /* 收键盘 */ }
}
// 发码(忘记密码无 3 协议;若意外有 Terms 弹框则 Agree)
await tapText(driver, 'Get Verification Code');
await sleep(2000);
await tapText(driver, 'Agree');
await sleep(2500);
// 取重置码 + 输入(单框)。发码与取码并发,验证码页("Verify It's You")可能稍后才渲染 → 等框出现再输。
const code = await getVerificationCode(params.token, { timeoutMs: 60000 });
console.log(`忘记密码 ${params.email} 重置码=${code}`);
let codeEd: string | null = null;
for (let i = 0; i < 10 && !codeEd; i++) {
codeEd = driver.platform === 'ios'
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")');
if (!codeEd) await sleep(1000);
}
if (!codeEd) throw new Error('重置码输入框未找到');
await driver.tapElement(codeEd).catch(() => {});
await driver.typeText(codeEd, code);
await sleep(2000);
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
for (const b of ['Next', 'Confirm', 'Continue', '下一步']) {
if (await sourceHas(driver, b)) { await tapText(driver, b); await sleep(2000); break; }
}
// 设新密码(2 框)→ 保存
await sleep(1500);
if (driver.platform === 'ios') {
let sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]);
for (let i = 0; i < 6 && sfs.length < 2; i++) { await sleep(1000); sfs = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeSecureTextField"').catch(() => [] as string[]); }
if (sfs.length < 2) throw new Error(`iOS 新密码页 SecureTextField <2(实际 ${sfs.length})`);
await driver.tapElement(sfs[0]).catch(() => {}); await driver.typeText(sfs[0], newPassword); await sleep(300);
await driver.tapElement(sfs[1]).catch(() => {}); await driver.typeText(sfs[1], newPassword); await sleep(300);
for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } }
// iOS 保存按钮是 "OK",显式重试点
let ok = false;
for (let i = 0; i < 6 && !ok; i++) { if (await tapText(driver, 'OK')) ok = true; else await sleep(1000); }
if (!ok) console.log('WARN: iOS 新密码页未点到 OK');
} else {
const pwds = await driver.findElementsRaw('-android uiautomator', 'new UiSelector().className("android.widget.EditText")');
if (pwds.length < 2) throw new Error(`新密码页输入框 <2(实际 ${pwds.length})`);
await driver.typeText(pwds[0], newPassword);
await sleep(300);
await driver.typeText(pwds[1], newPassword);
await sleep(300);
try { await driver.goBack(); } catch { /* 收键盘 */ }
for (const b of ['Confirm', 'Done', 'OK', 'Submit', '确定', '完成']) {
if (await tapText(driver, b)) break;
}
}
await sleep(6000);
await driver.dismissPopupIfPresent();
console.log(`忘记密码完成,新密码: ${newPassword}`);
return newPassword;
}
/**
* 注销(删除)当前登录账号。Android 实测流程(2026-06):
* Profile → Manage Accounts → "Delete Account"(进 Account Deletion Information 页)
* → 底部 destroyBto 按钮 → 确认框 "OK" → "Verify It's You" 码页(单框)输入码
* → "Canceling your account..."(注销已提交,带 Withdraw 撤销入口)。
* 注销码发到账号邮箱,需传该邮箱的 mail.tm token(getMailboxToken 取)。
* @returns 是否到达 "Canceling your account" 成功页
*/
export async function deleteAccount(driver: DeviceDriver, params: { token: string }): Promise<boolean> {
const { getVerificationCode } = await import('./account-code.helper');
await goToProfile(driver);
await tapText(driver, 'Manage Accounts');
await waitForText(driver, 'Delete Account', 8000);
await tapText(driver, 'Delete Account'); // 菜单项 → Account Deletion Information 页
await waitForText(driver, 'Account Deletion Information', 8000);
// 进入销毁:Android 底部 destroyBto + 确认框 OK;iOS 实测流程不同 → "Next" → 确认框 "Confirm"(非 destroyBto/OK)
if (driver.platform === 'ios') {
await tapText(driver, 'Next'); await sleep(2000); // Account Deletion Information 页推进
await tapText(driver, 'Confirm'); await sleep(2500); // 确认框 Cancel|Confirm → Confirm
} else {
if (!(await tapId(driver, 'destroyBto'))) throw new Error('未找到 destroyBto 销毁按钮');
await sleep(2000);
await tapText(driver, 'OK'); // 确认框 "Delete Account | Cancel | OK" → OK
}
// 等 "Verify It's You" 码页渲染(确认后稍后才出)→ 取码输入
let codeEd: string | null = null;
for (let i = 0; i < 12 && !codeEd; i++) {
codeEd = driver.platform === 'ios'
? await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeTextField"').catch(() => null)
: await driver.findElementRaw('-android uiautomator', 'new UiSelector().resourceId("com.theswitchbot.switchbot:id/editText")');
if (!codeEd) await sleep(1000);
}
if (!codeEd) throw new Error('注销验证码输入框未找到');
const code = await getVerificationCode(params.token, { timeoutMs: 60000 });
console.log(`注销验证码=${code}`);
await driver.tapElement(codeEd).catch(() => {});
await driver.typeText(codeEd, code);
await sleep(1500);
const afterType = await driver.getSource();
console.log(`[DEL诊断] 输码后 含Canceling=${afterType.includes('Canceling')} 含Account Security=${afterType.includes('Account Security')} 按钮=${JSON.stringify([...new Set(Array.from(afterType.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 20))].slice(0, 12))}`);
// 若已自动提交到成功页,别再 goBack(会把成功页退回);否则收键盘
if (!afterType.includes('Canceling your account')) {
if (driver.platform === 'ios') { for (const k of ['Done', 'Return', 'done', '完成']) { const kb = await driver.findElementRaw('name', k).catch(() => null); if (kb) { await driver.tapElement(kb); break; } } }
else { try { await driver.goBack(); } catch { /* 收键盘 */ } }
const afterBack = await driver.getSource();
console.log(`[DEL诊断] goBack后 含Canceling=${afterBack.includes('Canceling')} 含Account Security=${afterBack.includes('Account Security')}`);
// 单框输满通常自动提交;若有确认按钮也点
for (const b of ['Confirm', 'OK', 'Next', '确定', 'Delete', 'Delete Account']) {
if (await sourceHas(driver, `text="${b}"`)) { await tapText(driver, b); break; }
}
}
const ok = await waitForText(driver, 'Canceling your account', 15000);
console.log(ok ? '注销已提交(Canceling your account)' : '未到注销成功页');
return ok;
}
/**
* 第三方(Google)登录。Android 实测流程(2026-06):
* 登录表单 → 点 "Sign in by a third-party account" 区的 lastThirdLogin 图标(上次用的=Google)
* → 拉起系统 Google 账号浮层(com.google.android.gms,需设备已登 Google 账号)
* → 点 continue_button("继续以…的身份登录")→ ~10s 回 App,登录成功(MainActivity)。
* 注:lastThirdLogin 是"上次使用的第三方",本机为 Google;首次/无历史时入口图标可能不同。
* 需设备预先登录了一个 Google 账号(账号浮层会直接给 one-tap 继续)。
* @returns 是否登录成功
*/
export async function signInWithGoogle(driver: DeviceDriver): Promise<boolean> {
await goToLoginForm(driver); // 到登录表单(登出态)
// 点第三方登录入口(Google = lastThirdLogin)
let g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin');
for (let i = 0; i < 5 && !g; i++) { await sleep(800); g = await driver.findElementRaw('id', 'com.theswitchbot.switchbot:id/lastThirdLogin'); }
if (!g) { console.log('FAIL: 未找到第三方登录入口 lastThirdLogin'); return false; }
await driver.tapElement(g);
await sleep(4000);
// 三星新流程:点 Google 后弹「选择账号 以继续使用SwitchBot」账号选择器 → 选第一个账号(任意)。
// (旧机型为 one-tap continue_button 浮层;Google 记住授权时也可能直接登录。三者都兼容。)
for (let i = 0; i < 8; i++) {
const src = await driver.getSource();
if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) {
console.log(`Google 登录:已直接登录成功(无需选账号, +${i}s)`);
return true;
}
if (/选择账号|Choose an account|Choose account/i.test(src)) {
// 账号行:点第一个含 "@" 的邮箱文本即选中该账号
const acct = await driver.findElementRaw('-android uiautomator', 'new UiSelector().textContains("@")');
if (acct) { await driver.tapElement(acct); console.log('已选择 Google 账号(任意)'); await sleep(5000); break; }
}
if (await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button')) break; // 旧 one-tap 流程
await sleep(1000);
}
// 选账号后:可能弹 continue_button 确认,或直接 OAuth 回调登录回 App。
for (let i = 0; i < 10; i++) {
const src = await driver.getSource();
if (src.includes('Add a Device') || src.includes('content-desc="Home"') || src.includes('Manage Homes')) {
console.log(`Google 登录:已回到 App (+${i}s)`);
break;
}
const cont = await driver.findElementRaw('id', 'com.google.android.gms:id/continue_button');
if (cont) { await driver.tapElement(cont); console.log('点 Google continue_button'); await sleep(7000); break; }
await sleep(1000);
}
await sleep(2000);
const ok = await isLoggedIn(driver);
console.log(`Google 第三方登录: ${ok}`);
return ok;
}