| """
|
| 抖音孪生包账号同步 - 服务端核心逻辑 (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
|
|
|
| from conn import Session
|
|
|
|
|
|
|
| SRC_PKG = "com.ss.android.ugc.aweme.mobile"
|
| DST_PKG = "com.ss.android.ugc.aweme"
|
|
|
|
|
| DB_TEMPLATES = [
|
| "databases/account_db*",
|
| "databases/aweme.db*",
|
| "databases/aweme_database_{uid}*",
|
| "databases/db_notice_{uid}*",
|
| "databases/encrypted_{uid}_im*",
|
| ]
|
|
|
|
|
| PREFS_FILES = [
|
| "shared_prefs/aweme-app.xml",
|
| "shared_prefs/default_config.xml",
|
| ]
|
|
|
|
|
| KEVA_REPOS_TEMPLATES = [
|
|
|
| "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",
|
|
|
| "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",
|
|
|
| "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
|
|
|
|
|
| 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)
|
|
|
|
|
| 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)
|
|
|
|
|
| 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_<uid>). "
|
| "请先在源包完成登录."
|
| )
|
| src_uids = list(dict.fromkeys(m))
|
| log(f" 源包登录账号: {src_uids}")
|
| progress(32)
|
| check_stop()
|
|
|
|
|
| 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))
|
|
|
|
|
| ts = int(time.time())
|
| stamp = f"{os.getpid()}_{ts}"
|
|
|
|
|
| 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()
|
|
|
|
|
| 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()
|
|
|
|
|
| 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)
|
|
|
|
|
| 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()
|
|
|
|
|
| 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)
|
|
|
|
|
| log("[9/9] 截图验证")
|
| sess.sh(f"screencap -p /sdcard/dysync_verify_{stamp}.png", check=False)
|
| verify_png = None
|
| if scratch_dir:
|
|
|
| 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
|
|
|
|
|
| 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
|
|
|