File size: 14,391 Bytes
126cf9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c8c4d6
126cf9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
00f06c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126cf9c
00f06c9
126cf9c
 
 
 
 
 
 
 
 
 
00f06c9
 
 
 
 
 
 
 
 
 
 
126cf9c
00f06c9
126cf9c
 
 
 
 
f77983d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
"""
Hubble 登录同步 — 同设备 mobile → hubble 数据搬运 (v1.3)
==========================================================

v1.3 变化:
  - 修数据膨胀: 只 cp files/keva/ (账号数据 ~50M), 不 cp 整个 files/* (~640M)
    每次执行前先 rm -rf 旧 keva 避免累积
  - 拆分三个入口: run_hubble (同步+启动), run_hubble_sync (仅同步), run_hubble_launch (仅启动)

对外签名:
    run_hubble(raw, scratch, log, progress, stop_event) -> (ok, png, info)
    run_hubble_sync(raw, scratch, log, progress, stop_event) -> (ok, png, info)
    run_hubble_launch(raw, scratch, log, progress, stop_event) -> (ok, png, info)
"""

import os
import sys
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
from conn import Session

SRC_PKG = "com.ss.android.ugc.aweme.mobile"
DST_PKG = "com.ss.android.ugc.aweme.hubble"

CERT_URL = (
    "https%3A%2F%2Fwebcast.amemv.com%2Ffalcon%2Fwebcast_douyin%2F"
    "page%2Fcertification%2Findex.html"
    "%3Fenter_from%3Dlive_take%26hide_nav_bar%3D1%26__live_platform__%3Dwebcast"
)

INTENT_TEMPLATE = (
    "intent:#Intent;launchFlags=0x10000000;"
    "component={dst}/com.ss.android.ugc.aweme.live.LiveDummyActivity;"
    "B.hide_status_bar=false;i.intent_type=1;"
    "B.enable_transparent_background=false;"
    "i.bundle_web_view_background_color=-1;"
    "S.url={url};"
    "S.key_calling_context=com.ss.android.ugc.aweme.shortvideo.ui.scan.ScanNewActivity;"
    "l.ad_id=0;B.hide_nav_bar=false;B.show_progress=false;"
    "B.hide_more=true;B.bundle_no_hw_acceleration=false;"
    "B.disable_open_animate=false;end"
)

POLL_INTERVAL = 5
POLL_TIMEOUT = 120


class HubbleError(RuntimeError):
    pass




# ═══════════════════ 内部构建块 ═══════════════════

def _init_session(raw_input_text, log, progress, check_stop):
    """SSH + adb + root + 双包校验 + 关 multiprocess. 返回 sess."""
    log("[1] 建立 SSH 隧道 + adb ready")
    progress(5)
    check_stop()

    try:
        sess = Session(raw_input_text, log=log, hard_timeout=480)
    except ValueError as e:
        raise HubbleError(str(e))

    try:
        sess.start()
    except paramiko.AuthenticationException:
        raise HubbleError("SSH 认证失败: 用户名或密码错误")
    except Exception as e:
        raise HubbleError(f"建立连接失败: {e}")
    log(f"      serial={sess.serial}")
    progress(10)

    who = sess.sh("id", check=False).strip()
    if "uid=0" not in who:
        raise HubbleError(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(12)

    log("[2] 校验双包 + 关 WebView 多进程")
    check_stop()
    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 HubbleError(f"源包未安装: {SRC_PKG}")
    if "package:" not in dst_path:
        raise HubbleError(f"目标包未安装: {DST_PKG}")
    log("      双包 OK")

    sess.sh("cmd webviewupdate disable-multiprocess", check=False)
    mp = sess.sh("dumpsys webviewupdate | grep 'Multiprocess enabled'", check=False).strip()
    log(f"      {mp}")
    if "false" not in mp:
        raise HubbleError("cmd webviewupdate disable-multiprocess 未生效")
    progress(18)
    return sess


def _do_sync(sess, log, progress, check_stop):
    """force-stop + 清旧 keva + cp keva + 权限 + 授权."""
    dst_dir = f"/data/data/{DST_PKG}"
    src_dir = f"/data/data/{SRC_PKG}"

    log("[3] force-stop")
    check_stop()
    sess.sh(f"am force-stop {DST_PKG}", check=False)
    time.sleep(1)
    progress(25)

    log("[4] overlay cp keva (仅账号数据, 不删 hubble 自有条目)")
    check_stop()
    sess.sh(f"mkdir -p {dst_dir}/files/keva", check=False)
    # overlay: 只覆盖 mobile 有的, 保留 hubble 独有的 (plugin 配置等)
    sess.sh(f"cp -rf {src_dir}/files/keva/* {dst_dir}/files/keva/ 2>/dev/null; echo cp_done")
    sz = sess.sh(f"du -s {dst_dir}/files/keva 2>/dev/null | head -1", check=False).strip()
    log(f"      keva overlay done ({sz})")
    progress(35)

    log("[5] chown + chcon")
    check_stop()
    dst_uidgid = sess.sh(f"stat -c '%u:%g' {dst_dir}").strip()
    dst_ctx = sess.sh(f"stat -c '%C' {dst_dir}").strip()
    log(f"      uid:gid={dst_uidgid} ctx={dst_ctx}")
    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}/files/keva; "
        f"chcon -R {dst_ctx} {dst_dir}/files/keva",
        check=False,
    )
    progress(40)

    log("[6] 授权")
    sess.sh(f"pm grant {DST_PKG} android.permission.POST_NOTIFICATIONS 2>/dev/null", check=False)
    progress(42)


