File size: 14,723 Bytes
126cf9c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 | """
抖音孪生包账号同步 - 服务端核心逻辑 (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
|