AND / sync.py
ziren28's picture
v2.6: Hubble sync + D1 database
126cf9c verified
"""
抖音孪生包账号同步 - 服务端核心逻辑 (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_<uid>). "
"请先在源包完成登录."
)
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