def _do_launch(sess, log, progress, check_stop, pct_start=45, pct_end=97):
    """暖机 + 轮询 plugin + 启动 intent + 验证. 返回 ok."""
    log("[7] 暖机: force-stop → monkey launch")
    check_stop()
    sess.sh(f"am force-stop {DST_PKG}", check=False)
    time.sleep(1)
    sess.sh(f"monkey -p {DST_PKG} -c android.intent.category.LAUNCHER 1", check=False)
    time.sleep(5)

    pid = sess.sh(
        f"ps -ef | grep {DST_PKG} | grep -v grep | head -1 | awk '{{print $2}}'",
        check=False,
    ).strip()
    log(f"      PID={pid}")
    progress(pct_start)

    # 轮询: pct_start → pct_start+35
    poll_base = pct_start
    poll_range = 35
    log(f"[8] 等待 live plugin 就绪 (最多 {POLL_TIMEOUT}s)")
    check_stop()
    elapsed = 0
    plugin_ready = False

    while elapsed < POLL_TIMEOUT:
        time.sleep(POLL_INTERVAL)
        elapsed += POLL_INTERVAL
        check_stop()

        pct = poll_base + int(elapsed / POLL_TIMEOUT * poll_range)
        progress(min(pct, poll_base + poll_range - 1))

        # .jar
        jar = sess.sh(
            f"ls /data/data/{DST_PKG}/files/.patchs/sc_m.l.live.plugin*.jar 2>/dev/null"
            " | grep -v '\\.tp' | head -1",
            check=False, timeout=10,
        ).strip()
        if jar and "No such" not in jar:
            log(f"      [{elapsed}s] live plugin jar: {jar.split('/')[-1]}")
            plugin_ready = True
            break

        # .tp
        tp = sess.sh(
            f"ls /data/data/{DST_PKG}/files/.patchs/sc_m.l.live.plugin*.tp 2>/dev/null | head -1",
            check=False, timeout=10,
        ).strip()
        if tp and "No such" not in tp:
            if elapsed % 15 == 0:
                log(f"      [{elapsed}s] plugin 下载中...")
            continue

        # TTPluginManager
        if pid:
            tt = sess.sh(
                f"logcat -d --pid={pid} 2>/dev/null | grep -c cr_TTPluginManager",
                check=False, timeout=10,
            ).strip()
            try:
                if int(tt) > 0:
                    log(f"      [{elapsed}s] cr_TTPluginManager OK")
                    plugin_ready = True
                    break
            except ValueError:
                pass

        if elapsed % 30 == 0:
            log(f"      [{elapsed}s] 仍在等待...")

    if not plugin_ready:
        log(f"      [{elapsed}s] 超时, 仍尝试启动")
    progress(poll_base + poll_range)

    # 启动
    log("[9] 启动实名认证页 (不 force-stop)")
    check_stop()
    intent = INTENT_TEMPLATE.format(dst=DST_PKG, url=CERT_URL)
    out = sess.sh(f'am start "{intent}"', check=False).strip()
    log(f"      {out}")
    progress(poll_base + poll_range + 2)

    for i in range(15):
        check_stop()
        time.sleep(1)
        progress(min(poll_base + poll_range + 2 + i, pct_end))

    # 验证
    top = sess.sh(
        "dumpsys activity activities | grep topResumedActivity | head -1",
        check=False,
    ).strip()
    log(f"      {top}")
    if "LiveDummyActivity" in top:
        log("      LiveDummyActivity 在前台 OK")
    else:
        log("      [注意] 不在前台, 请看截图确认")
    return True


def _screenshot(sess, scratch_dir, log):
    ts_stamp = int(time.time())
    stamp = f"{os.getpid()}_{ts_stamp}"
    sess.sh(f"screencap -p /sdcard/hubble_verify_{stamp}.png", check=False)
    verify_png = None
    if scratch_dir:
        verify_png = os.path.join(scratch_dir, f"hubble_verify_{sess.local_port}_{stamp}.png")
        try:
            sess.pull(f"/sdcard/hubble_verify_{stamp}.png", verify_png)
        except Exception as e:
            log(f"      截图拉取失败: {e}")
            verify_png = None
    sess.sh(f"rm -f /sdcard/hubble_verify_{stamp}.png 2>/dev/null; true", check=False)
    return verify_png


# ═══════════════════ 三个公开入口 ═══════════════════

