/** * android-stub.js — 最小 Android / AutoX 运行环境模拟 * * 为什么需要它:AutoX 设备端没有 linter、没有构建、没有测试框架 * (见 autox-coding-standards 的「设备端没有门禁」)。 * 产物一旦发到手机上,只能靠肉眼看界面判断对错。 * * 这个桩把 android.* / ui / storages / files / dialogs 等替换成可观测的假实现, * 让 tools/smoke.js 能在 PC 上把整个产物真实跑起来: * · 模块能否加载(作用域与依赖顺序是否正确) * · 界面树能否构建完整(每个 View 的构造是否踩到不存在的 API) * · 状态读写是否正确(点击后配置有没有真的变) * 桩只覆盖产物真正用到的 API,不追求完整。 */ 'use strict'; /* ------------------------------------------------------------ 视图基类 */ class MockLayoutParams { constructor(w, h) { this.width = w; this.height = h; this.weight = 0; this.margins = { l: 0, t: 0, r: 0, b: 0 }; } setMargins(l, t, r, b) { this.margins = { l, t, r, b }; return this; } } class MockView { constructor() { this.children = []; this.parent = null; this.layoutParams = null; this.listeners = {}; this.padding = { l: 0, t: 0, r: 0, b: 0 }; this.background = null; this.minWidth = 0; this.minHeight = 0; this.visibility = 0; this.tag = null; } setLayoutParams(p) { this.layoutParams = p; return this; } getLayoutParams() { return this.layoutParams; } addView(v, lp) { if (lp) { v.layoutParams = lp; } v.parent = this; this.children.push(v); return this; } removeAllViews() { this.children.forEach((c) => { c.parent = null; }); this.children = []; return this; } removeView(v) { this.children = this.children.filter((c) => c !== v); v.parent = null; } getChildCount() { return this.children.length; } getChildAt(index) { return this.children[index] || null; } getParent() { return this.parent; } setOnClickListener(fn) { this.listeners.click = fn; } setOnKeyListener(l) { this.listeners.key = l; } setOnTouchListener(l) { this.listeners.touch = l; } setBackground(d) { this.background = d; } setPadding(l, t, r, b) { this.padding = { l, t, r, b }; } setMinimumHeight(h) { this.minHeight = h; } setMinimumWidth(w) { this.minWidth = w; } setMinWidth(w) { this.minWidth = w; } setClickable(v) { this.clickable = v; } setFocusable(v) { this.focusable = v; } setFocusableInTouchMode(v) { this.focusableInTouchMode = v; } setGravity(g) { this.gravity = g; } setVisibility(v) { this.visibility = v; } getVisibility() { return this.visibility === undefined ? 0 : this.visibility; } setAlpha(a) { this.alpha = a; } getAlpha() { return this.alpha === undefined ? 1 : this.alpha; } setClipToOutline(v) { this.clipToOutline = v; } /* 悬浮球的布局轨迹靠它们:真机读的是「布局结果」,桩里就用 LayoutParams 里写的值顶上 */ addOnLayoutChangeListener(l) { if (!this.layoutListeners) { this.layoutListeners = []; } this.layoutListeners.push(l); } getLocationOnScreen(out) { if (out) { out[0] = this.getLeft(); out[1] = this.getTop(); } return out; } /* 悬浮球的状态切换靠"等这一帧画完"(onPreDraw)。桩里不做绘制, * 所以监听只是登记下来,真正的兜底由 setTimeout(桩里立即执行)走完 —— 结论一样。 */ getViewTreeObserver() { return { addOnPreDrawListener: (l) => { if (!this.preDrawListeners) { this.preDrawListeners = []; } this.preDrawListeners.push(l); }, removeOnPreDrawListener: () => { } }; } setTag(t) { this.tag = t; } getTag() { return this.tag; } requestFocus() { this.focused = true; } /* 几何:桩不做真正的测量,直接用 LayoutParams 里写的值当"量完之后"的结果。 * 悬浮球的 dumpLayout() 会打印这些数字,有了它们,离线也能核验"球/按钮被放到哪", * 而不是只能看到一片 0。 */ getWidth() { return numOr(this.layoutParams && this.layoutParams.width, 0); } getHeight() { return numOr(this.layoutParams && this.layoutParams.height, 0); } getLeft() { return numOr(this.layoutParams && this.layoutParams.leftMargin, 0); } getTop() { return numOr(this.layoutParams && this.layoutParams.topMargin, 0); } getX() { return this.getLeft(); } getY() { return this.getTop(); } /* 测试辅助:模拟点击 */ performClick() { if (this.listeners.click) { this.listeners.click(this); } } /** 测试辅助:模拟一次触摸(走与真机同一条通路的 OnTouchListener) */ dispatchTouch(event) { if (this.listeners.touch) { return this.listeners.touch.onTouch(this, event); } return false; } } /** 负值(MATCH_PARENT / WRAP_CONTENT)不该被当成真实尺寸上报 */ function numOr(v, fallback) { return (typeof v === 'number' && v > 0) ? v : fallback; } /* 可见性常量:用**真机的数字**(0/4/8)。桩里不给的话就是 undefined, * 而 `setVisibility(undefined)` 不报错 —— 于是"某个视图到底是不是 GONE"永远断言不出来。 */ MockView.VISIBLE = 0; MockView.INVISIBLE = 4; MockView.GONE = 8; /** * TextView 桩。 * * ★ `setText` 必须**同步触发** TextWatcher —— 真机就是这个顺序 * (已在设备上实测:watcher 回调在 setText 返回前就跑完了)。 * 如果桩里不触发,那些「程序化回写会自触发」的坑就测不出来, * 界面会在真机上陷入「回写 → 触发监听 → 再回写」的循环。 */ class MockTextView extends MockView { constructor() { super(); this.text = ''; this.textSize = 0; this.textColor = null; this.typeface = null; this.singleLine = false; this.ellipsize = null; this.hint = ''; this.hintColor = null; this.maxWidth = 0; this.watchers = []; this.selection = 0; } setText(t) { const prev = this.text; this.text = String(t === null || t === undefined ? '' : t); this.notifyText(prev, this.text); } getText() { return this.text; } addTextChangedListener(w) { this.watchers.push(w); } removeTextChangedListener(w) { this.watchers = this.watchers.filter((x) => x !== w); } notifyText(prev, next) { this.watchers.forEach((w) => { if (w.beforeTextChanged) { w.beforeTextChanged(prev, 0, prev.length, next.length); } if (w.onTextChanged) { w.onTextChanged(next, 0, prev.length, next.length); } if (w.afterTextChanged) { w.afterTextChanged(next); } }); } /** 测试辅助:模拟用户逐字输入(与 IME 走同一条通路) */ type(str) { for (let i = 0; i < String(str).length; i++) { const prev = this.text; this.text = prev + String(str).charAt(i); this.notifyText(prev, this.text); } } /** 测试辅助:模拟连续退格清空 */ eraseAll() { while (this.text.length > 0) { const prev = this.text; this.text = prev.substring(0, prev.length - 1); this.notifyText(prev, this.text); } } /** 测试辅助:退一格(从尾部删一个字符) */ backspace() { if (this.text.length === 0) { return; } const prev = this.text; this.text = prev.substring(0, prev.length - 1); this.notifyText(prev, this.text); } /* 监听器在桩里存的是**回调表**(真机上也是个实现了接口的对象), * 所以统一通过方法名派发,别把回调表当函数直接调 */ setOnFocusChangeListener(l) { this.focusListener = l; } notifyFocus(hasFocus) { const l = this.focusListener; if (!l) { return; } if (typeof l === 'function') { l(this, hasFocus); return; } if (typeof l.onFocusChange === 'function') { l.onFocusChange(this, hasFocus); } } gainFocus() { this.focused = true; this.notifyFocus(true); } loseFocus() { this.focused = false; this.notifyFocus(false); } setTextSize(s) { this.textSize = s; } setTextColor(c) { this.textColor = c; } setTypeface(t) { this.typeface = t; } setSingleLine(v) { this.singleLine = v; } setEllipsize(e) { this.ellipsize = e; } setHint(h) { this.hint = h; } setHintTextColor(c) { this.hintColor = c; } setMaxWidth(w) { this.maxWidth = w; } setMaxLines() { } setLineSpacing(add, mult) { this.lineSpacing = { add, mult }; } setSelection(i) { this.selection = i; } setSelectAllOnFocus(v) { this.selectAllOnFocus = v; } setImeOptions(o) { this.imeOptions = o; } } class MockEditText extends MockTextView { setInputType(t) { this.inputType = t; } } class MockScrollView extends MockView { setVerticalScrollBarEnabled() { } setOverScrollMode() { } } class MockLinearLayout extends MockView { setOrientation(o) { this.orientation = o; } } class MockFrameLayout extends MockView { } /** WindowManager.LayoutParams 桩:悬浮球用的是裸 WindowManager 这条路 */ class MockWMLayoutParams { constructor() { this.type = 0; this.format = 0; this.flags = 0; this.gravity = 0; this.x = 0; this.y = 0; this.width = 0; this.height = 0; } } class MockImageView extends MockView { setImageBitmap(b) { this.bitmap = b; } setImageDrawable(d) { this.drawable = d; } setScaleType(s) { this.scaleType = s; } } MockImageView.ScaleType = { FIT_XY: 'FIT_XY', CENTER_CROP: 'CENTER_CROP', FIT_CENTER: 'FIT_CENTER' }; /** * MotionEvent 桩。 * * 悬浮球靠 getAction()/getRawX()/getRawY() 判断「单击 vs 拖动 vs 取消」, * 而这三者的区分是本模块最容易出错的地方(ACTION_CANCEL 曾经被当成 UP 处理, * 导致"每次展开立刻又收起")。所以它必须能被离线构造出来,否则那段逻辑永远测不到。 */ class MockMotionEvent { constructor(action, rawX, rawY) { this.action = action; this.rawX = rawX; this.rawY = rawY; } getAction() { return this.action; } getRawX() { return this.rawX; } getRawY() { return this.rawY; } getX() { return this.rawX; } getY() { return this.rawY; } } MockMotionEvent.ACTION_DOWN = 0; MockMotionEvent.ACTION_UP = 1; MockMotionEvent.ACTION_MOVE = 2; MockMotionEvent.ACTION_CANCEL = 3; /** * floaty 窗口桩。 * * 真机上 `floaty.window(xml)` 返回一个已经挂在 WindowManager 上的窗口对象, * `findView(id)` 从它膨胀出来的视图树里取视图。桩只需要两件事: * · `findView()` 给回一个可用的 FrameLayout(产物只在里面 add/remove 子视图) * · 记录 setSize/setPosition 的入参 —— 悬浮球的坐标公式算错过两次(dp/px 混用、 * 漏算根视图内边距),有了这份记录,离线就能断言"球应该落在哪"。 */ class MockFloatyWindow { constructor() { this.root = new MockFrameLayout(); this.width = 0; this.height = 0; this.x = 0; this.y = 0; this.closed = false; this.focusDisabled = false; this.adjustEnabled = true; } findView() { return this.root; } setSize(w, h) { this.width = w; this.height = h; return this; } setPosition(x, y) { this.x = x; this.y = y; return this; } setAdjustEnabled(v) { this.adjustEnabled = !!v; return this; } disableFocus() { this.focusDisabled = true; return this; } close() { this.closed = true; return this; } getWidth() { return this.width; } getHeight() { return this.height; } getX() { return this.x; } getY() { return this.y; } } MockLinearLayout.LayoutParams = MockLayoutParams; MockFrameLayout.LayoutParams = MockLayoutParams; class MockGradientDrawable { constructor() { this.colors = null; this.color = null; this.radius = 0; this.stroke = null; } setShape(s) { this.shape = s; } setColor(c) { this.color = c; } setCornerRadius(r) { this.radius = r; } setStroke(w, c) { this.stroke = { width: w, color: c }; } setOrientation(o) { this.orientation = o; } setColors(list) { this.colors = list; } setGradientType(t) { this.gradientType = t; } setGradientRadius(r) { this.gradientRadius = r; } /* 登录按钮的「校验中」态靠整体降透明度表达 */ setAlpha(a) { this.alpha = a; } } /* GradientDrawable 的静态常量:产物在代码里设置形状与渐变方向时会读它们 */ MockGradientDrawable.RECTANGLE = 0; MockGradientDrawable.OVAL = 1; MockGradientDrawable.LINE = 2; MockGradientDrawable.LINEAR_GRADIENT = 0; MockGradientDrawable.RADIAL_GRADIENT = 1; MockGradientDrawable.SWEEP_GRADIENT = 2; MockGradientDrawable.Orientation = { LEFT_RIGHT: 0, RIGHT_LEFT: 1, TOP_BOTTOM: 2, BOTTOM_TOP: 3, TL_BR: 4, BL_TR: 5 }; class MockIntent { constructor(action, uri) { this.action = action; this.uri = uri || null; this.extras = {}; } setData(uri) { this.uri = uri; return this; } putExtra(k, v) { this.extras[k] = v; return this; } addFlags() { return this; } } /* 真机上的值,桩里保持同一个数字:断言能对得上, * 也不会因为桩漏了常量而静默变成 undefined(那会让"拉回前台"悄悄失效)。 */ MockIntent.FLAG_ACTIVITY_REORDER_TO_FRONT = 0x20000000; class MockUri { static parse(s) { return { raw: s }; } } class MockAdapterListener { constructor(obj) { Object.assign(this, obj || {}); } } /** * Java 接口桩:真机上 `new Interface({...})` 走的是 Rhino 的 JavaAdapter, * 拿到的是一个实现了该接口的对象。桩里直接把回调表原样返回 —— * 只要「回调能被存下来并在对的时机被调用」,被测代码的行为就与真机一致。 */ MockView.OnFocusChangeListener = function (handlers) { return handlers; }; MockView.OnClickListener = function (handlers) { return handlers; }; MockView.OnKeyListener = function (handlers) { return handlers; }; /* 悬浮球的球体靠它收手势(单击 / 拖动 / 取消),必须能构造 */ MockView.OnTouchListener = function (handlers) { return handlers; }; MockView.OnLayoutChangeListener = function (handlers) { return handlers; }; MockView.OnPreDrawListener = function (handlers) { return handlers; }; /* ------------------------------------------------------------ 状态容器 */ function install() { const android = { graphics: { drawable: { GradientDrawable: MockGradientDrawable }, Typeface: { DEFAULT_BOLD: 'BOLD', MONOSPACE: 'MONO', DEFAULT: 'DEFAULT' }, PixelFormat: { OPAQUE: -1 } }, widget: { TextView: MockTextView, EditText: MockEditText, ScrollView: MockScrollView, LinearLayout: MockLinearLayout, FrameLayout: MockFrameLayout, ImageView: MockImageView, AdapterView: { OnItemSelectedListener: MockAdapterListener } }, view: { View: MockView, Gravity: { CENTER: 17, CENTER_HORIZONTAL: 1, CENTER_VERTICAL: 16, TOP: 48, LEFT: 3, RIGHT: 5, END: 8388613 }, KeyEvent: { KEYCODE_BACK: 4 }, MotionEvent: MockMotionEvent, /* 注意必须是 function 而不是箭头函数:产物里是 `new android.view.ViewTreeObserver.OnPreDrawListener({...})` */ ViewTreeObserver: { OnPreDrawListener: function (h) { return h; } }, WindowManager: { LayoutParams: MockWMLayoutParams }, inputmethod: { EditorInfo: { IME_ACTION_DONE: 6, IME_ACTION_NEXT: 5 } } }, text: { TextUtils: { TruncateAt: { START: 'START', END: 'END', MIDDLE: 'MIDDLE' } }, InputType: { TYPE_CLASS_TEXT: 1, TYPE_CLASS_NUMBER: 2, TYPE_NUMBER_FLAG_DECIMAL: 8192 }, TextWatcher: function (handlers) { return handlers; } }, content: { Intent: MockIntent, Context: { MODE_PRIVATE: 0 }, pm: { PackageManager: { PERMISSION_GRANTED: 0, PERMISSION_DENIED: -1 } } }, net: { Uri: MockUri }, os: { Build: { MODEL: 'SmokeDevice', VERSION: { RELEASE: '14', SDK: '34' } } } }; const memoryStorage = {}; const memoryFiles = {}; const mounted = { root: null }; const logLines = []; /* 计时器: * · setTimeout 立即执行 —— 登录流程用它留一帧给「登录中…」, * 自检里没有理由真的等 450ms; * · setInterval **不自动跑**,只登记句柄(自动跑就是死循环)。 * 需要验证计时逻辑时由测试调用 flushIntervals() 手动推进。 */ const intervals = new Map(); let intervalSeq = 0; const wmWindows = []; const wmStub = { addView: (view, lp) => { view.setLayoutParams(lp); wmWindows.push({ view: view, lp: lp }); }, updateViewLayout: (view, lp) => { view.setLayoutParams(lp); }, removeView: (view) => { const i = wmWindows.findIndex((w) => w.view === view); if (i >= 0) { wmWindows.splice(i, 1); } } }; const context = { getResources: () => ({ getDisplayMetrics: () => ({ density: 2.75 }), getIdentifier: () => 0, getDimensionPixelSize: () => 66 }), getPackageName: () => 'org.autojs.autoxjs.v7', getPackageManager: () => ({ getPackageInfo: (name) => { /* 让 TikTok 主包与 AutoX 自身"存在", * 既验证 device.js 的多包探测与回退,也覆盖 appVersionName 的成功路径 */ if (name === 'com.zhiliaoapp.musically') { return { versionName: '39.5.4' }; } if (name === 'org.autojs.autoxjs.v7') { return { versionName: '9.3.11' }; } throw new Error('NameNotFoundException: ' + name); } }), /* 悬浮球走裸 WindowManager(不用 floaty),桩里给它一个可观测的实现: * 记录加过哪些窗口、它们的 LayoutParams,方便断言「两个窗口、尺寸恒定、触摸开关」。 */ getSystemService: (name) => (name === 'window' ? wmStub : null), /* 通知权限桩成「未授予」:Android 13+ 才走这条分支, * 而这正是「通知标可选」那段逻辑唯一的触发条件 */ checkSelfPermission: () => -1, startActivity: () => { } }; const g = globalThis; g.android = android; g.context = context; g.device = { width: 1080, height: 1920 }; g.auto = { service: null }; /* 悬浮球的三件套:floaty(开窗)/ images(品牌图)/ MotionEvent(手势)。 * 缺任意一个,悬浮球在离线自检里都会"静默不建"(产物把创建包在 try 里), * 于是整块功能零覆盖 —— 而这正是「扇形按钮点了没反应」溜到真机的原因。 */ const floatyState = { windows: [] }; g.floaty = { checkPermission: () => true, window: () => { const w = new MockFloatyWindow(); floatyState.windows.push(w); return w; }, closeAll: () => { } }; g.images = { read: () => ({ bitmap: { stub: true } }), release: () => { } }; g.sleep = () => { }; g.toast = () => { }; g.app = { viewFile: () => { }, /* 「拉回前台」靠它。桩里记下来,自检据此断言确实按 REORDER_TO_FRONT 发的 Intent。 */ startActivity: (intent) => { g.app.lastIntent = intent; }, /** 「切回 TikTok」靠它 */ launchPackage: (name) => { g.app.lastLaunchedPackage = name; } }; /* UI 模式下当前 Activity(拉回前台时用它取类名) */ g.activity = { getClass: () => ({ getName: () => 'org.autojs.autojs.ui.script.ScriptExecuteActivity' }) }; g.threads = { start: (fn) => { fn(); }, shutDownAll: () => { } }; g.setTimeout = (fn) => { fn(); return 0; }; g.setInterval = (fn) => { intervalSeq += 1; intervals.set(intervalSeq, fn); return intervalSeq; }; g.clearInterval = (handle) => { intervals.delete(handle); }; /* clearTimeout 必须有:状态切换靠"等布局落地 + 兜底定时器", * 落地时要把兜底那个清掉(真机上是真清,桩里立即执行所以本来就是空操作) */ g.clearTimeout = () => { }; /* 事件登记表:自检可以手动触发 "resume" 之类的生命周期事件 */ const uiEvents = {}; g.ui = { statusBarColor: () => { }, setContentView: (v) => { mounted.root = v; }, run: (fn) => fn(), post: (fn) => fn(), emitter: { on: (name, fn) => { uiEvents[name] = fn; }, emit: (name, payload) => { if (uiEvents[name]) { uiEvents[name](payload); } } } }; g.storages = { create: (name) => ({ get: (k, d) => { const bucket = memoryStorage[name] || {}; return Object.prototype.hasOwnProperty.call(bucket, k) ? bucket[k] : d; }, put: (k, v) => { memoryStorage[name] = memoryStorage[name] || {}; memoryStorage[name][k] = v; }, remove: (k) => { if (memoryStorage[name]) { delete memoryStorage[name][k]; } } }) }; /* 文件读写走内存,绝不碰真实磁盘——自检不应该有副作用 */ g.files = { write: (p, c) => { memoryFiles[p] = String(c); }, read: (p) => (Object.prototype.hasOwnProperty.call(memoryFiles, p) ? memoryFiles[p] : ''), exists: (p) => Object.prototype.hasOwnProperty.call(memoryFiles, p), listDir: () => [], isDir: () => false, ensureDir: () => { } }; /* ------------------------------------------------------------ 引擎桩 * * 入口脚本与产物路径固定下来,让 Engine.locate() 稳定命中。 * 路径刻意做成「项目目录 + 相对位置」,与真机的项目模式一致 —— * 于是"从 myEngine().source 推出同级目录"这条推导逻辑也被测到, * 而不是只测"某个写死的路径存在"。 */ const ENTRY_SOURCE = '/sdcard/Scripts/demo/main.js'; const ENGINE_SCRIPT = '/sdcard/Scripts/demo/tiktok-auto/dist/tiktok-auto.js'; memoryFiles[ENGINE_SCRIPT] = '/* 引擎产物(桩) */'; const engineState = { /** execScriptFile 的调用记录 —— 自检据此断言参数传对了 */ spawned: [], /** 除宿主之外的引擎。清空即模拟「子引擎已退出」 */ children: [{ id: 'child-1' }] }; g.engines = { myEngine: () => ({ id: 'host-1', source: ENTRY_SOURCE, execArgv: {} }), all: () => [{ id: 'host-1' }].concat(engineState.children), execScriptFile: (scriptPath, config) => { engineState.spawned.push({ path: scriptPath, config: config }); /* ★ 真机的 `ScriptExecution.getEngine()` 会返回子引擎对象(也可能是 null, * 取决于子引擎建好了没)。这里返回 `children[0]` 是为了让 * `Engine.childId` 精确匹配那条路径被测到 —— 否则永远走"任意第二个引擎" * 的宽口径,精确匹配等于没测。 */ return { getEngine: () => engineState.children[0] || null }; } }; g.dialogs = { select: () => -1, input: () => null, confirm: () => false, alert: () => { } }; /* 桩原样转发 console.log,但留一份副本给自检断言用 */ const originalLog = console.log; g.console = Object.assign({}, console, { log: (...args) => { logLines.push(args.join(' ')); originalLog(...args); } }); return { mounted, memoryStorage, memoryFiles, logLines, /** 手动触发 UI 生命周期事件(如 "resume") */ emitter: g.ui.emitter, /** 已登记的事件名 → 回调,用于断言"确实注册了" */ uiEvents, /** 引擎桩的可变状态:调用记录 + 子引擎列表(清空 children 即模拟引擎退出) */ engineState, ENTRY_SOURCE, ENGINE_SCRIPT, /** 已登记的 interval 回调数量(跑完一次运行后应为 1,停止后应为 0)。 */ intervalCount: () => intervals.size, /** 手动推进一次所有 interval,用于验证运行计时。 */ flushIntervals: () => { intervals.forEach((fn) => fn()); }, /** 已加到 WindowManager 上的窗口(悬浮球现在是两个 overlay 窗口)。 */ wmWindows: () => wmWindows, /** 已创建的 floaty 窗口(历史遗留:浮窗早已不用 floaty)。 */ floatyWindows: () => floatyState.windows, /** 造一个触摸事件(走产物真实的 OnTouchListener 通路)。 */ touchEvent: (action, x, y) => new MockMotionEvent(action, x, y), MotionEvent: MockMotionEvent }; } module.exports = { install };