""" 抖音孪生包账号同步 - 服务端核心逻辑 (v2.2 = v2.1 逻辑 + conn.Session 并发加固) ================================================= 功能: 云真机上 com.ss.android.ugc.aweme.mobile → com.ss.android.ugc.aweme 账号+设备身份整层同步 (Plan A, phone 4 验证通过) v2.2 相比 v2.1: - SSH/ADB 生命周期改由 conn.Session 托管, 删除本文件里重复的 Tunnel / Adb / parse_ssh_command / _robust_adb_ready (原有 kill-server 并发炸弹) - 硬超时 180s, 本地端口动态分配, SSH keep-alive, 自动重连 1 次 - 对外签名保持不变: run_sync(raw_input_text, scratch_dir, log, progress, stop_event=None) -> (ok: bool, verify_png_path: str|None, backup_tar_path: str|None) Plan A 关键决策 (实测): 1. 账号层 + 设备身份整层搬 (did/Cdid/DeviceSettingSp/device_info_sp 全部搬) 同一台硬件, 服务端基于硬件指纹识别"同一设备", 搬 mobile 的 iid 过去仍被识别 2. 必须搬隐私协议 keva (privacy_policy_dialog_repo 等), 否则目标卡"暂无法登录 请点同意" 3. 必须清目标的 shared_prefs/applog_stats.xml, 否则 XML/keva 两份 did 并存, splash 卡死 4. 必须清 account_device_transfer_logout_local_exp_v2 (本地 AB 实验 keva) 免干扰 5. chown/chcon 必须先单独修 files/ 父目录, 再递归修 files/keva, 否则 app uid 无 files/ 的 x 权限 → KEVA SDK 报 Read-only → splash 永久卡死 白名单精准清单 (避免全量带入 mobile 特有 keva 触发服务端一致性检查): DB_TEMPLATES: 5 个模板 PREFS_FILES: 2 个精确 KEVA_REPOS_TEMPLATES: 账号 + 身份 + 隐私合规 CLEAR_EXTRA_ON_DST: 2 个 """ import os import re import sys import tempfile import time _HERE = os.path.dirname(os.path.abspath(__file__)) _LIB = os.path.join(_HERE, "lib") if os.path.isdir(_LIB) and _LIB not in sys.path: sys.path.insert(0, _LIB) import paramiko # noqa: F401 (conn.Session 内部用, 这里保留显式 import 便于异常捕获) from conn import Session # ============ 硬编码适用范围 ============ SRC_PKG = "com.ss.android.ugc.aweme.mobile" DST_PKG = "com.ss.android.ugc.aweme" # databases 白名单模板 ({uid} 会按源包登录账号 uid 展开) DB_TEMPLATES = [ "databases/account_db*", "databases/aweme.db*", "databases/aweme_database_{uid}*", "databases/db_notice_{uid}*", "databases/encrypted_{uid}_im*", ] # shared_prefs 精确文件 PREFS_FILES = [ "shared_prefs/aweme-app.xml", "shared_prefs/default_config.xml", ] # files/keva/repo/ 白名单 KEVA_REPOS_TEMPLATES = [ # --- 账号 / session / token (核心 Plan A 14 项) --- "files/keva/repo/com.bytedance.sdk.account_setting", "files/keva/repo/aweme_account_session", "files/keva/repo/iuserstate", "files/keva/repo/aweme_user", "files/keva/repo/aweme_account_keva", "files/keva/repo/local_user", "files/keva/repo/token_shared_preference", "files/keva/repo/token_union_sdk_config.prefs", "files/keva/repo/tt_token_time", "files/keva/repo/CsrfTokenManager_sp", "files/keva/repo/multi_account_repo", "files/keva/repo/{uid}_aweme_user_info", "files/keva/repo/aweme_trusted_users_repo", "files/keva/repo/ssoconfigs", # --- 账号 SDK 辅助 (补齐, 避免服务端签名校验失败被踢) --- "files/keva/repo/account_sdk_d_ticket", "files/keva/repo/account_sdk_ts_sign_cache", "files/keva/repo/account_sdk_settings_sp", "files/keva/repo/account_sec_share_data", "files/keva/repo/bd_account_sec_time", "files/keva/repo/ug_account_token", "files/keva/repo/com_ss_android_token_sp_host", "files/keva/repo/com_bytedance_sdk_account_utils_common_request_cache_helper", "files/keva/repo/ct_account_api_sdk", "files/keva/repo/AccountBadger", "files/keva/repo/account_app_boot_cnt", "files/keva/repo/account_credential_pop", "files/keva/repo/account_writer_repo", "files/keva/repo/aweme_account_country_code", "files/keva/repo/multi_account_notice_keva", "files/keva/repo/{uid}multi_account_notice_keva", "files/keva/repo/account_oversea_login_config", "files/keva/repo/account_saas_login_method", "files/keva/repo/account_saas_migration_version", "files/keva/repo/account_saas_special_account", "files/keva/repo/account_saas_switch_info", # --- 设备身份层 --- "files/keva/repo/applog_stats", "files/keva/repo/com.ss.android.deviceregister.utils.Cdid", "files/keva/repo/DeviceSettingSp", "files/keva/repo/device_info_sp", # --- 隐私/合规 (只搬 mobile 有实际内容的; 空壳的保留目标原样, 避免把已有"已同意"标记清掉) --- "files/keva/repo/privacy_policy_dialog_repo", "files/keva/repo/privacy_user_setting_status_save", "files/keva/repo/privacy_monitor", "files/keva/repo/compliance_setting", "files/keva/repo/compliance_popup_config", ] # 解压前需清空的 (标版侧残留干扰项) CLEAR_EXTRA_ON_DST = [ "files/keva/repo/account_device_transfer_logout_local_exp_v2", "shared_prefs/applog_stats.xml", ] # ============ 主流程 ============ class SyncError(RuntimeError): pass def run_sync(raw_input_text, scratch_dir, log, progress, stop_event=None): """ 同设备账号同步主流程. 签名与 restore.run_restore 对齐. 返回 (ok, verify_png_path_or_None, backup_tar_path_or_None) """ def check_stop(): if stop_event is not None and stop_event.is_set(): raise SyncError("用户已中断") if scratch_dir: try: os.makedirs(scratch_dir, exist_ok=True) except Exception: pass # [1/9] SSH + adb ready (Session 托管) log("[1/9] 建立 SSH 隧道 + adb ready (动态端口, keep-alive, 硬超时 180s)") progress(6) check_stop() try: sess = Session(raw_input_text, log=log) except ValueError as e: raise SyncError(str(e)) try: try: sess.start() except paramiko.AuthenticationException: raise SyncError("SSH 认证失败: 用户名或密码错误") except Exception as e: raise SyncError(f"建立连接失败: {e}") log(f" serial={sess.serial}") progress(14) check_stop() # 设备信息 who = sess.sh("id", check=False).strip() if "uid=0" not in who: raise SyncError(f"需要 root, 当前: {who}") brand = sess.sh("getprop ro.product.brand", check=False).strip() model = sess.sh("getprop ro.product.model", check=False).strip() log(f" 设备: {brand} {model} ({who})") progress(18) # [2/9] 校验两个孪生包都在 log("[2/9] 校验孪生包同时存在") src_path = sess.sh(f"pm path {SRC_PKG}", check=False).strip() dst_path = sess.sh(f"pm path {DST_PKG}", check=False).strip() if "package:" not in src_path: raise SyncError(f"源包未安装: {SRC_PKG}") if "package:" not in dst_path: raise SyncError(f"目标包未安装: {DST_PKG}") log(" src ok dst ok") progress(24) # [3/9] 读元数据 + 源账号 uid log("[3/9] 收集元数据") src_dir = f"/data/data/{SRC_PKG}" dst_dir = f"/data/data/{DST_PKG}" dst_uidgid = sess.sh(f"stat -c '%u:%g' {dst_dir}").strip() dst_ctx = sess.sh(f"ls -Zd {dst_dir}").strip().split()[0] log(f" 目标 UID={dst_uidgid} SELinux={dst_ctx}") src_dbs = sess.sh(f"ls {src_dir}/databases/ 2>/dev/null", check=False) m = re.findall(r"aweme_database_(\d{10,})", src_dbs) if not m: raise SyncError( f"源包 {SRC_PKG} 未检测到已登录账号 (找不到 aweme_database_). " "请先在源包完成登录." ) src_uids = list(dict.fromkeys(m)) log(f" 源包登录账号: {src_uids}") progress(32) check_stop() # 模板展开 (db_templates -> 实际路径) sync_paths = [] for t in DB_TEMPLATES: if "{uid}" in t: for u in src_uids: sync_paths.append(t.replace("{uid}", u)) else: sync_paths.append(t) sync_paths += PREFS_FILES for t in KEVA_REPOS_TEMPLATES: if "{uid}" in t: for u in src_uids: sync_paths.append(t.replace("{uid}", u)) else: sync_paths.append(t) # 保序去重 sync_paths = list(dict.fromkeys(sync_paths)) # 并发安全的临时文件名 (pid + ts) ts = int(time.time()) stamp = f"{os.getpid()}_{ts}" # [4/9] 停两包 + 备份目标 (回滚) log("[4/9] 停止抖音, 备份目标包同步层 (可回滚)") sess.sh(f"am force-stop {SRC_PKG}", check=False) sess.sh(f"am force-stop {DST_PKG}", check=False) time.sleep(2) backup_tar = f"/data/local/tmp/dysync_dst_rollback_{stamp}.tar" check_dst = f"cd {dst_dir}; ls -d " + " ".join(sync_paths) + " 2>/dev/null" dst_exist = [l.strip() for l in sess.sh(check_dst, check=False).splitlines() if l.strip()] if dst_exist: sess.sh( f"cd {dst_dir} && tar -cf {backup_tar} " + " ".join( f"'{p}'" for p in dst_exist ), check=False, timeout=120, ) sz = sess.sh(f"stat -c '%s' {backup_tar} 2>/dev/null", check=False).strip() log(f" 回滚备份 → {backup_tar} ({sz} bytes)") else: log(" 目标包无同名文件 (首次同步), 跳过回滚备份") backup_tar = None progress(45) check_stop() # [5/9] 打包源数据 log(f"[5/9] 打包源包 {SRC_PKG} (白名单: db / prefs / keva)") check_src = f"cd {src_dir}; ls -d " + " ".join(sync_paths) + " 2>/dev/null" src_exist = [l.strip() for l in sess.sh(check_src, check=False).splitlines() if l.strip()] if not src_exist: raise SyncError("源包白名单清单下无匹配文件") src_tar = f"/data/local/tmp/dysync_src_{stamp}.tar" sess.sh( f"cd {src_dir} && tar -cf {src_tar} " + " ".join( f"'{p}'" for p in src_exist ), timeout=180, ) src_size = int(sess.sh(f"stat -c '%s' {src_tar}").strip()) log(f" 打包 → {src_tar} ({src_size/1024:.1f} KB, {len(src_exist)} 项)") progress(60) check_stop() # [6/9] 清目标同名 + 额外清单 log("[6/9] 清空目标包同名文件") rm_list = list(dst_exist) + CLEAR_EXTRA_ON_DST if rm_list: sess.sh( f"cd {dst_dir} && " + " && ".join( f"rm -rf '{p}'" for p in rm_list ), check=False, ) log(f" 已清 {len(rm_list)} 项") progress(70) # [7/9] 解压 + 修 UID/SELinux (含 files/ 父目录, 见模块 docstring 第 5 条) log("[7/9] 解压到目标, 修 UID / SELinux") sess.sh(f"tar -xf {src_tar} -C {dst_dir}", timeout=120) sess.sh( f"chown {dst_uidgid} {dst_dir}/files 2>/dev/null; " f"chcon {dst_ctx} {dst_dir}/files 2>/dev/null; " f"chown -R {dst_uidgid} {dst_dir}/databases {dst_dir}/shared_prefs {dst_dir}/files/keva 2>/dev/null; " f"chcon -R {dst_ctx} {dst_dir}/databases {dst_dir}/shared_prefs {dst_dir}/files/keva 2>/dev/null; " "true", check=False, ) log(" 权限修复完成 (含 files/ 父目录)") progress(82) check_stop() # [8/9] 启动 + 等待 log("[8/9] 启动目标包") sess.sh(f"monkey -p {DST_PKG} -c android.intent.category.LAUNCHER 1", check=False) for i in range(18): check_stop() time.sleep(1) progress(82 + i) # [9/9] 截图 + 清理 log("[9/9] 截图验证") sess.sh(f"screencap -p /sdcard/dysync_verify_{stamp}.png", check=False) verify_png = None if scratch_dir: # 截图临时放 /tmp, finally 里统一清 verify_png = os.path.join( tempfile.gettempdir(), f"hkdy_sync_verify_{sess.local_port}_{stamp}.png", ) try: sess.pull(f"/sdcard/dysync_verify_{stamp}.png", verify_png) sz_png = os.path.getsize(verify_png) if os.path.exists(verify_png) else 0 log(f" 截图 {sz_png} B (临时 {verify_png}, 函数退出清理)") except Exception as e: log(f" ⚠ 截图拉取失败: {e}") verify_png = None # 清理临时文件 (保留 backup_tar 供回滚) sess.sh( f"rm -f {src_tar} /sdcard/dysync_verify_{stamp}.png 2>/dev/null; true", check=False, ) has_sess = sess.sh( f"ls {dst_dir}/files/keva/repo/aweme_account_session/*.blk 2>/dev/null | head -1", check=False, ).strip() log(f" 目标 session keva 存在: {'是' if has_sess else '否'}") act = sess.sh( "dumpsys activity top 2>/dev/null | grep 'ACTIVITY com.ss.android.ugc.aweme' | head -1", check=False, ).strip() log(f" 当前 Activity: {act}") log("") log("=== 同步完成 ===") if backup_tar: log(f"设备端回滚备份: {backup_tar}") log("请在云真机上打开标版抖音查看登录态.") log("⚠ 首次同步后若弹出「个人信息保护指引」, 手动点一次「同意」即可,") log(" 后续不再弹 (这是 CN 包的一次性合规流程, 无法从港版搬运).") progress(100) return True, None, backup_tar # 截图不持久化 finally: try: if verify_png and os.path.exists(verify_png): os.unlink(verify_png) except Exception: pass try: sess.close() except Exception: pass