@skrillex1224/android-toolkit 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import fs from "node:fs";
11
11
  var constants_exports = {};
12
12
  __export(constants_exports, {
13
13
  ActorInfo: () => ActorInfo,
14
+ AppiumSettings: () => AppiumSettings,
14
15
  Code: () => Code,
15
16
  Status: () => Status,
16
17
  UnicodeIme: () => UnicodeIme
@@ -27,7 +28,7 @@ var Code = Object.freeze({
27
28
  ContentUnavailable: 30010003,
28
29
  SourceExtractionFailed: 30010004,
29
30
  AutomationFailed: 30010005,
30
- FridaUnavailable: 30010008,
31
+ DataAccessUnavailable: 30010008,
31
32
  AppNotInstalled: 30010009
32
33
  });
33
34
  var Status = Object.freeze({
@@ -37,7 +38,12 @@ var Status = Object.freeze({
37
38
  var UnicodeIme = Object.freeze({
38
39
  packageName: "io.appium.settings",
39
40
  component: "io.appium.settings/.UnicodeIME",
40
- inputTextAction: "ADB_INPUT_TEXT"
41
+ inputAction: "ADB_INPUT_TEXT"
42
+ });
43
+ var AppiumSettings = Object.freeze({
44
+ packageName: "io.appium.settings",
45
+ clipboardReceiver: "io.appium.settings/.receivers.ClipboardReceiver",
46
+ clipboardGetAction: "io.appium.settings.clipboard.get"
41
47
  });
42
48
  var normalizeShare = (share) => {
43
49
  const source = share && typeof share === "object" ? share : {};
@@ -82,7 +88,6 @@ function createAndroidContext(input = {}, defaults = {}) {
82
88
  serial: firstNonEmpty(defaults.serial, device.serial),
83
89
  packageName: firstNonEmpty(defaults.packageName, device.packageName),
84
90
  adbPath: firstNonEmpty(defaults.adbPath, process.env.ANDROID_TOOLKIT_ADB_PATH),
85
- fridaPath: firstNonEmpty(defaults.fridaPath, process.env.ANDROID_TOOLKIT_FRIDA_PATH),
86
91
  appVersion: firstNonEmpty(defaults.appVersion, device.appVersion),
87
92
  slotKey: firstNonEmpty(defaults.slotKey, device.slotKey),
88
93
  instanceId: firstNonEmpty(defaults.instanceId, device.instanceId),
@@ -268,6 +273,8 @@ var Device = {
268
273
  screenshotPng,
269
274
  screenshotBase64,
270
275
  dumpUiXml,
276
+ readClipboard,
277
+ clearClipboard,
271
278
  wakeAndUnlock,
272
279
  hideKeyboard,
273
280
  isKeyboardVisible,
@@ -399,10 +406,12 @@ async function pressEnter(ctx) {
399
406
  }
400
407
  async function typeText(ctx, text2) {
401
408
  await ensureUnicodeIme(ctx);
402
- await adbShell(ctx, ["am", "broadcast", "-a", UnicodeIme.inputTextAction, "--es", "msg", String(text2 ?? "")], {
403
- timeoutMs: 3e4,
404
- maxBuffer: 4 * 1024 * 1024
409
+ const value = String(text2 ?? "");
410
+ if (!value) return;
411
+ await adbShell(ctx, ["input", "text", shellSingleQuote(encodeUnicodeImeText(value))], {
412
+ timeoutMs: Math.max(15e3, value.length * 300)
405
413
  });
414
+ await sleep(1200);
406
415
  }
407
416
  async function ensureUnicodeIme(ctx) {
408
417
  const out = await adbShell(ctx, ["ime", "list", "-s"], { timeoutMs: 15e3 });
@@ -428,26 +437,71 @@ async function screenshotPng(ctx) {
428
437
  async function screenshotBase64(ctx) {
429
438
  return `data:image/png;base64,${(await screenshotPng(ctx)).toString("base64")}`;
430
439
  }
431
- async function dumpUiXml(ctx) {
432
- const dumpPath = `/data/local/tmp/android-toolkit-window-${Date.now()}-${Math.random().toString(16).slice(2)}.xml`;
433
- await adbShell(ctx, ["rm", "-f", dumpPath], { timeoutMs: 1e4 }).catch(() => {
434
- });
435
- await adbShell(ctx, ["uiautomator", "dump", dumpPath], {
436
- timeoutMs: 3e4,
437
- maxBuffer: 4 * 1024 * 1024
440
+ async function readClipboard(ctx) {
441
+ const out = await adbShell(ctx, [
442
+ "am",
443
+ "broadcast",
444
+ "-n",
445
+ AppiumSettings.clipboardReceiver,
446
+ "-a",
447
+ AppiumSettings.clipboardGetAction
448
+ ], {
449
+ timeoutMs: 8e3,
450
+ maxBuffer: 1024 * 1024
451
+ }).catch((error) => {
452
+ throw new CrawlerError({
453
+ message: `source_extraction_failed: Appium Settings clipboard receiver unavailable ${error?.message || String(error)}`,
454
+ code: Code.SourceExtractionFailed,
455
+ context: { receiver: AppiumSettings.clipboardReceiver }
456
+ });
438
457
  });
439
- try {
440
- const raw = await adbShell(ctx, ["cat", dumpPath], {
441
- timeoutMs: 3e4,
442
- maxBuffer: 16 * 1024 * 1024
458
+ const text2 = normalizeAppiumClipboardBroadcast(out);
459
+ if (!text2) {
460
+ throw new CrawlerError({
461
+ message: "source_extraction_failed: ADB clipboard is empty",
462
+ code: Code.SourceExtractionFailed,
463
+ context: { receiver: AppiumSettings.clipboardReceiver, output: String(out || "").slice(0, 1e3) }
443
464
  });
444
- const start = String(raw || "").indexOf("<?xml");
445
- if (start < 0) throw new CrawlerError({ message: `automation_failed: uiautomator dump missing xml: ${raw}`, code: Code.AutomationFailed });
446
- return String(raw || "").slice(start).trim();
447
- } finally {
465
+ }
466
+ return text2;
467
+ }
468
+ async function clearClipboard(ctx) {
469
+ Logger.info("Device.clearClipboard skipped", {
470
+ reason: "Android shell clipboard set is not portable; captureLink validates by link change."
471
+ });
472
+ }
473
+ async function dumpUiXml(ctx) {
474
+ let lastError = null;
475
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
476
+ const dumpPath = `/data/local/tmp/android-toolkit-window-${Date.now()}-${Math.random().toString(16).slice(2)}.xml`;
448
477
  await adbShell(ctx, ["rm", "-f", dumpPath], { timeoutMs: 1e4 }).catch(() => {
449
478
  });
479
+ try {
480
+ await adbShell(ctx, ["uiautomator", "dump", dumpPath], {
481
+ timeoutMs: 3e4,
482
+ maxBuffer: 4 * 1024 * 1024
483
+ });
484
+ const raw = await adbShell(ctx, ["cat", dumpPath], {
485
+ timeoutMs: 3e4,
486
+ maxBuffer: 16 * 1024 * 1024
487
+ });
488
+ const start = String(raw || "").indexOf("<?xml");
489
+ if (start < 0) throw new CrawlerError({ message: `automation_failed: uiautomator dump missing xml: ${raw}`, code: Code.AutomationFailed });
490
+ return String(raw || "").slice(start).trim();
491
+ } catch (error) {
492
+ lastError = error;
493
+ Logger.warn("uiautomator dump retry", { attempt, message: error?.message || String(error) });
494
+ await sleep(350 * attempt);
495
+ } finally {
496
+ await adbShell(ctx, ["rm", "-f", dumpPath], { timeoutMs: 1e4 }).catch(() => {
497
+ });
498
+ }
450
499
  }
500
+ throw CrawlerError.isCrawlerError(lastError) ? lastError : new CrawlerError({
501
+ message: `automation_failed: uiautomator dump failed ${lastError?.message || String(lastError || "")}`.trim(),
502
+ code: Code.AutomationFailed,
503
+ context: { lastError: lastError?.message || String(lastError || "") }
504
+ });
451
505
  }
452
506
  async function wakeAndUnlock(ctx) {
453
507
  await pressKey(ctx, "KEYCODE_WAKEUP").catch(() => {
@@ -485,11 +539,51 @@ function requireValue(value, name) {
485
539
  }
486
540
  function isAdbUnavailableError(error) {
487
541
  const message = String(error?.message || error || "");
488
- return /ENOENT|spawn .*adb|not found|No such file|device .* not found|no devices|offline/i.test(message);
542
+ return /ENOENT|spawn .*adb|device .* not found|no devices|offline/i.test(message);
489
543
  }
490
544
  function compactArgs(args) {
491
545
  return args.map(String).join(" ").slice(0, 500);
492
546
  }
547
+ function encodeUnicodeImeText(text2) {
548
+ let out = "";
549
+ let buffer = "";
550
+ const flush = () => {
551
+ if (!buffer) return;
552
+ const bytes = Buffer.alloc(buffer.length * 2);
553
+ for (let index = 0; index < buffer.length; index += 1) {
554
+ bytes.writeUInt16BE(buffer.charCodeAt(index), index * 2);
555
+ }
556
+ out += `&${bytes.toString("base64").replace(/\//g, ",").replace(/=+$/g, "")}-`;
557
+ buffer = "";
558
+ };
559
+ for (const char of String(text2 || "")) {
560
+ const code = char.charCodeAt(0);
561
+ if (code >= 32 && code <= 126) {
562
+ flush();
563
+ out += char === "&" ? "&-" : char;
564
+ } else {
565
+ buffer += char;
566
+ }
567
+ }
568
+ flush();
569
+ return out;
570
+ }
571
+ function shellSingleQuote(value) {
572
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
573
+ }
574
+ function normalizeAppiumClipboardBroadcast(value) {
575
+ const out = String(value || "");
576
+ if (!/Broadcast completed:\s*result=-1\b/.test(out)) return "";
577
+ const match = /data="([^"]*)"/.exec(out) || /data=([^\s]+)/.exec(out);
578
+ const base64 = match ? String(match[1] || "").trim() : "";
579
+ if (!base64) return "";
580
+ try {
581
+ return Buffer.from(base64, "base64").toString("utf8").trim();
582
+ } catch {
583
+ return "";
584
+ }
585
+ }
586
+ var $DeviceInternalsForTest = Object.freeze({});
493
587
 
494
588
  // src/apify-kit.js
495
589
  var instance = null;
@@ -682,16 +776,19 @@ async function waitFor(ctx, selector, options = {}) {
682
776
  const intervalMs = Number(options.intervalMs || options.pollIntervalMs || 500);
683
777
  const deadline = Date.now() + timeoutMs;
684
778
  let lastError = null;
779
+ let sawCleanPoll = false;
685
780
  while (Date.now() < deadline) {
686
781
  try {
687
782
  const node = await find(ctx, selector, { ...options, optional: true });
783
+ sawCleanPoll = true;
784
+ lastError = null;
688
785
  if (node) return node;
689
786
  } catch (error) {
690
787
  lastError = error;
691
788
  }
692
789
  await sleep(intervalMs);
693
790
  }
694
- if (lastError) throw lastError;
791
+ if (lastError && !sawCleanPoll) throw lastError;
695
792
  throw new CrawlerError({
696
793
  message: `automation_failed: \u7B49\u5F85 View \u8D85\u65F6 ${selectorLabel(selector)}`,
697
794
  code: Code.AutomationFailed,
@@ -878,9 +975,27 @@ async function click(ctx, selectorOrPoint, options = {}) {
878
975
  return { target, actual, point };
879
976
  }
880
977
  async function fill(ctx, selector, text2, options = {}) {
881
- await click(ctx, selector, options);
882
- await Device.typeText(ctx, text2);
978
+ const value = String(text2 ?? "");
979
+ const clicked = await click(ctx, selector, {
980
+ ...options,
981
+ settleMs: Number(options.focusSettleMs || 350)
982
+ });
983
+ try {
984
+ await Device.typeText(ctx, value);
985
+ } catch (error) {
986
+ throw new CrawlerError({
987
+ message: `automation_failed: View \u5199\u5165\u6587\u672C\u5931\u8D25 ${JSON.stringify(selector)}`,
988
+ code: Code.AutomationFailed,
989
+ context: { selector, error: error?.message || String(error) }
990
+ });
991
+ }
992
+ Logger.info("DeviceInput.fill", {
993
+ selector,
994
+ target: simplifyNode(clicked.actual || clicked.target),
995
+ chars: value.length
996
+ });
883
997
  await sleep(Number(options.settleMs || 350));
998
+ return { target: clicked.actual || clicked.target, chars: value.length };
884
999
  }
885
1000
  async function press(ctx, key) {
886
1001
  await Device.pressKey(ctx, key);
@@ -968,160 +1083,18 @@ function simplifyNode(node) {
968
1083
  };
969
1084
  }
970
1085
 
971
- // src/internals/frida-script.js
972
- import { spawn } from "node:child_process";
973
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1086
+ // src/device-sqlite.js
1087
+ import { execFile as execFile2 } from "node:child_process";
1088
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
974
1089
  import { tmpdir } from "node:os";
975
1090
  import path from "node:path";
976
- async function runFridaScriptInternal(ctx, source, options = {}) {
977
- await assertFridaReady(ctx);
978
- const label = String(options.label || "frida-script");
979
- const marker = String(options.marker || "ANDROID_TOOLKIT_SCRIPT_JSON ");
980
- const pid = await resolvePid(ctx, options.packageName || ctx.packageName);
981
- const scriptPath = writeTempScript(source, label);
982
- const args = [
983
- "-q",
984
- "-t",
985
- String(Number(options.fridaTimeoutSeconds || 8)),
986
- "-D",
987
- ctx.serial,
988
- "-p",
989
- pid,
990
- "-l",
991
- scriptPath
992
- ];
993
- Logger.info("frida script start", { label, pid, timeoutMs: Number(options.timeoutMs || 12e3) });
994
- try {
995
- return await runFridaProcess(ctx.fridaPath, args, {
996
- marker,
997
- timeoutMs: Number(options.timeoutMs || 12e3),
998
- maxLines: Number(options.maxLines || 1500)
999
- });
1000
- } finally {
1001
- rmSync(path.dirname(scriptPath), { recursive: true, force: true });
1002
- }
1003
- }
1004
- async function resolveFridaPid(ctx, packageName = ctx.packageName) {
1005
- return resolvePid(ctx, packageName);
1006
- }
1007
- async function assertFridaReady(ctx) {
1008
- if (!ctx?.fridaPath) {
1009
- throw new CrawlerError({
1010
- message: "frida_unavailable: ANDROID_TOOLKIT_FRIDA_PATH is required",
1011
- code: Code.FridaUnavailable
1012
- });
1013
- }
1014
- if (!ctx?.serial) {
1015
- throw new CrawlerError({
1016
- message: "frida_unavailable: device serial is required",
1017
- code: Code.FridaUnavailable
1018
- });
1019
- }
1020
- }
1021
- async function resolvePid(ctx, packageName = ctx.packageName) {
1022
- const target = String(packageName || "").trim();
1023
- if (!target) {
1024
- throw new CrawlerError({
1025
- message: "frida_unavailable: packageName is required",
1026
- code: Code.FridaUnavailable
1027
- });
1028
- }
1029
- const out = await Device.adbShell(ctx, ["pidof", target], { timeoutMs: 8e3 }).catch(() => "");
1030
- const pid = String(out || "").trim().split(/\s+/).find(Boolean);
1031
- if (!pid) {
1032
- throw new CrawlerError({
1033
- message: `frida_unavailable: target app pid not found ${target}`,
1034
- code: Code.FridaUnavailable,
1035
- context: { packageName: target }
1036
- });
1037
- }
1038
- return pid;
1039
- }
1040
- function runFridaProcess(fridaPath, args, options) {
1041
- return new Promise((resolve, reject) => {
1042
- const events = [];
1043
- const lines = [];
1044
- let buffer = "";
1045
- let finished = false;
1046
- const child = spawn(fridaPath, args, { stdio: ["ignore", "pipe", "pipe"] });
1047
- const timer = setTimeout(() => {
1048
- if (finished) return;
1049
- finished = true;
1050
- child.kill("SIGTERM");
1051
- reject(new CrawlerError({
1052
- message: `frida_unavailable: frida script timeout ${options.timeoutMs}ms`,
1053
- code: Code.FridaUnavailable,
1054
- context: { lines: lines.slice(-40) }
1055
- }));
1056
- }, options.timeoutMs);
1057
- const consumeLine = (line) => {
1058
- const text2 = String(line || "").trim();
1059
- if (!text2) return;
1060
- lines.push(text2);
1061
- if (lines.length > options.maxLines) lines.shift();
1062
- const markerIndex = text2.indexOf(options.marker);
1063
- if (markerIndex < 0) return;
1064
- const jsonText = text2.slice(markerIndex + options.marker.length).trim();
1065
- try {
1066
- events.push(JSON.parse(jsonText));
1067
- } catch (error) {
1068
- events.push({ type: "parse_error", error: error?.message || String(error), line: text2 });
1069
- }
1070
- };
1071
- const onChunk = (chunk) => {
1072
- buffer += chunk.toString("utf8");
1073
- let index = buffer.indexOf("\n");
1074
- while (index >= 0) {
1075
- consumeLine(buffer.slice(0, index));
1076
- buffer = buffer.slice(index + 1);
1077
- index = buffer.indexOf("\n");
1078
- }
1079
- };
1080
- child.stdout.on("data", onChunk);
1081
- child.stderr.on("data", onChunk);
1082
- child.on("error", (error) => {
1083
- if (finished) return;
1084
- finished = true;
1085
- clearTimeout(timer);
1086
- reject(new CrawlerError({
1087
- message: `frida_unavailable: ${error?.message || String(error)}`,
1088
- code: Code.FridaUnavailable
1089
- }));
1090
- });
1091
- child.on("close", () => {
1092
- if (finished) return;
1093
- finished = true;
1094
- clearTimeout(timer);
1095
- if (buffer.trim()) consumeLine(buffer);
1096
- const parsed = events.filter((event) => event?.type !== "parse_error");
1097
- if (parsed.length === 0) {
1098
- reject(new CrawlerError({
1099
- message: "frida_unavailable: script emitted no event",
1100
- code: Code.FridaUnavailable,
1101
- context: { lines: lines.slice(-40), events }
1102
- }));
1103
- return;
1104
- }
1105
- resolve(parsed.at(-1));
1106
- });
1107
- });
1108
- }
1109
- function writeTempScript(source, label) {
1110
- const dir = mkdtempSync(path.join(tmpdir(), `android-toolkit-${safeName(label)}-`));
1111
- const scriptPath = path.join(dir, "script.js");
1112
- writeFileSync(scriptPath, String(source || ""), "utf8");
1113
- return scriptPath;
1114
- }
1115
- function safeName(value) {
1116
- return String(value || "script").replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80);
1117
- }
1118
-
1119
- // src/frida-client.js
1120
- var Frida = {
1121
- querySQLite,
1091
+ import { promisify as promisify2 } from "node:util";
1092
+ var execFileAsync2 = promisify2(execFile2);
1093
+ var DeviceSQLite = {
1094
+ query,
1122
1095
  health
1123
1096
  };
1124
- async function querySQLite(ctx, options = {}) {
1097
+ async function query(ctx, options = {}) {
1125
1098
  if (!options.sql) {
1126
1099
  throw new CrawlerError({
1127
1100
  message: "invalid_request: sql is required",
@@ -1136,115 +1109,183 @@ async function querySQLite(ctx, options = {}) {
1136
1109
  dbNameExcludes: normalizeStringArray(options.dbNameExcludes),
1137
1110
  sql: String(options.sql || ""),
1138
1111
  args: normalizeStringArray(options.args),
1139
- maxRows: Math.max(1, Number(options.maxRows || 200))
1112
+ maxRows: Math.max(1, Number(options.maxRows || 200)),
1113
+ label: String(options.label || "query-sqlite")
1140
1114
  };
1141
- const event = await runFridaScriptInternal(ctx, sqliteScript(config), {
1142
- label: options.label || "query-sqlite",
1143
- packageName: options.packageName || ctx.packageName,
1144
- timeoutMs: options.timeoutMs || 3e4,
1145
- fridaTimeoutSeconds: options.fridaTimeoutSeconds || 25,
1146
- maxLines: options.maxLines || 4e3
1147
- });
1148
- if (!event.ok) {
1115
+ if (!config.dbPath && !config.dbDir) {
1149
1116
  throw new CrawlerError({
1150
- message: `content_unavailable: sqlite query failed ${event.error || ""}`.trim(),
1151
- code: Code.ContentUnavailable,
1152
- context: event
1117
+ message: "invalid_request: dbPath or dbDir is required",
1118
+ code: Code.InvalidRequest
1119
+ });
1120
+ }
1121
+ const startedAt = Date.now();
1122
+ const dir = await mkdtemp(path.join(tmpdir(), "android-toolkit-sqlite-"));
1123
+ try {
1124
+ const dbPaths = await resolveDeviceDbPaths(ctx, config);
1125
+ if (dbPaths.length === 0) {
1126
+ throw new CrawlerError({
1127
+ message: "data_access_unavailable: sqlite database not found",
1128
+ code: Code.DataAccessUnavailable,
1129
+ context: { dbDir: config.dbDir, dbPath: config.dbPath, dbNamePrefix: config.dbNamePrefix }
1130
+ });
1131
+ }
1132
+ const databases = [];
1133
+ for (const dbPath of dbPaths) {
1134
+ const localDbPath = await pullDatabaseSnapshot(ctx, dbPath, dir);
1135
+ const result = await queryLocalSQLite(localDbPath, config);
1136
+ databases.push({
1137
+ dbPath,
1138
+ columns: result.columns,
1139
+ rows: result.rows,
1140
+ rowCount: result.rows.length,
1141
+ truncated: result.truncated
1142
+ });
1143
+ }
1144
+ Logger.info("DeviceSQLite.query", {
1145
+ label: config.label,
1146
+ databaseCount: databases.length,
1147
+ rowCount: databases.reduce((total, item) => total + item.rowCount, 0),
1148
+ duration: Logger.duration(startedAt)
1149
+ });
1150
+ return { ok: true, databases, rows: databases.length === 1 ? databases[0].rows : [] };
1151
+ } finally {
1152
+ await rm(dir, { recursive: true, force: true }).catch(() => {
1153
1153
  });
1154
1154
  }
1155
- return event;
1156
1155
  }
1157
- async function health(ctx) {
1158
- if (!ctx?.fridaPath) {
1156
+ async function health(ctx, options = {}) {
1157
+ const packageName = String(options.packageName || ctx?.packageName || "").trim();
1158
+ if (!packageName) {
1159
1159
  throw new CrawlerError({
1160
- message: "frida_unavailable: ANDROID_TOOLKIT_FRIDA_PATH is required",
1161
- code: Code.FridaUnavailable
1160
+ message: "invalid_request: packageName is required",
1161
+ code: Code.InvalidRequest
1162
1162
  });
1163
1163
  }
1164
- if (!ctx?.serial) {
1164
+ await adbSuShell(ctx, `test -d ${shellQuote(`/data/data/${packageName}`)}`, {
1165
+ timeoutMs: Number(options.timeoutMs || 1e4)
1166
+ });
1167
+ return { ok: true, packageName };
1168
+ }
1169
+ async function resolveDeviceDbPaths(ctx, config) {
1170
+ if (config.dbPath) return [config.dbPath];
1171
+ const command = `cd ${shellQuote(config.dbDir)} || exit 1; for file in *; do [ -f "$file" ] || continue; case "$file" in *-wal|*-shm) continue ;; esac; printf "%s\\n" "$file"; done`;
1172
+ const out = await adbSuShell(ctx, command, {
1173
+ timeoutMs: 15e3,
1174
+ maxBuffer: 2 * 1024 * 1024
1175
+ }).catch((error) => {
1165
1176
  throw new CrawlerError({
1166
- message: "frida_unavailable: device serial is required",
1167
- code: Code.FridaUnavailable
1177
+ message: `data_access_unavailable: sqlite dbDir unavailable ${error?.message || String(error)}`,
1178
+ code: Code.DataAccessUnavailable,
1179
+ context: { dbDir: config.dbDir }
1168
1180
  });
1169
- }
1170
- const pid = await resolveFridaPid(ctx, ctx.packageName);
1171
- return { ok: true, pid };
1172
- }
1173
- function sqliteScript(config) {
1174
- return `
1175
- Java.perform(function () {
1176
- function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
1177
- var config = ${JSON.stringify(config)};
1178
- var JavaString = Java.use('java.lang.String');
1179
- function safeString(cursor, index) {
1180
- try { return cursor.isNull(index) ? '' : String(cursor.getString(index)); } catch (_) { return ''; }
1181
- }
1182
- function stringArray(values) {
1183
- if (!values || values.length === 0) return null;
1184
- var out = [];
1185
- for (var i = 0; i < values.length; i++) out.push(String(values[i]));
1186
- return Java.array('java.lang.String', out);
1187
- }
1188
- function matchesName(name) {
1189
- if (config.dbNamePrefix && name.indexOf(config.dbNamePrefix) !== 0) return false;
1190
- if (config.dbNameIncludes && name.indexOf(config.dbNameIncludes) < 0) return false;
1191
- for (var i = 0; i < config.dbNameExcludes.length; i++) {
1192
- if (String(name).indexOf(config.dbNameExcludes[i]) >= 0) return false;
1193
- }
1194
- return true;
1195
- }
1196
- function dbPaths() {
1197
- if (config.dbPath) return [config.dbPath];
1198
- var File = Java.use('java.io.File');
1199
- var dir = File.$new(config.dbDir);
1200
- var files = dir.listFiles();
1201
- var out = [];
1202
- if (!files) return out;
1203
- for (var i = 0; i < files.length; i++) {
1204
- var name = String(files[i].getName());
1205
- if (matchesName(name)) out.push(String(files[i].getAbsolutePath()));
1206
- }
1207
- return out;
1208
- }
1209
- function queryOne(dbPath) {
1210
- var SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
1211
- var db = SQLiteDatabase.openDatabase(JavaString.$new(dbPath), null, 1);
1212
- try {
1213
- var cursor = db.rawQuery(JavaString.$new(config.sql), stringArray(config.args));
1214
- var columns = [];
1215
- var columnCount = cursor.getColumnCount();
1216
- for (var c = 0; c < columnCount; c++) columns.push(String(cursor.getColumnName(c)));
1217
- var rows = [];
1218
- var truncated = false;
1219
- while (cursor.moveToNext()) {
1220
- if (rows.length >= config.maxRows) { truncated = true; break; }
1221
- var row = {};
1222
- for (var i = 0; i < columns.length; i++) row[columns[i]] = safeString(cursor, i);
1223
- rows.push(row);
1224
- }
1225
- cursor.close();
1226
- return { dbPath: dbPath, columns: columns, rows: rows, rowCount: rows.length, truncated: truncated };
1227
- } finally {
1228
- db.close();
1229
- }
1230
- }
1181
+ });
1182
+ return String(out || "").split("\n").map((line) => line.trim()).filter(Boolean).filter((name) => matchesName(name, config)).map((name) => `${config.dbDir.replace(/\/+$/, "")}/${name}`).sort();
1183
+ }
1184
+ async function pullDatabaseSnapshot(ctx, dbPath, localDir) {
1185
+ const serial = requireNonEmpty2(ctx?.serial, "serial");
1186
+ const adbPath = requireNonEmpty2(ctx?.adbPath, "adbPath");
1187
+ const baseName = path.basename(dbPath);
1188
+ const remoteDir = `/data/local/tmp/android-toolkit-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1189
+ const remoteDb = `${remoteDir}/${baseName}`;
1190
+ const localDb = path.join(localDir, baseName);
1191
+ const copyCommand = [
1192
+ `rm -rf ${shellQuote(remoteDir)}`,
1193
+ `mkdir -p ${shellQuote(remoteDir)}`,
1194
+ `cp ${shellQuote(dbPath)} ${shellQuote(remoteDb)}`,
1195
+ `[ -f ${shellQuote(`${dbPath}-wal`)} ] && cp ${shellQuote(`${dbPath}-wal`)} ${shellQuote(`${remoteDb}-wal`)} || true`,
1196
+ `[ -f ${shellQuote(`${dbPath}-shm`)} ] && cp ${shellQuote(`${dbPath}-shm`)} ${shellQuote(`${remoteDb}-shm`)} || true`,
1197
+ `chmod 644 ${shellQuote(remoteDir)}/*`
1198
+ ].join(" && ");
1231
1199
  try {
1232
- var paths = dbPaths();
1233
- if (paths.length === 0) { emit({ ok: false, error: 'sqlite database not found', config: config }); return; }
1234
- var databases = [];
1235
- for (var d = 0; d < paths.length; d++) databases.push(queryOne(paths[d]));
1236
- emit({ ok: true, databases: databases, rows: databases.length === 1 ? databases[0].rows : [] });
1237
- } catch (error) {
1238
- emit({ ok: false, error: String(error), stack: String(error.stack || '') });
1200
+ await adbSuShell(ctx, copyCommand, { timeoutMs: 3e4, maxBuffer: 4 * 1024 * 1024 });
1201
+ await adbPull(adbPath, serial, remoteDb, localDb).catch((error) => {
1202
+ throw new CrawlerError({
1203
+ message: `data_access_unavailable: sqlite snapshot pull failed ${error?.message || String(error)}`,
1204
+ code: Code.DataAccessUnavailable,
1205
+ context: { dbPath }
1206
+ });
1207
+ });
1208
+ await adbPullOptional(adbPath, serial, `${remoteDb}-wal`, `${localDb}-wal`);
1209
+ await adbPullOptional(adbPath, serial, `${remoteDb}-shm`, `${localDb}-shm`);
1210
+ return localDb;
1211
+ } finally {
1212
+ await adbSuShell(ctx, `rm -rf ${shellQuote(remoteDir)}`, { timeoutMs: 1e4 }).catch(() => {
1213
+ });
1239
1214
  }
1240
- });
1215
+ }
1216
+ async function adbSuShell(ctx, command, options = {}) {
1217
+ return Device.adbShell(ctx, [`su -c ${shellQuote(command)}`], options);
1218
+ }
1219
+ async function queryLocalSQLite(dbPath, config) {
1220
+ const payloadPath = `${dbPath}.query.json`;
1221
+ const outputPath = `${dbPath}.rows.json`;
1222
+ await writeFile(payloadPath, JSON.stringify({
1223
+ dbPath,
1224
+ sql: config.sql,
1225
+ args: config.args,
1226
+ maxRows: config.maxRows,
1227
+ outputPath
1228
+ }), "utf8");
1229
+ const code = `
1230
+ const fs = require('node:fs');
1231
+ const { DatabaseSync } = require('node:sqlite');
1232
+ const payload = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
1233
+ const db = new DatabaseSync(payload.dbPath, { open: true, readOnly: true });
1234
+ try {
1235
+ const stmt = db.prepare(payload.sql);
1236
+ const columns = stmt.columns().map((column) => String(column.name || ''));
1237
+ const rows = [];
1238
+ let truncated = false;
1239
+ for (const row of stmt.iterate(...payload.args)) {
1240
+ if (rows.length >= payload.maxRows) { truncated = true; break; }
1241
+ const out = {};
1242
+ for (const column of columns) out[column] = row[column] == null ? '' : String(row[column]);
1243
+ rows.push(out);
1244
+ }
1245
+ fs.writeFileSync(payload.outputPath, JSON.stringify({ columns, rows, truncated }), 'utf8');
1246
+ } finally {
1247
+ db.close();
1248
+ }
1241
1249
  `;
1250
+ await execFileAsync2(process.execPath, ["-e", code, payloadPath], {
1251
+ timeout: 3e4,
1252
+ maxBuffer: 4 * 1024 * 1024,
1253
+ encoding: "utf8"
1254
+ });
1255
+ return JSON.parse(await import("node:fs/promises").then((fs3) => fs3.readFile(outputPath, "utf8")));
1256
+ }
1257
+ async function adbPull(adbPath, serial, remotePath, localPath) {
1258
+ await execFileAsync2(adbPath, ["-s", serial, "pull", remotePath, localPath], {
1259
+ timeout: 3e4,
1260
+ maxBuffer: 8 * 1024 * 1024,
1261
+ encoding: "utf8"
1262
+ });
1263
+ }
1264
+ async function adbPullOptional(adbPath, serial, remotePath, localPath) {
1265
+ await adbPull(adbPath, serial, remotePath, localPath).catch(() => {
1266
+ });
1267
+ }
1268
+ function matchesName(name, config) {
1269
+ if (config.dbNamePrefix && !name.startsWith(config.dbNamePrefix)) return false;
1270
+ if (config.dbNameIncludes && !name.includes(config.dbNameIncludes)) return false;
1271
+ for (const item of config.dbNameExcludes || []) {
1272
+ if (item && name.includes(item)) return false;
1273
+ }
1274
+ return true;
1242
1275
  }
1243
1276
  function normalizeStringArray(value) {
1244
1277
  if (Array.isArray(value)) return value.map((item) => String(item ?? ""));
1245
1278
  if (value == null) return [];
1246
1279
  return [String(value)];
1247
1280
  }
1281
+ function requireNonEmpty2(value, name) {
1282
+ const clean = String(value ?? "").trim();
1283
+ if (!clean) throw new CrawlerError({ message: `invalid_request: ${name} is required`, code: Code.InvalidRequest });
1284
+ return clean;
1285
+ }
1286
+ function shellQuote(value) {
1287
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
1288
+ }
1248
1289
 
1249
1290
  // src/launch.js
1250
1291
  import fs2 from "node:fs";
@@ -1538,12 +1579,8 @@ var compressImageBuffer = async (buffer, compression) => {
1538
1579
  let smallest = null;
1539
1580
  for (let attempt = 0; attempt < 12; attempt += 1) {
1540
1581
  const candidate = await encodeJpeg(sourceImage, compression, scale, quality);
1541
- if (!smallest || candidate.bytes < smallest.bytes) {
1542
- smallest = candidate;
1543
- }
1544
- if (candidate.bytes <= compression.maxBytes) {
1545
- return { ...candidate, withinLimit: true };
1546
- }
1582
+ if (!smallest || candidate.bytes < smallest.bytes) smallest = candidate;
1583
+ if (candidate.bytes <= compression.maxBytes) return { ...candidate, withinLimit: true };
1547
1584
  if (quality > minQuality) {
1548
1585
  quality = Math.max(minQuality, Math.floor(quality * 0.75));
1549
1586
  continue;
@@ -1553,9 +1590,7 @@ var compressImageBuffer = async (buffer, compression) => {
1553
1590
  compression.minScale,
1554
1591
  Math.min(scale * 0.85, scale * ratio * 0.94)
1555
1592
  );
1556
- if (nextScale >= scale * 0.99 || scale <= compression.minScale) {
1557
- break;
1558
- }
1593
+ if (nextScale >= scale * 0.99 || scale <= compression.minScale) break;
1559
1594
  scale = nextScale;
1560
1595
  }
1561
1596
  const finalCandidate = await encodeJpeg(sourceImage, compression, compression.minScale, minQuality);
@@ -1571,9 +1606,7 @@ var compressImageBufferToBase64 = async (buffer, compression) => {
1571
1606
  Logger.warn("captureScreen \u538B\u7F29\u5931\u8D25\uFF0C\u8FD4\u56DE\u539F\u56FE", { message: error?.message || String(error) });
1572
1607
  return null;
1573
1608
  });
1574
- if (!result?.buffer) {
1575
- return buffer.toString("base64");
1576
- }
1609
+ if (!result?.buffer) return buffer.toString("base64");
1577
1610
  if (result.withinLimit) {
1578
1611
  Logger.info("captureScreen \u5DF2\u538B\u7F29", {
1579
1612
  originalBytes,
@@ -1661,21 +1694,24 @@ async function captureLink(ctx, options = {}) {
1661
1694
  const deadline = Date.now() + timeoutMs;
1662
1695
  const prefix = String(share.prefix || "").trim();
1663
1696
  Logger.start("Share.captureLink", { actor: actorInfo.key, prefix, timeoutMs });
1664
- await clearClipboard(ctx).catch((error) => {
1665
- Logger.warn("Share.captureLink clearClipboard failed", { message: error?.message || String(error) });
1666
- });
1697
+ const beforeEvent = await readClipboard2(ctx).catch(() => null);
1698
+ const beforeLink = beforeEvent ? selectLink(beforeEvent, prefix) : "";
1699
+ if (beforeLink) Logger.info("Share.captureLink baseline", { beforeLink });
1667
1700
  if (typeof options.performActions === "function") {
1668
1701
  await options.performActions();
1669
1702
  }
1670
1703
  let lastEvent = null;
1671
1704
  while (Date.now() < deadline) {
1672
- const event = await readClipboard(ctx).catch((error) => ({ ok: false, error: error?.message || String(error) }));
1705
+ const event = await readClipboard2(ctx).catch((error) => ({ ok: false, error: error?.message || String(error) }));
1673
1706
  lastEvent = event;
1674
1707
  const link = selectLink(event, prefix);
1675
- if (link) {
1708
+ if (link && link !== beforeLink) {
1676
1709
  Logger.success("Share.captureLink", { link });
1677
1710
  return { link, source: event.source || "clipboard", payloadSnapshot: event.payloadSnapshot || "" };
1678
1711
  }
1712
+ if (link && link === beforeLink) {
1713
+ lastEvent = { ...event, rejected: "same_as_baseline", beforeLink };
1714
+ }
1679
1715
  await sleep(pollIntervalMs);
1680
1716
  }
1681
1717
  throw new CrawlerError({
@@ -1684,77 +1720,20 @@ async function captureLink(ctx, options = {}) {
1684
1720
  context: { prefix, lastEvent }
1685
1721
  });
1686
1722
  }
1687
- async function clearClipboard(ctx) {
1688
- await runFridaScriptInternal(ctx, clipboardScript("clear"), {
1689
- label: "share-clear-clipboard",
1690
- timeoutMs: 8e3,
1691
- fridaTimeoutSeconds: 8
1692
- });
1693
- }
1694
- async function readClipboard(ctx) {
1695
- return runFridaScriptInternal(ctx, clipboardScript("read"), {
1696
- label: "share-read-clipboard",
1697
- timeoutMs: 9e3,
1698
- fridaTimeoutSeconds: 8,
1699
- maxLines: 1500
1700
- });
1701
- }
1702
- function clipboardScript(mode) {
1703
- return `
1704
- Java.perform(function () {
1705
- function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
1706
- var mode = ${JSON.stringify(mode)};
1707
- var JavaObject = Java.use('java.lang.Object');
1708
- function text(value) {
1709
- if (value === null || value === undefined) return '';
1710
- try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) {}
1711
- try { return value.toString.overload().call(value) + ''; } catch (_) {}
1712
- try { return value.toString() + ''; } catch (_) {}
1713
- return '';
1714
- }
1715
- function addCandidate(out, source, value) {
1716
- var s = text(value);
1717
- if (!s) return;
1718
- var matches = s.match(/https?:\\/\\/[^\\s"'<>\uFF0C\u3002]+/g) || [];
1719
- for (var i = 0; i < matches.length; i++) {
1720
- var link = matches[i].replace(/[)\\].,\uFF0C\u3002\uFF1B;!?\uFF01\uFF1F]+$/g, '');
1721
- out.push({ source: source, link: link, payload: s.slice(0, 1000) });
1722
- }
1723
- }
1724
- try {
1725
- var ActivityThread = Java.use('android.app.ActivityThread');
1726
- var ClipData = Java.use('android.content.ClipData');
1727
- var app = ActivityThread.currentApplication();
1728
- var manager = app ? app.getSystemService('clipboard') : null;
1729
- if (!manager) { emit({ ok: false, error: 'clipboard manager unavailable' }); return; }
1730
- if (mode === 'clear') {
1731
- manager.setPrimaryClip(ClipData.newPlainText('android-toolkit', ''));
1732
- emit({ ok: true, source: 'clipboard.clear' });
1733
- return;
1734
- }
1735
- var candidates = [];
1736
- var clip = manager.getPrimaryClip();
1737
- var count = clip ? clip.getItemCount() : 0;
1738
- for (var i = 0; i < count; i++) {
1739
- var item = clip.getItemAt(i);
1740
- try { addCandidate(candidates, 'clipboard.text', item.getText()); } catch (_) {}
1741
- try { addCandidate(candidates, 'clipboard.html', item.getHtmlText()); } catch (_) {}
1742
- try { addCandidate(candidates, 'clipboard.uri', item.getUri()); } catch (_) {}
1743
- try { addCandidate(candidates, 'clipboard.intent', item.getIntent()); } catch (_) {}
1744
- try { addCandidate(candidates, 'clipboard.coerceToText', item.coerceToText(app)); } catch (_) {}
1745
- }
1746
- emit({
1747
- ok: candidates.length > 0,
1748
- link: candidates.length > 0 ? candidates[0].link : '',
1749
- source: candidates.length > 0 ? candidates[0].source : 'clipboard',
1750
- candidates: candidates,
1751
- payloadSnapshot: candidates.length > 0 ? String(candidates[0].payload || '').slice(0, 500) : ''
1752
- });
1753
- } catch (error) {
1754
- emit({ ok: false, error: String(error), stack: String(error.stack || '') });
1755
- }
1756
- });
1757
- `;
1723
+ async function readClipboard2(ctx) {
1724
+ const payload = await Device.readClipboard(ctx);
1725
+ const candidates = extractLinks(payload).map((link) => ({
1726
+ source: "adb.clipboard",
1727
+ link,
1728
+ payload: payload.slice(0, 1e3)
1729
+ }));
1730
+ return {
1731
+ ok: candidates.length > 0,
1732
+ link: candidates[0]?.link || "",
1733
+ source: "adb.clipboard",
1734
+ candidates,
1735
+ payloadSnapshot: payload.slice(0, 500)
1736
+ };
1758
1737
  }
1759
1738
  async function composeSprite(buffers, options = {}) {
1760
1739
  if (!buffers.length) {
@@ -1799,6 +1778,11 @@ function selectLink(event, prefix) {
1799
1778
  }
1800
1779
  return candidates.find((link) => !prefix || String(link).startsWith(prefix)) || "";
1801
1780
  }
1781
+ function extractLinks(value) {
1782
+ const text2 = String(value || "");
1783
+ const matches = text2.match(/https?:\/\/[^\s"'<>,。]+/g) || [];
1784
+ return matches.map((link) => link.replace(/[)\].,,。;;!?!?]+$/g, ""));
1785
+ }
1802
1786
 
1803
1787
  // src/launch.js
1804
1788
  var DEFAULT_INPUT_PATH = "/apify_storage/input.json";
@@ -1827,7 +1811,7 @@ async function run(handler, options = {}) {
1827
1811
  Device,
1828
1812
  DeviceInput,
1829
1813
  DeviceView,
1830
- Frida,
1814
+ DeviceSQLite,
1831
1815
  Share,
1832
1816
  Mutation,
1833
1817
  Logger,
@@ -1865,20 +1849,26 @@ function pathOption(value, fallback) {
1865
1849
  }
1866
1850
 
1867
1851
  // entrys/node.js
1868
- var useAndroidToolKit = () => ({
1869
- Launch,
1870
- ApifyKit,
1871
- DeviceInput,
1872
- DeviceView,
1873
- Device,
1874
- Mutation,
1875
- Share,
1876
- Frida,
1877
- Constants: constants_exports,
1878
- Errors: errors_exports,
1879
- Logger,
1880
- Context
1852
+ var ToolkitMode = Object.freeze({
1853
+ default: "default"
1881
1854
  });
1855
+ var useAndroidToolKit = () => {
1856
+ return {
1857
+ Launch,
1858
+ ApifyKit,
1859
+ DeviceInput,
1860
+ DeviceView,
1861
+ DeviceSQLite,
1862
+ Device,
1863
+ Mutation,
1864
+ Share,
1865
+ Constants: constants_exports,
1866
+ Errors: errors_exports,
1867
+ Logger,
1868
+ Context
1869
+ };
1870
+ };
1871
+ useAndroidToolKit.Mode = ToolkitMode;
1882
1872
  export {
1883
1873
  useAndroidToolKit
1884
1874
  };