codevanta 0.1.1__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
Files changed (42) hide show
  1. codevanta/analysis/adb.py +1358 -0
  2. codevanta/analysis/apple_ips.py +628 -0
  3. codevanta/analysis/asar.py +519 -0
  4. codevanta/analysis/binmeta.py +1520 -0
  5. codevanta/analysis/cdp_client.py +1154 -0
  6. codevanta/analysis/compare.py +738 -0
  7. codevanta/analysis/containers.py +1364 -0
  8. codevanta/analysis/crash_core.py +676 -0
  9. codevanta/analysis/evidence_bundle.py +25 -8
  10. codevanta/analysis/fdt.py +436 -0
  11. codevanta/analysis/nav.py +704 -0
  12. codevanta/analysis/nav_providers.py +172 -0
  13. codevanta/analysis/sigrok_cli_wrap.py +319 -0
  14. codevanta/analysis/sigrok_sr.py +352 -0
  15. codevanta/analysis/solver.py +712 -2
  16. codevanta/analysis/sqlite_view.py +281 -0
  17. codevanta/analysis/uart_decode.py +360 -0
  18. codevanta/cli/containers.py +272 -0
  19. codevanta/cli/crash.py +234 -0
  20. codevanta/cli/main.py +406 -5
  21. codevanta/cli/meta.py +107 -0
  22. codevanta/cli/mobile.py +361 -0
  23. codevanta/cli/nav.py +268 -0
  24. codevanta/cli/signal.py +285 -0
  25. codevanta/cli/web.py +259 -0
  26. codevanta/core/unknowns.py +340 -0
  27. codevanta/mcp_server/ext_bin.py +64 -0
  28. codevanta/mcp_server/ext_crash.py +80 -0
  29. codevanta/mcp_server/ext_meta.py +25 -0
  30. codevanta/mcp_server/ext_mobile.py +65 -0
  31. codevanta/mcp_server/ext_nav.py +214 -0
  32. codevanta/mcp_server/ext_signal.py +91 -0
  33. codevanta/mcp_server/ext_solver.py +130 -0
  34. codevanta/mcp_server/ext_web.py +138 -0
  35. codevanta/mcp_server/server.py +276 -0
  36. codevanta/mcp_server/tools.py +183 -0
  37. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/METADATA +4 -1
  38. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/RECORD +42 -11
  39. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/WHEEL +0 -0
  40. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/entry_points.txt +0 -0
  41. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/licenses/LICENSE +0 -0
  42. {codevanta-0.1.1.dist-info → codevanta-0.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1358 @@
1
+ """Android/ADB lane: pure parsers plus thin, skip-gated live wrappers.
2
+
3
+ This module is the ADB twin of the gdb lane: everything that *understands*
4
+ device output is a pure parser that is fully tested offline against canned
5
+ transcripts, and everything that *touches* a device is a thin wrapper that is
6
+ skip-gated on one ``shutil.which`` probe and shells out with a bounded
7
+ timeout. On a machine without adb -- a Windows workstation, a slim CI
8
+ container -- the parsers still run and the wrappers still import; they only
9
+ ever fail with a typed error naming exactly what is missing.
10
+
11
+ Pure parsers
12
+ ------------
13
+ :func:`parse_devices` reads ``adb devices -l`` output (serial, state, model,
14
+ and an emulator/physical kind), :func:`parse_packages` reads
15
+ ``pm list packages -3`` (name, and the ``base.apk`` path when the ``-f``
16
+ prefixed transcript is fed in), :func:`parse_processes` reads ``ps -A`` in
17
+ both the modern (``VSZ``/``ADDR``/``S``) and legacy (``VSIZE``) column
18
+ layouts, :func:`parse_services` reads ``service list`` lines,
19
+ :func:`parse_getprop` keeps the build-identity whitelist
20
+ (``ro.build.version.*``, ``ro.product.*``, ``ro.build.fingerprint``) from a
21
+ getprop dump in either the bracketed or the plain rendering,
22
+ :func:`parse_dumpsys_package` pulls versionName, installer, firstInstallTime
23
+ and the requested-permission count out of one ``dumpsys package`` transcript,
24
+ and :func:`parse_window_focus` decodes ``mCurrentFocus``/``mFocusedApp`` from
25
+ ``dumpsys window``. Every parser tolerates junk lines as warnings; malformed
26
+ *input to the parser itself* (anything that is not ``str``) raises
27
+ :class:`~codevanta.core.report.AnalysisError`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any, Protocol
40
+
41
+ from codevanta.core.report import AnalysisError
42
+ from codevanta.evidence.confidence import Confidence
43
+ from codevanta.evidence.models import Evidence
44
+
45
+ __all__ = [
46
+ "ADB_MISSING_MESSAGE",
47
+ "AdbBugreportResult",
48
+ "AdbDevice",
49
+ "AdbDevicesResult",
50
+ "AdbInstallResult",
51
+ "AdbPackageEntry",
52
+ "AdbPackageInfoResult",
53
+ "AdbPackagesResult",
54
+ "AdbProcessEntry",
55
+ "AdbProcessesResult",
56
+ "AdbPropsResult",
57
+ "AdbScreencapResult",
58
+ "AdbServiceEntry",
59
+ "AdbServicesResult",
60
+ "AdbWindowFocusResult",
61
+ "DEFAULT_TIMEOUT",
62
+ "MAX_APK_BYTES",
63
+ "MAX_DEVICES",
64
+ "MAX_PACKAGES",
65
+ "MAX_PROCESSES",
66
+ "MAX_PROPS",
67
+ "MAX_SERVICES",
68
+ "MAX_TIMEOUT",
69
+ "MAX_WARNINGS",
70
+ "Runner",
71
+ "RunnerOutcome",
72
+ "adb_available",
73
+ "bugreport",
74
+ "inspect_device",
75
+ "install_apk",
76
+ "list_devices",
77
+ "list_packages",
78
+ "list_processes",
79
+ "parse_devices",
80
+ "parse_dumpsys_package",
81
+ "parse_getprop",
82
+ "parse_packages",
83
+ "parse_processes",
84
+ "parse_services",
85
+ "parse_window_focus",
86
+ "screencap",
87
+ ]
88
+
89
+ #: Devices kept from one ``adb devices -l`` transcript, whatever is attached.
90
+ MAX_DEVICES = 64
91
+
92
+ #: Packages kept from one ``pm list packages -3`` transcript (bounded work).
93
+ MAX_PACKAGES = 256
94
+
95
+ #: Processes kept from one ``ps -A`` transcript (bounded work).
96
+ MAX_PROCESSES = 256
97
+
98
+ #: Services kept from one ``service list`` transcript (bounded work).
99
+ MAX_SERVICES = 256
100
+
101
+ #: Whitelisted properties kept from one ``getprop`` transcript (bounded work).
102
+ MAX_PROPS = 256
103
+
104
+ #: Warnings kept per parse before the rest are summarised away.
105
+ MAX_WARNINGS = 32
106
+
107
+ #: Default wall-clock budget for one adb invocation, in seconds.
108
+ DEFAULT_TIMEOUT = 20.0
109
+
110
+ #: Hard ceiling on the wall-clock budget of one adb invocation. Bugreports on
111
+ #: real hardware regularly exceed the 20 s default; raise ``timeout``
112
+ #: deliberately, never silently.
113
+ MAX_TIMEOUT = 600.0
114
+
115
+ #: Hard ceiling on the size of an APK :func:`install_apk` will accept.
116
+ MAX_APK_BYTES = 4 * 1024 * 1024 * 1024
117
+
118
+ #: Characters a serial may contain (``emulator-5554``, ``127.0.0.1:5555``,
119
+ #: USB serials, product names); anything else is refused before it reaches argv.
120
+ _SERIAL_CHARS = re.compile(r"^[A-Za-z0-9._:-]+$")
121
+
122
+ #: Prefixes of ``adb devices`` chatter that is not a device row.
123
+ _DEVICES_SKIP_PREFIXES = (
124
+ "List of devices attached",
125
+ "* daemon",
126
+ "* server",
127
+ "* failed",
128
+ "- waiting",
129
+ "adb:",
130
+ "restarting in",
131
+ )
132
+
133
+ #: ``package:/data/app/.../base.apk=com.example.app`` (``-f`` transcripts) and
134
+ #: ``package:com.example.app`` (bare ``-3`` transcripts).
135
+ _PACKAGE_LINE = re.compile(r"^package:(?P<rest>.+)$")
136
+
137
+ #: ``[ro.build.version.release]: [14]`` -- the bracketed getprop rendering.
138
+ _PROP_BRACKETED = re.compile(r"^\[(?P<key>[^\]]+)\]:\s*\[(?P<value>.*)\]$")
139
+
140
+ #: ``ActivityRecord{... u0 com.example.app/.MainActivity ...}`` /
141
+ #: ``Window{... u0 com.example.app/com.example.app.MainActivity}``.
142
+ _FOCUS_COMPONENT = re.compile(r"\bu0\s+(?P<component>[^\s{}]+/[^\s{}]+)")
143
+
144
+ #: ``ComponentInfo{com.example.app/com.example.app.MainActivity}``.
145
+ _COMPONENT_INFO = re.compile(r"ComponentInfo\{(?P<component>[^}]+)\}")
146
+
147
+ #: ``Package [com.example.app] (...)`` -- dumpsys package's first line.
148
+ #: ``Package [com.example.app] (...)`` -- dumpsys package's first line.
149
+ _DUMPSYS_PACKAGE_HEADER = re.compile(r"^Package \[(?P<name>[^\]]+)\]")
150
+
151
+
152
+ #: ``service list`` row: ``59 window: [android.view.IWindowManager]``.
153
+ _SERVICE_LINE = re.compile(
154
+ r"^(?P<number>\d+)\s+(?P<name>\S+?):(?:\s+\[(?P<interface>[^\]]*)\])?\s*$"
155
+ )
156
+
157
+ #: Canonical ``ps -A`` column names, from both modern and legacy headers.
158
+ _PS_COLUMNS = {
159
+ "user",
160
+ "pid",
161
+ "ppid",
162
+ "vsz",
163
+ "vsize",
164
+ "rss",
165
+ "wchan",
166
+ "addr",
167
+ "s",
168
+ "name",
169
+ "pcy",
170
+ }
171
+
172
+ #: Evidence ``source`` ids, one per adb command family (``adb.<command>``).
173
+ EVIDENCE_DEVICES = "adb.devices"
174
+ EVIDENCE_GETPROP = "adb.getprop"
175
+ EVIDENCE_PACKAGES = "adb.pm_list_packages"
176
+ EVIDENCE_PROCESSES = "adb.ps"
177
+ EVIDENCE_SERVICES = "adb.service_list"
178
+ EVIDENCE_DUMPSYS_PACKAGE = "adb.dumpsys_package"
179
+ EVIDENCE_WINDOW = "adb.dumpsys_window"
180
+ EVIDENCE_INSTALL = "adb.install"
181
+ EVIDENCE_SCREENCAP = "adb.screencap"
182
+ EVIDENCE_BUGREPORT = "adb.bugreport"
183
+
184
+ #: Typed failure for every wrapper when adb is absent, with the install hint.
185
+ ADB_MISSING_MESSAGE = (
186
+ "adb is not on PATH; install Android platform-tools "
187
+ "(https://developer.android.com/tools/releases/platform-tools) "
188
+ "and make sure adb is on PATH"
189
+ )
190
+
191
+ #: Build-identity whitelist for :func:`parse_getprop`.
192
+ _PROP_KEY_PREFIXES = ("ro.build.version.", "ro.product.")
193
+ _PROP_KEY_EXACT = ("ro.build.fingerprint",)
194
+
195
+
196
+ class RunnerOutcome(Protocol):
197
+ """What a runner must return: ``subprocess.CompletedProcess``-shaped.
198
+
199
+ Attributes:
200
+ returncode: The adb process exit code (``0`` on success).
201
+ stdout: Everything adb printed (``str`` for text wrappers, ``bytes``
202
+ for :func:`screencap`).
203
+ stderr: Everything adb wrote to its error stream.
204
+ """
205
+
206
+ returncode: int
207
+ stdout: Any
208
+ stderr: Any
209
+
210
+
211
+ class Runner(Protocol):
212
+ """The injectable stand-in for ``subprocess.run`` (see module docstring)."""
213
+
214
+ def __call__(self, command: tuple[str, ...], **kwargs: Any) -> RunnerOutcome: ...
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class AdbDevice:
219
+ """One row of ``adb devices -l`` output."""
220
+
221
+ serial: str
222
+ state: str
223
+ kind: str
224
+ model: str | None = None
225
+ device: str | None = None
226
+
227
+ def to_dict(self) -> dict[str, Any]:
228
+ data: dict[str, Any] = {
229
+ "serial": self.serial,
230
+ "state": self.state,
231
+ "kind": self.kind,
232
+ }
233
+ if self.model is not None:
234
+ data["model"] = self.model
235
+ if self.device is not None:
236
+ data["device"] = self.device
237
+ return data
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class AdbDevicesResult:
242
+ """Bounded, evidence-backed parse of one ``adb devices -l`` transcript."""
243
+
244
+ devices: tuple[AdbDevice, ...]
245
+ truncated: bool
246
+ warnings: list[str] = field(default_factory=list)
247
+ evidence: Evidence = field(
248
+ default_factory=lambda: Evidence(
249
+ source=EVIDENCE_DEVICES,
250
+ method="devices -l",
251
+ confidence=Confidence.UNKNOWN,
252
+ )
253
+ )
254
+
255
+ def to_dict(self) -> dict[str, Any]:
256
+ return {
257
+ "devices": [device.to_dict() for device in self.devices],
258
+ "count": len(self.devices),
259
+ "truncated": self.truncated,
260
+ "warnings": list(self.warnings),
261
+ "evidence": self.evidence.to_dict(),
262
+ }
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class AdbPackageEntry:
267
+ """One row of ``pm list packages -3`` (path present on ``-f`` transcripts)."""
268
+
269
+ name: str
270
+ path: str | None = None
271
+
272
+ def to_dict(self) -> dict[str, Any]:
273
+ data: dict[str, Any] = {"name": self.name}
274
+ if self.path is not None:
275
+ data["path"] = self.path
276
+ return data
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class AdbPackagesResult:
281
+ """Bounded name -> path mapping from one ``pm list packages`` transcript."""
282
+
283
+ packages: tuple[AdbPackageEntry, ...]
284
+ truncated: bool
285
+ warnings: list[str] = field(default_factory=list)
286
+ evidence: Evidence = field(
287
+ default_factory=lambda: Evidence(
288
+ source=EVIDENCE_PACKAGES,
289
+ method="shell pm list packages -3",
290
+ confidence=Confidence.UNKNOWN,
291
+ )
292
+ )
293
+
294
+ def to_dict(self) -> dict[str, Any]:
295
+ return {
296
+ "packages": [package.to_dict() for package in self.packages],
297
+ "count": len(self.packages),
298
+ "truncated": self.truncated,
299
+ "warnings": list(self.warnings),
300
+ "evidence": self.evidence.to_dict(),
301
+ }
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class AdbProcessEntry:
306
+ """One row of ``ps -A`` output (rss in KiB, state the ``S`` letter)."""
307
+
308
+ user: str
309
+ pid: int
310
+ ppid: int | None
311
+ rss: int | None
312
+ state: str | None
313
+ name: str
314
+
315
+ def to_dict(self) -> dict[str, Any]:
316
+ return {
317
+ "user": self.user,
318
+ "pid": self.pid,
319
+ "ppid": self.ppid,
320
+ "rss": self.rss,
321
+ "state": self.state,
322
+ "name": self.name,
323
+ }
324
+
325
+
326
+ @dataclass(frozen=True)
327
+ class AdbProcessesResult:
328
+ """Bounded process list from one ``ps -A`` transcript."""
329
+
330
+ processes: tuple[AdbProcessEntry, ...]
331
+ truncated: bool
332
+ warnings: list[str] = field(default_factory=list)
333
+ evidence: Evidence = field(
334
+ default_factory=lambda: Evidence(
335
+ source=EVIDENCE_PROCESSES,
336
+ method="shell ps -A",
337
+ confidence=Confidence.UNKNOWN,
338
+ )
339
+ )
340
+
341
+ def to_dict(self) -> dict[str, Any]:
342
+ return {
343
+ "processes": [process.to_dict() for process in self.processes],
344
+ "count": len(self.processes),
345
+ "truncated": self.truncated,
346
+ "warnings": list(self.warnings),
347
+ "evidence": self.evidence.to_dict(),
348
+ }
349
+
350
+
351
+ @dataclass(frozen=True)
352
+ class AdbServiceEntry:
353
+ """One row of ``service list`` output."""
354
+
355
+ number: int
356
+ name: str
357
+ interface: str | None = None
358
+
359
+ def to_dict(self) -> dict[str, Any]:
360
+ data: dict[str, Any] = {"number": self.number, "name": self.name}
361
+ if self.interface:
362
+ data["interface"] = self.interface
363
+ return data
364
+
365
+
366
+ @dataclass(frozen=True)
367
+ class AdbServicesResult:
368
+ """Bounded binder-service listing from one ``service list`` transcript."""
369
+
370
+ services: tuple[AdbServiceEntry, ...]
371
+ truncated: bool
372
+ warnings: list[str] = field(default_factory=list)
373
+ evidence: Evidence = field(
374
+ default_factory=lambda: Evidence(
375
+ source=EVIDENCE_SERVICES,
376
+ method="shell service list",
377
+ confidence=Confidence.UNKNOWN,
378
+ )
379
+ )
380
+
381
+ def to_dict(self) -> dict[str, Any]:
382
+ return {
383
+ "services": [service.to_dict() for service in self.services],
384
+ "count": len(self.services),
385
+ "truncated": self.truncated,
386
+ "warnings": list(self.warnings),
387
+ "evidence": self.evidence.to_dict(),
388
+ }
389
+
390
+
391
+ @dataclass(frozen=True)
392
+ class AdbPropsResult:
393
+ """Whitelisted build identity from one ``getprop`` transcript."""
394
+
395
+ props: dict[str, str]
396
+ truncated: bool
397
+ warnings: list[str] = field(default_factory=list)
398
+ evidence: Evidence = field(
399
+ default_factory=lambda: Evidence(
400
+ source=EVIDENCE_GETPROP,
401
+ method="shell getprop",
402
+ confidence=Confidence.UNKNOWN,
403
+ )
404
+ )
405
+
406
+ def to_dict(self) -> dict[str, Any]:
407
+ return {
408
+ "props": dict(self.props),
409
+ "count": len(self.props),
410
+ "truncated": self.truncated,
411
+ "warnings": list(self.warnings),
412
+ "evidence": self.evidence.to_dict(),
413
+ }
414
+
415
+
416
+ @dataclass(frozen=True)
417
+ class AdbPackageInfoResult:
418
+ """Package identity decoded from one ``dumpsys package`` transcript."""
419
+
420
+ package: str | None
421
+ version_name: str | None
422
+ installer: str | None
423
+ first_install_time: str | None
424
+ requested_permissions_count: int
425
+ warnings: list[str] = field(default_factory=list)
426
+ evidence: Evidence = field(
427
+ default_factory=lambda: Evidence(
428
+ source=EVIDENCE_DUMPSYS_PACKAGE,
429
+ method="shell dumpsys package",
430
+ confidence=Confidence.UNKNOWN,
431
+ )
432
+ )
433
+
434
+ def to_dict(self) -> dict[str, Any]:
435
+ return {
436
+ "package": self.package,
437
+ "version_name": self.version_name,
438
+ "installer": self.installer,
439
+ "first_install_time": self.first_install_time,
440
+ "requested_permissions_count": self.requested_permissions_count,
441
+ "warnings": list(self.warnings),
442
+ "evidence": self.evidence.to_dict(),
443
+ }
444
+
445
+
446
+ @dataclass(frozen=True)
447
+ class AdbWindowFocusResult:
448
+ """Focused window/app decoded from one ``dumpsys window`` transcript."""
449
+
450
+ current_focus: str | None
451
+ focused_app: str | None
452
+ current_focus_component: str | None = None
453
+ focused_app_component: str | None = None
454
+ warnings: list[str] = field(default_factory=list)
455
+ evidence: Evidence = field(
456
+ default_factory=lambda: Evidence(
457
+ source=EVIDENCE_WINDOW,
458
+ method="shell dumpsys window",
459
+ confidence=Confidence.UNKNOWN,
460
+ )
461
+ )
462
+
463
+ def to_dict(self) -> dict[str, Any]:
464
+ return {
465
+ "current_focus": self.current_focus,
466
+ "focused_app": self.focused_app,
467
+ "current_focus_component": self.current_focus_component,
468
+ "focused_app_component": self.focused_app_component,
469
+ "warnings": list(self.warnings),
470
+ "evidence": self.evidence.to_dict(),
471
+ }
472
+
473
+
474
+ @dataclass(frozen=True)
475
+ class AdbInstallResult:
476
+ """Record of one confirmed ``adb install`` (host sha256 taken first)."""
477
+
478
+ serial: str
479
+ apk: str
480
+ sha256: str
481
+ size: int
482
+ output: str
483
+ warnings: list[str] = field(default_factory=list)
484
+ evidence: Evidence = field(
485
+ default_factory=lambda: Evidence(
486
+ source=EVIDENCE_INSTALL,
487
+ method="install -r",
488
+ confidence=Confidence.CERTAIN,
489
+ )
490
+ )
491
+
492
+ def to_dict(self) -> dict[str, Any]:
493
+ return {
494
+ "serial": self.serial,
495
+ "apk": self.apk,
496
+ "sha256": self.sha256,
497
+ "size": self.size,
498
+ "output": self.output,
499
+ "warnings": list(self.warnings),
500
+ "evidence": self.evidence.to_dict(),
501
+ }
502
+
503
+
504
+ @dataclass(frozen=True)
505
+ class AdbScreencapResult:
506
+ """Record of one ``exec-out screencap -p`` capture written to disk."""
507
+
508
+ serial: str
509
+ path: str
510
+ size: int
511
+ sha256: str
512
+ is_png: bool
513
+ warnings: list[str] = field(default_factory=list)
514
+ evidence: Evidence = field(
515
+ default_factory=lambda: Evidence(
516
+ source=EVIDENCE_SCREENCAP,
517
+ method="exec-out screencap -p",
518
+ confidence=Confidence.CERTAIN,
519
+ )
520
+ )
521
+
522
+ def to_dict(self) -> dict[str, Any]:
523
+ return {
524
+ "serial": self.serial,
525
+ "path": self.path,
526
+ "size": self.size,
527
+ "sha256": self.sha256,
528
+ "is_png": self.is_png,
529
+ "warnings": list(self.warnings),
530
+ "evidence": self.evidence.to_dict(),
531
+ }
532
+
533
+
534
+ @dataclass(frozen=True)
535
+ class AdbBugreportResult:
536
+ """Record of one ``adb bugreport`` zip written into a caller directory."""
537
+
538
+ serial: str
539
+ path: str
540
+ size: int
541
+ warnings: list[str] = field(default_factory=list)
542
+ evidence: Evidence = field(
543
+ default_factory=lambda: Evidence(
544
+ source=EVIDENCE_BUGREPORT,
545
+ method="bugreport",
546
+ confidence=Confidence.CERTAIN,
547
+ )
548
+ )
549
+
550
+ def to_dict(self) -> dict[str, Any]:
551
+ return {
552
+ "serial": self.serial,
553
+ "path": self.path,
554
+ "size": self.size,
555
+ "warnings": list(self.warnings),
556
+ "evidence": self.evidence.to_dict(),
557
+ }
558
+
559
+
560
+ def _as_text(raw: Any) -> str:
561
+ """Return ``raw`` as transcript text, refusing anything that is not ``str``."""
562
+ if not isinstance(raw, str):
563
+ raise AnalysisError(f"adb transcript must be a string, got {type(raw).__name__}")
564
+ return raw
565
+
566
+
567
+ def _note(warnings: list[str], message: str) -> None:
568
+ """Append ``message``, bounded so one noisy transcript cannot flood memory."""
569
+ if len(warnings) < MAX_WARNINGS:
570
+ warnings.append(message)
571
+ elif len(warnings) == MAX_WARNINGS:
572
+ warnings.append(f"further warnings suppressed (limit {MAX_WARNINGS})")
573
+
574
+
575
+ def _evidence_for(
576
+ source: str,
577
+ method: str,
578
+ count: int,
579
+ warnings: list[str],
580
+ detail: str | None = None,
581
+ ) -> Evidence:
582
+ """Build the parse's evidence record.
583
+
584
+ Direct, unambiguous rows parsed from a transcript are ``CERTAIN``; rows
585
+ parsed *with* warnings drop to ``MEDIUM``; an empty parse reports
586
+ ``UNKNOWN`` rather than dressing absence up as a finding.
587
+ """
588
+ if count <= 0:
589
+ confidence = Confidence.UNKNOWN
590
+ elif warnings:
591
+ confidence = Confidence.MEDIUM
592
+ else:
593
+ confidence = Confidence.CERTAIN
594
+ if detail is None:
595
+ detail = f"{count} record{'s' if count == 1 else ''} parsed" if count else "nothing parsed"
596
+ return Evidence(
597
+ source=source,
598
+ method=method,
599
+ confidence=confidence,
600
+ detail=detail,
601
+ )
602
+
603
+
604
+ def _check_timeout(value: Any) -> float:
605
+ """Validate one wall-clock budget; every wrapper shares the bound."""
606
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
607
+ raise AnalysisError(f"timeout must be a number of seconds, got {value!r}")
608
+ timeout = float(value)
609
+ if not 1.0 <= timeout <= MAX_TIMEOUT:
610
+ raise AnalysisError(
611
+ f"timeout {timeout} is out of range; expected 1 .. {MAX_TIMEOUT} seconds"
612
+ )
613
+ return timeout
614
+
615
+
616
+ def _check_serial(serial: Any) -> str:
617
+ """Validate one device serial before it reaches argv."""
618
+ if not isinstance(serial, str):
619
+ raise AnalysisError(f"serial must be a string, got {type(serial).__name__}")
620
+ text = serial.strip()
621
+ if not text:
622
+ raise AnalysisError("serial must not be empty")
623
+ if len(text) > 256:
624
+ raise AnalysisError(f"serial is {len(text)} characters long; expected at most 256")
625
+ if not _SERIAL_CHARS.match(text):
626
+ raise AnalysisError(
627
+ f"serial {text!r} contains characters adb does not accept; "
628
+ "expected alphanumerics plus . _ : -"
629
+ )
630
+ return text
631
+
632
+
633
+ def _tail(text: str, limit: int = 400) -> str:
634
+ """Return the last ``limit`` characters of ``text``, whitespace-flattened."""
635
+ flat = " ".join(text.split())
636
+ return flat[-limit:] if len(flat) > limit else flat
637
+
638
+
639
+ def _adb_path() -> str | None:
640
+ """Probe ``PATH`` for adb exactly once per call; nothing else can fail."""
641
+ return shutil.which("adb")
642
+
643
+
644
+ def _require_adb() -> None:
645
+ """Raise the typed skip-gate failure when adb is absent."""
646
+ if _adb_path() is None:
647
+ raise AnalysisError(ADB_MISSING_MESSAGE)
648
+
649
+
650
+ def _run_adb(
651
+ args: Sequence[str],
652
+ *,
653
+ timeout: float,
654
+ runner: Runner | None = None,
655
+ binary: bool = False,
656
+ ) -> tuple[int, str | bytes, str]:
657
+ """Run ``adb ...`` once, bounded, and return ``(returncode, stdout, stderr)``.
658
+
659
+ ``binary=True`` captures raw bytes (:func:`screencap`); every other caller
660
+ gets text. With the default runner the typed adb-missing failure fires
661
+ before anything is spawned; an injected ``runner`` stands in for
662
+ ``subprocess.run`` entirely, so the PATH probe is skipped and the pipeline
663
+ runs offline against canned output (its own ``FileNotFoundError`` still
664
+ surfaces as the same typed error).
665
+ """
666
+ if runner is None:
667
+ _require_adb()
668
+ checked = _check_timeout(timeout)
669
+ command = ("adb", *args)
670
+ execute = runner if runner is not None else subprocess.run
671
+ try:
672
+ outcome = execute(
673
+ command,
674
+ capture_output=True,
675
+ text=not binary,
676
+ timeout=checked,
677
+ )
678
+ except subprocess.TimeoutExpired as exc:
679
+ raise AnalysisError(
680
+ f"adb {args[0]} exceeded its timeout of {checked} seconds and was "
681
+ "terminated; raise timeout or check the device state with adb devices"
682
+ ) from exc
683
+ except FileNotFoundError as exc:
684
+ raise AnalysisError(
685
+ f"{ADB_MISSING_MESSAGE}; the runner raised FileNotFoundError, so the "
686
+ "adb executable vanished between the PATH probe and the call"
687
+ ) from exc
688
+ returncode = getattr(outcome, "returncode", None)
689
+ stdout = getattr(outcome, "stdout", None)
690
+ stderr = getattr(outcome, "stderr", None)
691
+ if not isinstance(returncode, int):
692
+ raise AnalysisError(
693
+ "the runner outcome violates the injection contract: returncode must "
694
+ "be an int"
695
+ )
696
+ if binary and not isinstance(stdout, bytes):
697
+ raise AnalysisError(
698
+ "the runner outcome violates the injection contract: binary runs "
699
+ "(screencap) must return stdout as bytes"
700
+ )
701
+ if not binary and isinstance(stdout, bytes):
702
+ stdout = stdout.decode("utf-8", errors="replace")
703
+ stderr_text = stderr if isinstance(stderr, str) else str(stderr or "")
704
+ return returncode, stdout if stdout is not None else "", stderr_text
705
+
706
+
707
+ def _check_exit(command: tuple[str, ...], returncode: int, stderr: str) -> None:
708
+ """Turn a non-zero adb exit into a typed error naming what adb said."""
709
+ if returncode == 0:
710
+ return
711
+ spoken = _tail(stderr) or "no stderr output"
712
+ raise AnalysisError(f"adb command {' '.join(command)!r} exited with code {returncode}: {spoken}")
713
+
714
+
715
+ def _sha256_file(path: Path) -> tuple[str, int]:
716
+ """Stream ``path`` and return ``(sha256 hex digest, size in bytes)``."""
717
+ digest = hashlib.sha256()
718
+ size = 0
719
+ with path.open("rb") as handle:
720
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
721
+ digest.update(chunk)
722
+ size += len(chunk)
723
+ return digest.hexdigest(), size
724
+
725
+
726
+ def _device_kind(serial: str, device: str | None) -> str:
727
+ """Classify a serial as ``emulator`` or ``physical``.
728
+
729
+ The emulator's own serial prefix (``emulator-5554``) and the ``device:``
730
+ codename it reports (``emu64xa``, ``emu64xe``) are the two signals adb
731
+ itself gives us; anything else is treated as a physical device.
732
+ """
733
+ if serial.startswith("emulator-"):
734
+ return "emulator"
735
+ if (device or "").startswith("emu"):
736
+ return "emulator"
737
+ return "physical"
738
+
739
+
740
+ def parse_devices(text: str) -> AdbDevicesResult:
741
+ """Parse ``adb devices -l`` output into bounded typed rows.
742
+
743
+ Daemon chatter (``* daemon not running ...``, ``List of devices attached``)
744
+ is skipped; each remaining line is ``<serial> <state> key:value ...`` where
745
+ ``model:`` and ``device:`` carry the marketing model and the codename.
746
+ Lines that parse are kept up to :data:`MAX_DEVICES`; anything past the cap
747
+ sets ``truncated`` and the parse never raises for transcript content.
748
+ """
749
+ raw = _as_text(text)
750
+ devices: list[AdbDevice] = []
751
+ warnings: list[str] = []
752
+ truncated = False
753
+ for line in raw.splitlines():
754
+ stripped = line.strip()
755
+ if not stripped or stripped.startswith(_DEVICES_SKIP_PREFIXES):
756
+ continue
757
+ if len(devices) >= MAX_DEVICES:
758
+ truncated = True
759
+ _note(warnings, f"device limit {MAX_DEVICES} reached; further rows dropped")
760
+ break
761
+ fields = stripped.split()
762
+ if len(fields) < 2:
763
+ _note(warnings, f"unparsable device row skipped: {stripped!r}")
764
+ continue
765
+ serial, state = fields[0], fields[1]
766
+ model: str | None = None
767
+ device: str | None = None
768
+ for token in fields[2:]:
769
+ if token.startswith("model:"):
770
+ model = token[len("model:") :] or None
771
+ elif token.startswith("device:"):
772
+ device = token[len("device:") :] or None
773
+ devices.append(
774
+ AdbDevice(
775
+ serial=serial,
776
+ state=state,
777
+ kind=_device_kind(serial, device),
778
+ model=model,
779
+ device=device,
780
+ )
781
+ )
782
+ return AdbDevicesResult(
783
+ devices=tuple(devices),
784
+ truncated=truncated,
785
+ warnings=warnings,
786
+ evidence=_evidence_for(EVIDENCE_DEVICES, "devices -l", len(devices), warnings),
787
+ )
788
+
789
+
790
+ def parse_packages(text: str) -> AdbPackagesResult:
791
+ """Parse ``pm list packages -3`` into a bounded name -> path mapping.
792
+
793
+ Bare transcripts give ``package:<name>`` rows with no path; ``pm list
794
+ packages -f -3`` transcripts give ``package:<apk>=<name>`` rows whose path
795
+ becomes :attr:`AdbPackageEntry.path`. Junk lines become warnings, never
796
+ errors.
797
+ """
798
+ raw = _as_text(text)
799
+ packages: list[AdbPackageEntry] = []
800
+ warnings: list[str] = []
801
+ truncated = False
802
+ for line in raw.splitlines():
803
+ stripped = line.strip()
804
+ if not stripped:
805
+ continue
806
+ match = _PACKAGE_LINE.match(stripped)
807
+ if match is None:
808
+ _note(warnings, f"line without the 'package:' prefix skipped: {stripped!r}")
809
+ continue
810
+ if len(packages) >= MAX_PACKAGES:
811
+ truncated = True
812
+ _note(warnings, f"package limit {MAX_PACKAGES} reached; further rows dropped")
813
+ break
814
+ rest = match.group("rest").strip()
815
+ path: str | None = None
816
+ name = rest
817
+ if "=" in rest:
818
+ # The separator is the LAST '=': apk paths themselves carry
819
+ # base64 '~~x==' padding segments (``adb shell pm -f`` transcripts).
820
+ candidate_path, _, candidate_name = rest.rpartition("=")
821
+ path = candidate_path.strip() or None
822
+ name = candidate_name.strip()
823
+ if not name:
824
+ _note(warnings, f"package row with an empty name skipped: {stripped!r}")
825
+ continue
826
+ packages.append(AdbPackageEntry(name=name, path=path))
827
+ return AdbPackagesResult(
828
+ packages=tuple(packages),
829
+ truncated=truncated,
830
+ warnings=warnings,
831
+ evidence=_evidence_for(
832
+ EVIDENCE_PACKAGES, "shell pm list packages -3", len(packages), warnings
833
+ ),
834
+ )
835
+
836
+
837
+ def _as_int_or_none(text: str) -> int | None:
838
+ """Return ``text`` as an ``int`` when it is a plain decimal, else ``None``."""
839
+ return int(text) if text.isdigit() else None
840
+
841
+
842
+ def _ps_header_indexes(line: str) -> dict[str, int] | None:
843
+ """Return canonical column indexes when ``line`` is a ``ps`` header row."""
844
+ tokens = line.split()
845
+ lowered = [token.lower() for token in tokens]
846
+ if "pid" not in lowered or "name" not in lowered:
847
+ return None
848
+ if not all(token in _PS_COLUMNS for token in lowered):
849
+ return None
850
+ return {token: position for position, token in enumerate(lowered)}
851
+
852
+
853
+ def parse_processes(text: str) -> AdbProcessesResult:
854
+ """Parse ``ps -A`` output into bounded typed rows.
855
+
856
+ The header row (``USER PID PPID VSZ RSS WCHAN ADDR S NAME`` modern, or the
857
+ legacy ``USER PID PPID VSIZE RSS WCHAN NAME``) is located and drives the
858
+ column mapping; a transcript without a header row falls back to the modern
859
+ layout with a warning. Process names may contain spaces, so everything from
860
+ the name column onward is joined back into one name.
861
+ """
862
+ raw = _as_text(text)
863
+ processes: list[AdbProcessEntry] = []
864
+ warnings: list[str] = []
865
+ truncated = False
866
+ indexes: dict[str, int] | None = None
867
+ header_width = 0
868
+ for line in raw.splitlines():
869
+ stripped = line.rstrip("\r")
870
+ if not stripped.strip():
871
+ continue
872
+ if indexes is None:
873
+ candidate = _ps_header_indexes(stripped)
874
+ if candidate is not None:
875
+ indexes = candidate
876
+ header_width = max(candidate.values()) + 1
877
+ continue
878
+ if indexes is None:
879
+ indexes = {"user": 0, "pid": 1, "ppid": 2, "rss": 4, "s": 7, "name": 8}
880
+ header_width = 9
881
+ _note(warnings, "no ps header row found; assumed the modern ps -A column layout")
882
+ if stripped.upper().startswith("USER "):
883
+ continue
884
+ if len(processes) >= MAX_PROCESSES:
885
+ truncated = True
886
+ _note(warnings, f"process limit {MAX_PROCESSES} reached; further rows dropped")
887
+ break
888
+ fields = stripped.split()
889
+ if len(fields) < header_width:
890
+ _note(warnings, f"short ps row skipped: {stripped!r}")
891
+ continue
892
+ pid = _as_int_or_none(fields[indexes["pid"]])
893
+ if pid is None:
894
+ _note(warnings, f"ps row without a numeric pid skipped: {stripped!r}")
895
+ continue
896
+ name = " ".join(fields[indexes["name"] :]).strip()
897
+ if not name:
898
+ _note(warnings, f"ps row without a name skipped: pid {pid}")
899
+ continue
900
+ processes.append(
901
+ AdbProcessEntry(
902
+ user=fields[indexes.get("user", 0)],
903
+ pid=pid,
904
+ ppid=_as_int_or_none(fields[indexes["ppid"]]),
905
+ rss=_as_int_or_none(fields[indexes["rss"]]) if "rss" in indexes else None,
906
+ state=fields[indexes["s"]] if "s" in indexes else None,
907
+ name=name,
908
+ )
909
+ )
910
+ if indexes is None:
911
+ _note(warnings, "no ps rows found in the transcript")
912
+ return AdbProcessesResult(
913
+ processes=tuple(processes),
914
+ truncated=truncated,
915
+ warnings=warnings,
916
+ evidence=_evidence_for(EVIDENCE_PROCESSES, "shell ps -A", len(processes), warnings),
917
+ )
918
+
919
+
920
+ def parse_services(text: str) -> AdbServicesResult:
921
+ """Parse ``service list`` rows into bounded typed entries.
922
+
923
+ Each row is ``<number> <name>: [<interface>]``; the interface can be empty
924
+ (``[]``) or absent, and both are stored as ``None``. Unparsable lines become
925
+ warnings, never errors.
926
+ """
927
+ raw = _as_text(text)
928
+ services: list[AdbServiceEntry] = []
929
+ warnings: list[str] = []
930
+ truncated = False
931
+ for line in raw.splitlines():
932
+ stripped = line.strip()
933
+ if not stripped:
934
+ continue
935
+ match = _SERVICE_LINE.match(stripped)
936
+ if match is None:
937
+ _note(warnings, f"unparsable service row skipped: {stripped!r}")
938
+ continue
939
+ if len(services) >= MAX_SERVICES:
940
+ truncated = True
941
+ _note(warnings, f"service limit {MAX_SERVICES} reached; further rows dropped")
942
+ break
943
+ services.append(
944
+ AdbServiceEntry(
945
+ number=int(match.group("number")),
946
+ name=match.group("name"),
947
+ interface=match.group("interface") or None,
948
+ )
949
+ )
950
+ return AdbServicesResult(
951
+ services=tuple(services),
952
+ truncated=truncated,
953
+ warnings=warnings,
954
+ evidence=_evidence_for(
955
+ EVIDENCE_SERVICES, "shell service list", len(services), warnings
956
+ ),
957
+ )
958
+
959
+
960
+ def _prop_whitelisted(key: str) -> bool:
961
+ """True for the build-identity keys the lane keeps (everything else drops)."""
962
+ return key in _PROP_KEY_EXACT or key.startswith(_PROP_KEY_PREFIXES)
963
+
964
+
965
+ def parse_getprop(text: str) -> AdbPropsResult:
966
+ """Parse a ``getprop`` dump into the whitelisted build identity.
967
+
968
+ Both renderings adb prints are accepted: the bracketed
969
+ ``[ro.build.version.release]: [14]`` form and the plain ``key: value`` form.
970
+ Keys outside ``ro.build.version.*``, ``ro.product.*`` and
971
+ ``ro.build.fingerprint`` are dropped silently -- the whitelist is the point
972
+ -- and the kept keys are capped at :data:`MAX_PROPS`.
973
+ """
974
+ raw = _as_text(text)
975
+ props: dict[str, str] = {}
976
+ warnings: list[str] = []
977
+ truncated = False
978
+ for line in raw.splitlines():
979
+ stripped = line.strip()
980
+ if not stripped:
981
+ continue
982
+ key: str | None = None
983
+ value: str | None = None
984
+ bracketed = _PROP_BRACKETED.match(stripped)
985
+ if bracketed is not None:
986
+ key = bracketed.group("key").strip()
987
+ value = bracketed.group("value").strip()
988
+ elif ":" in stripped:
989
+ candidate_key, _, candidate_value = stripped.partition(":")
990
+ key = candidate_key.strip()
991
+ value = candidate_value.strip()
992
+ if not key or value is None:
993
+ _note(warnings, f"unparsable getprop line skipped: {stripped!r}")
994
+ continue
995
+ if not _prop_whitelisted(key):
996
+ continue
997
+ if len(props) >= MAX_PROPS:
998
+ truncated = True
999
+ _note(warnings, f"property limit {MAX_PROPS} reached; further rows dropped")
1000
+ break
1001
+ props[key] = value
1002
+ ordered = {key: props[key] for key in sorted(props)}
1003
+ return AdbPropsResult(
1004
+ props=ordered,
1005
+ truncated=truncated,
1006
+ warnings=warnings,
1007
+ evidence=_evidence_for(
1008
+ EVIDENCE_GETPROP, "shell getprop", len(ordered), warnings
1009
+ ),
1010
+ )
1011
+
1012
+
1013
+ def parse_dumpsys_package(text: str) -> AdbPackageInfoResult:
1014
+ """Decode one package's identity from a ``dumpsys package`` transcript.
1015
+
1016
+ Kept: the ``Package [name]`` header, ``versionName``, ``installer`` (or the
1017
+ legacy ``installerPackageName``), ``firstInstallTime`` and the count of the
1018
+ ``requested permissions`` block. The count is what matters -- permission
1019
+ lists are unbounded, so they are never stored.
1020
+ """
1021
+ raw = _as_text(text)
1022
+ package: str | None = None
1023
+ version_name: str | None = None
1024
+ installer: str | None = None
1025
+ first_install: str | None = None
1026
+ permission_count = 0
1027
+ in_requested = False
1028
+ warnings: list[str] = []
1029
+ for line in raw.splitlines():
1030
+ stripped = line.strip()
1031
+ header = _DUMPSYS_PACKAGE_HEADER.match(stripped)
1032
+ if header is not None and package is None:
1033
+ package = header.group("name")
1034
+ if not stripped:
1035
+ in_requested = False
1036
+ continue
1037
+ if stripped.endswith(":"):
1038
+ in_requested = stripped == "requested permissions:"
1039
+ continue
1040
+ if in_requested:
1041
+ permission_count += 1
1042
+ continue
1043
+ if "=" not in stripped:
1044
+ continue
1045
+ key, _, value = stripped.partition("=")
1046
+ key = key.strip()
1047
+ value = value.strip()
1048
+ if key == "versionName" and version_name is None:
1049
+ version_name = value
1050
+ elif key == "firstInstallTime" and first_install is None:
1051
+ first_install = value
1052
+ elif key == "installer" and installer is None:
1053
+ installer = value
1054
+ elif key == "installerPackageName" and installer is None:
1055
+ installer = value
1056
+ if permission_count == 0:
1057
+ _note(warnings, "no 'requested permissions' block found in the transcript")
1058
+ fields_found = sum(
1059
+ 1 for value in (package, version_name, installer, first_install) if value is not None
1060
+ )
1061
+ return AdbPackageInfoResult(
1062
+ package=package,
1063
+ version_name=version_name,
1064
+ installer=installer,
1065
+ first_install_time=first_install,
1066
+ requested_permissions_count=permission_count,
1067
+ warnings=warnings,
1068
+ evidence=_evidence_for(
1069
+ EVIDENCE_DUMPSYS_PACKAGE,
1070
+ "shell dumpsys package",
1071
+ fields_found,
1072
+ warnings,
1073
+ detail=(
1074
+ f"package {package!r}; versionName {version_name!r}; "
1075
+ f"{permission_count} requested permissions"
1076
+ ),
1077
+ ),
1078
+ )
1079
+
1080
+
1081
+ def _focus_component(value: str) -> str | None:
1082
+ """Extract ``package/component`` from a focus value, ``None`` when absent."""
1083
+ info = _COMPONENT_INFO.search(value)
1084
+ if info is not None:
1085
+ return info.group("component")
1086
+ token = _FOCUS_COMPONENT.search(value)
1087
+ if token is not None:
1088
+ return token.group("component")
1089
+ return None
1090
+
1091
+
1092
+ def parse_window_focus(text: str) -> AdbWindowFocusResult:
1093
+ """Decode the focused window/app from a ``dumpsys window`` transcript.
1094
+
1095
+ ``mCurrentFocus`` is the window with input focus; ``mFocusedApp`` is the
1096
+ activity the framework considers foreground. ``mFocusedWindow`` is kept as
1097
+ a fallback for builds that only print the latter pair. The raw values are
1098
+ preserved and ``package/component`` is extracted where adb's ``u0`` token
1099
+ or a ``ComponentInfo{...}`` carries one.
1100
+ """
1101
+ raw = _as_text(text)
1102
+ current_focus: str | None = None
1103
+ focused_window: str | None = None
1104
+ focused_app: str | None = None
1105
+ warnings: list[str] = []
1106
+ for line in raw.splitlines():
1107
+ stripped = line.strip()
1108
+ if stripped.startswith("mCurrentFocus="):
1109
+ current_focus = stripped[len("mCurrentFocus=") :].strip()
1110
+ elif stripped.startswith("mFocusedWindow=") and focused_window is None:
1111
+ focused_window = stripped[len("mFocusedWindow=") :].strip()
1112
+ elif stripped.startswith("mFocusedApp="):
1113
+ focused_app = stripped[len("mFocusedApp=") :].strip()
1114
+ effective_focus = current_focus or focused_window
1115
+ if effective_focus is None and focused_app is None:
1116
+ _note(warnings, "no mCurrentFocus/mFocusedWindow/mFocusedApp line in the transcript")
1117
+ return AdbWindowFocusResult(
1118
+ current_focus=effective_focus,
1119
+ focused_app=focused_app,
1120
+ current_focus_component=_focus_component(effective_focus) if effective_focus else None,
1121
+ focused_app_component=_focus_component(focused_app) if focused_app else None,
1122
+ warnings=warnings,
1123
+ evidence=_evidence_for(
1124
+ EVIDENCE_WINDOW,
1125
+ "shell dumpsys window",
1126
+ sum(1 for value in (effective_focus, focused_app) if value is not None),
1127
+ warnings,
1128
+ ),
1129
+ )
1130
+
1131
+
1132
+ def adb_available() -> bool:
1133
+ """Report whether adb is on ``PATH``; the only gate a caller needs."""
1134
+ return _adb_path() is not None
1135
+
1136
+
1137
+ def list_devices(
1138
+ *,
1139
+ runner: Runner | None = None,
1140
+ timeout: float = DEFAULT_TIMEOUT,
1141
+ ) -> AdbDevicesResult:
1142
+ """Run ``adb devices -l`` and return the parsed, bounded device list."""
1143
+ code, out, err = _run_adb(("devices", "-l"), timeout=timeout, runner=runner)
1144
+ _check_exit(("adb", "devices", "-l"), code, err)
1145
+ return parse_devices(out if isinstance(out, str) else "")
1146
+
1147
+
1148
+ def inspect_device(
1149
+ serial: str,
1150
+ *,
1151
+ runner: Runner | None = None,
1152
+ timeout: float = DEFAULT_TIMEOUT,
1153
+ ) -> AdbPropsResult:
1154
+ """Run ``adb -s <serial> shell getprop`` and keep the build identity."""
1155
+ checked = _check_serial(serial)
1156
+ code, out, err = _run_adb(
1157
+ ("-s", checked, "shell", "getprop"), timeout=timeout, runner=runner
1158
+ )
1159
+ _check_exit(("adb", "-s", checked, "shell", "getprop"), code, err)
1160
+ return parse_getprop(out if isinstance(out, str) else "")
1161
+
1162
+
1163
+ def list_packages(
1164
+ serial: str,
1165
+ *,
1166
+ runner: Runner | None = None,
1167
+ timeout: float = DEFAULT_TIMEOUT,
1168
+ ) -> AdbPackagesResult:
1169
+ """Run ``adb -s <serial> shell pm list packages -3`` (third-party only)."""
1170
+ checked = _check_serial(serial)
1171
+ code, out, err = _run_adb(
1172
+ ("-s", checked, "shell", "pm", "list", "packages", "-3"),
1173
+ timeout=timeout,
1174
+ runner=runner,
1175
+ )
1176
+ _check_exit(("adb", "-s", checked, "shell", "pm list packages -3"), code, err)
1177
+ return parse_packages(out if isinstance(out, str) else "")
1178
+
1179
+
1180
+ def list_processes(
1181
+ serial: str,
1182
+ *,
1183
+ runner: Runner | None = None,
1184
+ timeout: float = DEFAULT_TIMEOUT,
1185
+ ) -> AdbProcessesResult:
1186
+ """Run ``adb -s <serial> shell ps -A`` and return the bounded process rows."""
1187
+ checked = _check_serial(serial)
1188
+ code, out, err = _run_adb(
1189
+ ("-s", checked, "shell", "ps", "-A"), timeout=timeout, runner=runner
1190
+ )
1191
+ _check_exit(("adb", "-s", checked, "shell", "ps -A"), code, err)
1192
+ return parse_processes(out if isinstance(out, str) else "")
1193
+
1194
+
1195
+ def install_apk(
1196
+ serial: str,
1197
+ apk_path: str | Path,
1198
+ *,
1199
+ confirm: bool = False,
1200
+ runner: Runner | None = None,
1201
+ timeout: float = DEFAULT_TIMEOUT,
1202
+ ) -> AdbInstallResult:
1203
+ """Install ``apk_path`` on ``serial`` -- refuses without ``confirm=True``.
1204
+
1205
+ The host-side ``sha256`` and size are recorded *before* adb is invoked, so
1206
+ the result always names exactly which bytes the device was asked to accept.
1207
+ An adb failure (non-zero exit, or the ``Failure [...]`` line adb prints with
1208
+ a zero exit on some builds) raises a typed error carrying adb's output.
1209
+ """
1210
+ checked = _check_serial(serial)
1211
+ if confirm is not True:
1212
+ raise AnalysisError(
1213
+ "install_apk requires confirm=True; refusing to change the device "
1214
+ "without an explicit confirmation (CLI: pass --yes)"
1215
+ )
1216
+ candidate = Path(apk_path).expanduser()
1217
+ if not candidate.exists():
1218
+ raise AnalysisError(f"APK not found: {candidate}")
1219
+ if not candidate.is_file():
1220
+ raise AnalysisError(f"not a regular file: {candidate}")
1221
+ size = candidate.stat().st_size
1222
+ if size <= 0:
1223
+ raise AnalysisError(f"APK {candidate} is empty")
1224
+ if size > MAX_APK_BYTES:
1225
+ raise AnalysisError(
1226
+ f"APK {candidate} is {size} bytes; the install budget is {MAX_APK_BYTES} bytes"
1227
+ )
1228
+ digest, measured = _sha256_file(candidate)
1229
+ command_tail = ("-s", checked, "install", "-r", str(candidate))
1230
+ code, out, err = _run_adb(command_tail, timeout=timeout, runner=runner)
1231
+ spoken = out if isinstance(out, str) else ""
1232
+ if code != 0 or "Failure" in spoken:
1233
+ detail = _tail(spoken) or _tail(err) or "no adb output"
1234
+ raise AnalysisError(
1235
+ f"adb install of {candidate} (sha256 {digest}) failed on {checked}: {detail}"
1236
+ )
1237
+ return AdbInstallResult(
1238
+ serial=checked,
1239
+ apk=str(candidate),
1240
+ sha256=digest,
1241
+ size=measured,
1242
+ output=_tail(spoken),
1243
+ evidence=Evidence(
1244
+ source=EVIDENCE_INSTALL,
1245
+ method="install -r",
1246
+ confidence=Confidence.CERTAIN,
1247
+ detail=f"sha256 {digest}",
1248
+ extra={"size": measured, "serial": checked},
1249
+ ),
1250
+ )
1251
+
1252
+
1253
+ def screencap(
1254
+ serial: str,
1255
+ out_path: str | Path,
1256
+ *,
1257
+ runner: Runner | None = None,
1258
+ timeout: float = DEFAULT_TIMEOUT,
1259
+ ) -> AdbScreencapResult:
1260
+ """Capture the screen as a PNG via ``exec-out screencap -p``.
1261
+
1262
+ ``exec-out`` keeps the bytes binary-clean (the ``adb shell screencap`` path
1263
+ mangles ``\\n`` on some transports). The file is written, hashed and checked
1264
+ for the PNG magic; a non-PNG capture is written anyway and flagged as a
1265
+ warning rather than discarded.
1266
+ """
1267
+ checked = _check_serial(serial)
1268
+ target = Path(out_path).expanduser()
1269
+ parent = target.parent
1270
+ if str(parent):
1271
+ parent.mkdir(parents=True, exist_ok=True)
1272
+ code, data, err = _run_adb(
1273
+ ("-s", checked, "exec-out", "screencap", "-p"),
1274
+ timeout=timeout,
1275
+ runner=runner,
1276
+ binary=True,
1277
+ )
1278
+ _check_exit(("adb", "-s", checked, "exec-out screencap -p"), code, err)
1279
+ if not isinstance(data, bytes) or not data:
1280
+ raise AnalysisError(
1281
+ f"screencap on {checked} returned no bytes; is the screen accessible?"
1282
+ )
1283
+ target.write_bytes(data)
1284
+ digest = hashlib.sha256(data).hexdigest()
1285
+ warnings: list[str] = []
1286
+ is_png = data[:8] == b"\x89PNG\r\n\x1a\n"
1287
+ if not is_png:
1288
+ _note(warnings, f"capture is not a PNG (starts {data[:8].hex()}); written anyway")
1289
+ return AdbScreencapResult(
1290
+ serial=checked,
1291
+ path=str(target),
1292
+ size=len(data),
1293
+ sha256=digest,
1294
+ is_png=is_png,
1295
+ warnings=warnings,
1296
+ evidence=Evidence(
1297
+ source=EVIDENCE_SCREENCAP,
1298
+ method="exec-out screencap -p",
1299
+ confidence=Confidence.CERTAIN,
1300
+ detail=f"sha256 {digest}",
1301
+ extra={"size": len(data), "serial": checked},
1302
+ ),
1303
+ )
1304
+
1305
+
1306
+ def bugreport(
1307
+ serial: str,
1308
+ out_dir: str | Path,
1309
+ *,
1310
+ runner: Runner | None = None,
1311
+ timeout: float = DEFAULT_TIMEOUT,
1312
+ ) -> AdbBugreportResult:
1313
+ """Write one ``adb bugreport`` zip into ``out_dir``.
1314
+
1315
+ The target file is ``codevanta-bugreport-<sanitised serial>.zip``; the
1316
+ directory is created when missing. A full bugreport on real hardware often
1317
+ needs more than the 20 s default, so pass a larger ``timeout`` (bounded by
1318
+ :data:`MAX_TIMEOUT`) for live devices.
1319
+ """
1320
+ checked = _check_serial(serial)
1321
+ directory = Path(out_dir).expanduser()
1322
+ directory.mkdir(parents=True, exist_ok=True)
1323
+ safe = re.sub(r"[^A-Za-z0-9._-]", "_", checked)
1324
+ target = directory / f"codevanta-bugreport-{safe}.zip"
1325
+ command_tail = ("-s", checked, "bugreport", str(target))
1326
+ code, out, err = _run_adb(command_tail, timeout=timeout, runner=runner)
1327
+ _check_exit(("adb", "-s", checked, "bugreport"), code, err)
1328
+ if not target.exists():
1329
+ raise AnalysisError(
1330
+ f"adb bugreport on {checked} exited {code} but wrote nothing to {target}"
1331
+ )
1332
+ size = target.stat().st_size
1333
+ warnings: list[str] = []
1334
+ if size == 0:
1335
+ _note(warnings, f"bugreport zip at {target} is empty")
1336
+ return AdbBugreportResult(
1337
+ serial=checked,
1338
+ path=str(target),
1339
+ size=size,
1340
+ warnings=warnings,
1341
+ evidence=Evidence(
1342
+ source=EVIDENCE_BUGREPORT,
1343
+ method="bugreport",
1344
+ confidence=Confidence.CERTAIN,
1345
+ detail=f"{size} bytes",
1346
+ extra={"serial": checked},
1347
+ ),
1348
+ )
1349
+
1350
+
1351
+
1352
+
1353
+
1354
+
1355
+
1356
+
1357
+
1358
+