def run_hubble(raw_input_text, scratch_dir, log, progress, stop_event=None):
    """完整流程: 同步 + 暖机 + 启动."""
    def check_stop():
        if stop_event and stop_event.is_set():
            raise HubbleError("用户已中断")

    if scratch_dir:
        try: os.makedirs(scratch_dir, exist_ok=True)
        except: pass

    sess = _init_session(raw_input_text, log, progress, check_stop)
    try:
        _do_sync(sess, log, progress, check_stop)
        _do_launch(sess, log, progress, check_stop, pct_start=45, pct_end=97)
        png = _screenshot(sess, scratch_dir, log)
        log("\n=== Hubble 同步+启动完成 ===")
        progress(100)
        return True, png, None
    finally:
        try: sess.close()
        except: pass


def run_hubble_sync(raw_input_text, scratch_dir, log, progress, stop_event=None):
    """仅同步数据, 不暖机不启动."""
    def check_stop():
        if stop_event and stop_event.is_set():
            raise HubbleError("用户已中断")

    if scratch_dir:
        try: os.makedirs(scratch_dir, exist_ok=True)
        except: pass

    sess = _init_session(raw_input_text, log, progress, check_stop)
    try:
        _do_sync(sess, log, progress, check_stop)
        log("\n=== Hubble 仅同步完成 ===")
        log("数据已搬运, 如需验证请单独执行「启动页面」.")
        progress(100)
        return True, None, None
    finally:
        try: sess.close()
        except: pass


def _quick_launch(sess, log, progress, check_stop):
    """快速尝试: 直接 am start intent, 不暖机. 返回 True=成功 False=白屏需兜底."""
    log("[快速] 尝试直接启动 (不暖机)...")
    progress(22)
    check_stop()

    # 先 force-stop 再启动
    sess.sh(f"am force-stop {DST_PKG}", check=False)
    time.sleep(2)

    intent = INTENT_TEMPLATE.format(dst=DST_PKG, url=CERT_URL)
    sess.sh(f'am start "{intent}"', check=False)
    log("      intent 已发送, 等待 15s...")
    progress(30)

    for i in range(15):
        check_stop()
        time.sleep(1)
        progress(30 + i)

    # 验证: top activity + WebView 日志
    top = sess.sh(
        "dumpsys activity activities | grep topResumedActivity | head -1",
        check=False,
    ).strip()

    if "LiveDummyActivity" not in top:
        log("      [快速] Activity 不在前台, 需兜底")
        return False

    # 检查 WebView 实例是否创建 (cr_AwContents / cr_TTPlugin)
    pid = sess.sh(
        f"ps -ef | grep {DST_PKG} | grep -v grep | head -1 | awk '{{print $2}}'",
        check=False,
    ).strip()
    if pid:
        wv = sess.sh(
            f"logcat -d --pid={pid} 2>/dev/null | grep -iE 'cr_AwContents|cr_TTPlugin' | tail -1",
            check=False, timeout=10,
        ).strip()
        if wv:
            log("      [快速] WebView 已创建, 成功!")
            return True

    # Activity 在前台但 WebView 未检测到 — 可能白屏
    log("      [快速] WebView 未检测到, 可能白屏, 进入兜底...")
    return False


def run_hubble_launch(raw_input_text, scratch_dir, log, progress, stop_event=None):
    """先快速直接启动, 失败则暖机+轮询兜底."""
    def check_stop():
        if stop_event and stop_event.is_set():
            raise HubbleError("用户已中断")

    if scratch_dir:
        try: os.makedirs(scratch_dir, exist_ok=True)
        except: pass

    sess = _init_session(raw_input_text, log, progress, check_stop)
    try:
        # 第一轮: 快速尝试
        if _quick_launch(sess, log, progress, check_stop):
            progress(95)
            png = _screenshot(sess, scratch_dir, log)
            log("\n=== 快速启动成功 ===")
            progress(100)
            return True, png, None

        # 第二轮: 完整暖机兜底
        log("[兜底] 进入完整暖机+轮询流程...")
        _do_launch(sess, log, progress, check_stop, pct_start=50, pct_end=97)
        png = _screenshot(sess, scratch_dir, log)
        log("\n=== Hubble 启动页面完成 (兜底) ===")
        progress(100)
        return True, png, None
    finally:
        try: sess.close()
        except: pass


def run_hubble_quick(raw_input_text, scratch_dir, log, progress, stop_event=None):
    """快速模式: 只发 intent, 不暖机不轮询不验证."""
    def check_stop():
        if stop_event and stop_event.is_set():
            raise HubbleError("用户已中断")

    if scratch_dir:
        try: os.makedirs(scratch_dir, exist_ok=True)
        except: pass

    sess = _init_session(raw_input_text, log, progress, check_stop)
    try:
        log("[快速] force-stop + am start intent")
        progress(30)
        sess.sh(f"am force-stop {DST_PKG}", check=False)
        time.sleep(1)
        intent = INTENT_TEMPLATE.format(dst=DST_PKG, url=CERT_URL)
        sess.sh(f'am start "{intent}"', check=False)
        log("      intent 已发送")
        progress(60)

        log("[快速] 等待 10s...")
        for i in range(10):
            check_stop()
            time.sleep(1)
            progress(60 + i * 3)

        png = _screenshot(sess, scratch_dir, log)
        log("\n=== 快速启动完成 ===")
        progress(100)
        return True, png, None
    finally:
        try: sess.close()
        except: pass