/** * 平台账号 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 { 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 { 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 { return (await driver.getSource()).includes(kw); } /** 轮询等待某文本出现(应对 Loading 遮罩/页面过渡),最多 timeoutMs。 */ async function waitForText(driver: DeviceDriver, kw: string, timeoutMs = 8000): Promise { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 优先按原 destroyBto id;找不到 → 打印页面按钮诊断 + 滚到底重试 + 文案兜底(App 改版/按钮在页底时仍可注销)。 let pushed = await tapId(driver, 'destroyBto'); if (!pushed) { const src = await driver.getSource(); const btns = [...new Set(Array.from(src.matchAll(/text="([^"]+)"/g)).map((m) => m[1]).filter((t) => t && t.length < 24))]; console.log(`[DEL诊断] destroyBto 未找到,Account Deletion 页按钮: ${JSON.stringify(btns.slice(0, 20))}`); await driver.scrollDown(600).catch(() => {}); await sleep(800); pushed = await tapId(driver, 'destroyBto'); if (!pushed) { for (const b of ['Delete Account', 'Confirm', 'Delete', 'Continue', 'Next', '注销账号', '确认注销']) { if (await tapText(driver, b)) { pushed = true; break; } } } } if (!pushed) throw new Error('未找到 destroyBto 销毁按钮(已试滚动+文案兜底,见[DEL诊断]按钮列表)'); 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 { 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; }