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