untether-bt 0.7.0__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.
@@ -0,0 +1,153 @@
1
+ """untether-bt — a Bluetooth Swiss-army-knife for reverse engineering, troubleshooting, and engineering.
2
+
3
+ First-class Bluetooth **Classic (RFCOMM/SPP)** support — reachable from any host or from Home
4
+ Assistant via the companion ``untether_spp`` ESP32 bridge — plus the protocol primitives the
5
+ BLE-only ecosystem leaves to you. Includes: the framing/codec engine, the SPP bridge client, the
6
+ advertisement decoder, and the full reverse-engineering pipeline: the live ADB/UIAutomator driver
7
+ (drive the vendor app, mark each action) → btsnoop capture → HCI/ATT extraction → UI-action↔
8
+ wire-byte correlation. jadx/Frida wrappers and SDP/GATT-over-bleak follow.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .advertising import (
14
+ ADStructure,
15
+ flags,
16
+ local_name,
17
+ manufacturer_data,
18
+ parse_ad,
19
+ service_data,
20
+ service_uuids16,
21
+ )
22
+ from .android import AdbError, AdbRunner, AndroidDriver, extract_btsnoop_from_zip
23
+ from .apk import (
24
+ ApkAnalysis,
25
+ Finding,
26
+ analyze_apk,
27
+ analyze_tree,
28
+ decompile_apk,
29
+ pull_apk,
30
+ )
31
+ from .frida import FridaSession, hook_script_path, parse_hook_message
32
+ from .gatt import GattClient, normalize_uuid
33
+ from .numbers import (
34
+ company_name,
35
+ describe_uuid,
36
+ gatt_name,
37
+ sdp_service_name,
38
+ uuid16_to_128,
39
+ uuid128_to_16,
40
+ )
41
+ from .sdp import (
42
+ find_rfcomm_channels,
43
+ parse_data_element,
44
+ parse_records,
45
+ parse_ssa_response,
46
+ rfcomm_channel,
47
+ spp_channel,
48
+ )
49
+ from .uiauto import UiNode, find_node, parse_ui_dump
50
+ from .btsnoop import (
51
+ Btsnoop,
52
+ BtsnoopRecord,
53
+ decompress_btsnooz,
54
+ is_btsnooz,
55
+ load_btsnoop,
56
+ make_record,
57
+ parse_btsnoop,
58
+ write_btsnoop,
59
+ )
60
+ from .capture import Capture, Correlation, Mark, Recorder, WireEvent, correlate
61
+ from .framing import (
62
+ DIVOOM_NEWMODE,
63
+ DIVOOM_STUFFED,
64
+ Frame,
65
+ Framing,
66
+ Stuffing,
67
+ crc_sum16,
68
+ )
69
+ from .hci import AttPdu, HciPacket, L2capPayload, att_pdus, hci_packets, l2cap_payloads
70
+ from .spp import AsyncSppBridge, SppBridge
71
+ from .connection import SppConnection
72
+
73
+ __version__ = "0.7.0"
74
+
75
+ __all__ = [
76
+ "__version__",
77
+ # framing
78
+ "Framing",
79
+ "Frame",
80
+ "Stuffing",
81
+ "crc_sum16",
82
+ "DIVOOM_NEWMODE",
83
+ "DIVOOM_STUFFED",
84
+ # spp
85
+ "SppBridge",
86
+ "AsyncSppBridge",
87
+ "SppConnection",
88
+ # advertising
89
+ "ADStructure",
90
+ "parse_ad",
91
+ "manufacturer_data",
92
+ "service_data",
93
+ "service_uuids16",
94
+ "local_name",
95
+ "flags",
96
+ # capture / reverse-engineering
97
+ "Btsnoop",
98
+ "BtsnoopRecord",
99
+ "parse_btsnoop",
100
+ "write_btsnoop",
101
+ "make_record",
102
+ "decompress_btsnooz",
103
+ "is_btsnooz",
104
+ "load_btsnoop",
105
+ "HciPacket",
106
+ "L2capPayload",
107
+ "AttPdu",
108
+ "hci_packets",
109
+ "l2cap_payloads",
110
+ "att_pdus",
111
+ "Capture",
112
+ "WireEvent",
113
+ "Mark",
114
+ "Correlation",
115
+ "Recorder",
116
+ "correlate",
117
+ # android live driver (RE pipeline)
118
+ "AndroidDriver",
119
+ "AdbRunner",
120
+ "AdbError",
121
+ "extract_btsnoop_from_zip",
122
+ "UiNode",
123
+ "parse_ui_dump",
124
+ "find_node",
125
+ # static analysis (jadx)
126
+ "ApkAnalysis",
127
+ "Finding",
128
+ "analyze_tree",
129
+ "analyze_apk",
130
+ "decompile_apk",
131
+ "pull_apk",
132
+ # dynamic instrumentation (frida)
133
+ "FridaSession",
134
+ "parse_hook_message",
135
+ "hook_script_path",
136
+ # assigned numbers
137
+ "uuid16_to_128",
138
+ "uuid128_to_16",
139
+ "company_name",
140
+ "gatt_name",
141
+ "sdp_service_name",
142
+ "describe_uuid",
143
+ # sdp
144
+ "parse_data_element",
145
+ "parse_records",
146
+ "parse_ssa_response",
147
+ "rfcomm_channel",
148
+ "find_rfcomm_channels",
149
+ "spp_channel",
150
+ # gatt (over bleak)
151
+ "GattClient",
152
+ "normalize_uuid",
153
+ ]
@@ -0,0 +1,104 @@
1
+ """BLE advertisement parsing — the passive-broadcast layer.
2
+
3
+ Many BLE sensors never accept a connection; they broadcast their state in the advertisement's
4
+ manufacturer- or service-data. This module parses the AD structure list per the Core Specification
5
+ Supplement (each element is ``[length][AD type][data]``, where ``length`` counts the type byte but
6
+ not itself), and pulls out the fields you actually reverse-engineer against.
7
+
8
+ Note the endianness traps the spec calls out: the 16-bit Company Identifier (in manufacturer data)
9
+ and 16-bit Service-Data UUIDs are **little-endian**.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ # AD type codes (Assigned Numbers / CSS)
17
+ AD_FLAGS = 0x01
18
+ AD_UUID16_INCOMPLETE = 0x02
19
+ AD_UUID16_COMPLETE = 0x03
20
+ AD_NAME_SHORT = 0x08
21
+ AD_NAME_COMPLETE = 0x09
22
+ AD_TX_POWER = 0x0A
23
+ AD_SERVICE_DATA_16 = 0x16
24
+ AD_SERVICE_DATA_32 = 0x20
25
+ AD_SERVICE_DATA_128 = 0x21
26
+ AD_MANUFACTURER = 0xFF
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ADStructure:
31
+ type: int
32
+ data: bytes
33
+
34
+
35
+ def parse_ad(payload: bytes) -> list[ADStructure]:
36
+ """Parse a raw advertisement (or scan-response) payload into AD structures.
37
+
38
+ Tolerant of the trailing zero-padding controllers add, and of truncation.
39
+ """
40
+ out: list[ADStructure] = []
41
+ i, n = 0, len(payload)
42
+ while i < n:
43
+ length = payload[i]
44
+ if length == 0:
45
+ break # padding
46
+ if i + 1 + length > n:
47
+ break # truncated
48
+ out.append(ADStructure(payload[i + 1], payload[i + 2 : i + 1 + length]))
49
+ i += 1 + length
50
+ return out
51
+
52
+
53
+ def _structs(ad: bytes | list[ADStructure]) -> list[ADStructure]:
54
+ return parse_ad(ad) if isinstance(ad, (bytes, bytearray)) else ad
55
+
56
+
57
+ def manufacturer_data(ad: bytes | list[ADStructure]) -> tuple[int, bytes] | None:
58
+ """Return ``(company_id, data)`` from the Manufacturer Specific Data (0xFF), or None.
59
+
60
+ ``company_id`` is decoded little-endian per spec.
61
+ """
62
+ for s in _structs(ad):
63
+ if s.type == AD_MANUFACTURER and len(s.data) >= 2:
64
+ return int.from_bytes(s.data[:2], "little"), s.data[2:]
65
+ return None
66
+
67
+
68
+ def service_data(ad: bytes | list[ADStructure]) -> dict[int, bytes]:
69
+ """Map 16-bit Service-Data UUID -> its data bytes (UUID decoded little-endian)."""
70
+ out: dict[int, bytes] = {}
71
+ for s in _structs(ad):
72
+ if s.type == AD_SERVICE_DATA_16 and len(s.data) >= 2:
73
+ out[int.from_bytes(s.data[:2], "little")] = s.data[2:]
74
+ return out
75
+
76
+
77
+ def service_uuids16(ad: bytes | list[ADStructure]) -> list[int]:
78
+ """All advertised 16-bit service UUIDs (complete + incomplete lists), little-endian."""
79
+ out: list[int] = []
80
+ for s in _structs(ad):
81
+ if s.type in (AD_UUID16_INCOMPLETE, AD_UUID16_COMPLETE):
82
+ for j in range(0, len(s.data) - 1, 2):
83
+ out.append(int.from_bytes(s.data[j : j + 2], "little"))
84
+ return out
85
+
86
+
87
+ def local_name(ad: bytes | list[ADStructure]) -> str | None:
88
+ """The advertised local name (complete preferred over shortened)."""
89
+ short = None
90
+ for s in _structs(ad):
91
+ if s.type == AD_NAME_COMPLETE:
92
+ return s.data.decode("utf-8", "replace")
93
+ if s.type == AD_NAME_SHORT and short is None:
94
+ short = s.data.decode("utf-8", "replace")
95
+ return short
96
+
97
+
98
+ def flags(ad: bytes | list[ADStructure]) -> int | None:
99
+ """The Flags byte (0x01), if present. bit0 LE Limited Disc, bit1 LE General Disc,
100
+ bit2 BR/EDR Not Supported."""
101
+ for s in _structs(ad):
102
+ if s.type == AD_FLAGS and s.data:
103
+ return s.data[0]
104
+ return None
untether_bt/android.py ADDED
@@ -0,0 +1,159 @@
1
+ """Drive an Android device over ADB to reverse-engineer its Bluetooth app.
2
+
3
+ The live half of the RE loop: enable HCI snoop, drive the vendor app by accessibility label while
4
+ **marking** each UI action, then pull the capture and (with :func:`untether_bt.capture.correlate`)
5
+ see exactly which wire bytes each action produced.
6
+
7
+ The driver runs adb through an injectable runner, so the pure logic is fully unit-tested and the
8
+ real device path is a thin shell. Capture is pulled via ``adb bugreport`` (works unrooted) or a
9
+ direct path (rooted).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import glob
15
+ import os
16
+ import tempfile
17
+ import zipfile
18
+ from typing import Protocol
19
+
20
+ from .capture import Recorder
21
+ from .uiauto import UiNode, find_node, parse_ui_dump
22
+
23
+ _UI_REMOTE = "/sdcard/untether_ui.xml"
24
+ _DEFAULT_SNOOP_PATH = "/data/misc/bluetooth/logs/btsnoop_hci.log"
25
+
26
+
27
+ class AdbError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class Runner(Protocol):
32
+ def run(self, *args: str) -> str: ...
33
+ def run_bytes(self, *args: str) -> bytes: ...
34
+ def shell(self, *args: str) -> str: ...
35
+
36
+
37
+ class AdbRunner:
38
+ """Default runner: shells out to the ``adb`` binary (optionally targeting a serial)."""
39
+
40
+ def __init__(self, serial: str | None = None, adb_path: str = "adb", timeout: float = 90.0):
41
+ self._base = [adb_path] + (["-s", serial] if serial else [])
42
+ self._timeout = timeout
43
+
44
+ def _exec(self, args: tuple[str, ...], *, text: bool):
45
+ import subprocess
46
+
47
+ p = subprocess.run( # noqa: S603
48
+ [*self._base, *args], capture_output=True, timeout=self._timeout
49
+ )
50
+ if p.returncode != 0:
51
+ raise AdbError(f"adb {' '.join(args)} failed: {p.stderr.decode('utf-8', 'replace')[:300]}")
52
+ return p.stdout.decode("utf-8", "replace") if text else p.stdout
53
+
54
+ def run(self, *args: str) -> str:
55
+ return self._exec(args, text=True)
56
+
57
+ def run_bytes(self, *args: str) -> bytes:
58
+ return self._exec(args, text=False)
59
+
60
+ def shell(self, *args: str) -> str:
61
+ return self.run("shell", *args)
62
+
63
+
64
+ def extract_btsnoop_from_zip(zip_path: str) -> bytes:
65
+ """Pull the btsnoop_hci.log out of an ``adb bugreport`` zip."""
66
+ with zipfile.ZipFile(zip_path) as z:
67
+ names = z.namelist()
68
+ cands = [n for n in names if "btsnoop" in n.lower()]
69
+ if not cands:
70
+ raise AdbError("no btsnoop log found in bugreport zip")
71
+ # prefer the real uncompressed btsnoop_hci.log, then largest
72
+ cands.sort(key=lambda n: (not n.endswith("btsnoop_hci.log"), -z.getinfo(n).file_size))
73
+ return z.read(cands[0])
74
+
75
+
76
+ class AndroidDriver:
77
+ """High-level ADB driver for app-driven Bluetooth reverse engineering."""
78
+
79
+ def __init__(self, serial: str | None = None, runner: Runner | None = None):
80
+ self.adb: Runner = runner if runner is not None else AdbRunner(serial)
81
+
82
+ # ---- device ----
83
+ def devices(self) -> list[str]:
84
+ out = self.adb.run("devices")
85
+ return [
86
+ line.split("\t", 1)[0]
87
+ for line in out.splitlines()[1:]
88
+ if "\tdevice" in line
89
+ ]
90
+
91
+ def launch(self, package: str) -> None:
92
+ self.adb.shell("monkey", "-p", package, "-c", "android.intent.category.LAUNCHER", "1")
93
+
94
+ # ---- UI (accessibility-hierarchy driven) ----
95
+ def dump_ui(self) -> list[UiNode]:
96
+ self.adb.shell("uiautomator", "dump", _UI_REMOTE)
97
+ raw = self.adb.run_bytes("exec-out", "cat", _UI_REMOTE)
98
+ return parse_ui_dump(raw.decode("utf-8", "replace"))
99
+
100
+ def tap_xy(self, x: int, y: int) -> None:
101
+ self.adb.shell("input", "tap", str(x), str(y))
102
+
103
+ def tap(self, label: str, *, fields: tuple[str, ...] = ("text", "desc", "resource_id")) -> UiNode:
104
+ """Find a node by accessibility label and tap its center. Raises if not found."""
105
+ node = find_node(self.dump_ui(), label, fields=fields)
106
+ if node is None or node.center is None:
107
+ raise AdbError(f"no tappable node matching {label!r}")
108
+ self.tap_xy(*node.center)
109
+ return node
110
+
111
+ def text(self, value: str) -> None:
112
+ self.adb.shell("input", "text", value.replace(" ", "%s"))
113
+
114
+ def key(self, code: int) -> None:
115
+ self.adb.shell("input", "keyevent", str(code))
116
+
117
+ def back(self) -> None:
118
+ self.key(4)
119
+
120
+ def home(self) -> None:
121
+ self.key(3)
122
+
123
+ def swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> None:
124
+ self.adb.shell("input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms))
125
+
126
+ # ---- HCI snoop capture ----
127
+ def enable_hci_snoop(self, *, restart_bt: bool = True) -> None:
128
+ """Turn on Bluetooth HCI snoop logging.
129
+
130
+ Note: on many builds the durable switch is the Developer Options toggle
131
+ ("Enable Bluetooth HCI snoop log"); this sets the setting and (by default) restarts the
132
+ Bluetooth stack so it takes effect. If captures come back empty, flip the toggle by hand.
133
+ """
134
+ self.adb.shell("settings", "put", "global", "bluetooth_hci_log", "1")
135
+ if restart_bt:
136
+ self.adb.shell("svc", "bluetooth", "disable")
137
+ self.adb.shell("svc", "bluetooth", "enable")
138
+
139
+ def pull_btsnoop(self) -> bytes:
140
+ """Pull the current capture via ``adb bugreport`` (works unrooted). Returns btsnoop bytes."""
141
+ with tempfile.TemporaryDirectory() as d:
142
+ self.adb.run("bugreport", d)
143
+ zips = glob.glob(os.path.join(d, "*.zip"))
144
+ if not zips:
145
+ raise AdbError("adb bugreport produced no zip")
146
+ zips.sort(key=os.path.getmtime, reverse=True)
147
+ return extract_btsnoop_from_zip(zips[0])
148
+
149
+ def pull_btsnoop_path(self, path: str = _DEFAULT_SNOOP_PATH, *, su: bool = False) -> bytes:
150
+ """Pull the capture directly from its path (rooted devices)."""
151
+ args = ("exec-out", "su", "0", "cat", path) if su else ("exec-out", "cat", path)
152
+ return self.adb.run_bytes(*args)
153
+
154
+ # ---- the harness helper ----
155
+ def tap_and_mark(self, label: str, recorder: Recorder, **kw: object) -> UiNode:
156
+ """Tap a labelled node and timestamp the action into ``recorder`` for later correlation."""
157
+ node = self.tap(label, **kw) # type: ignore[arg-type]
158
+ recorder.mark(label)
159
+ return node
untether_bt/apk.py ADDED
@@ -0,0 +1,149 @@
1
+ """Static analysis of a vendor app — decompile with jadx, then map the Bluetooth surface.
2
+
3
+ This is Phase-0/1 of the methodology automated: get the APK (off a connected device or a local
4
+ file), decompile it, and answer the questions that steer everything downstream — **is this app BLE
5
+ GATT or Bluetooth Classic SPP?**, which **UUIDs**/characteristics does it touch, and **where** are
6
+ the command-building / write call sites.
7
+
8
+ ``analyze_tree`` is pure (point it at any decompiled source tree); ``decompile_apk`` / ``pull_apk``
9
+ are thin wrappers around ``jadx`` and ``adb``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections import Counter
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ from .android import AdbRunner, Runner
20
+
21
+ # --- signal patterns ---
22
+ _UUID128 = re.compile(
23
+ r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
24
+ )
25
+ _SPP_UUID = re.compile(r"0000110[0-9a-fA-F]-0000-1000-8000-00805f9b34fb", re.IGNORECASE)
26
+
27
+ # kind -> regex. Order matters only for readability; every line is checked against all.
28
+ _SIGNALS: dict[str, re.Pattern[str]] = {
29
+ "ble_write": re.compile(r"writeCharacteristic|writeDescriptor|\.setValue\("),
30
+ "ble_notify": re.compile(r"setCharacteristicNotification|onCharacteristicChanged"),
31
+ "ble_gatt": re.compile(r"BluetoothGatt|connectGatt|BluetoothLeScanner|BluetoothGattCharacteristic"),
32
+ "rfcomm": re.compile(r"createRfcommSocket|createInsecureRfcommSocket|BluetoothSocket"),
33
+ "byte_builder": re.compile(r"new byte\[\]\s*\{"),
34
+ }
35
+ _BLE_KINDS = {"ble_write", "ble_notify", "ble_gatt"}
36
+ _CLASSIC_KINDS = {"rfcomm"}
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Finding:
41
+ kind: str
42
+ file: str # path relative to the analyzed root
43
+ line: int
44
+ snippet: str
45
+
46
+
47
+ @dataclass
48
+ class ApkAnalysis:
49
+ transport: str = "unknown" # "ble" | "classic-spp" | "both" | "unknown"
50
+ gatt_uuids: list[str] = field(default_factory=list)
51
+ has_spp_uuid: bool = False
52
+ findings: list[Finding] = field(default_factory=list)
53
+
54
+ def by_kind(self) -> Counter[str]:
55
+ return Counter(f.kind for f in self.findings)
56
+
57
+ def summary(self) -> str:
58
+ counts = self.by_kind()
59
+ lines = [f"transport: {self.transport}"]
60
+ if self.has_spp_uuid:
61
+ lines.append("Serial Port (SPP) service UUID present (00001101)")
62
+ if self.gatt_uuids:
63
+ lines.append(f"{len(self.gatt_uuids)} distinct 128-bit UUID(s): " + ", ".join(self.gatt_uuids[:6]))
64
+ lines += [f" {k}: {counts[k]}" for k in sorted(counts)]
65
+ return "\n".join(lines)
66
+
67
+
68
+ def analyze_tree(root: str | Path, *, max_snippet: int = 160) -> ApkAnalysis:
69
+ """Walk a decompiled source tree (.java) and map its Bluetooth surface."""
70
+ root = Path(root)
71
+ findings: list[Finding] = []
72
+ uuids: set[str] = set()
73
+ has_spp = False
74
+ kinds_seen: set[str] = set()
75
+ for path in root.rglob("*.java"):
76
+ try:
77
+ text = path.read_text("utf-8", "replace")
78
+ except OSError:
79
+ continue
80
+ rel = str(path.relative_to(root))
81
+ for i, line in enumerate(text.splitlines(), 1):
82
+ for u in _UUID128.findall(line):
83
+ uuids.add(u.lower())
84
+ if _SPP_UUID.search(line):
85
+ has_spp = True
86
+ for kind, pat in _SIGNALS.items():
87
+ if pat.search(line):
88
+ kinds_seen.add(kind)
89
+ findings.append(Finding(kind, rel, i, line.strip()[:max_snippet]))
90
+
91
+ ble = bool(kinds_seen & _BLE_KINDS)
92
+ classic = has_spp or bool(kinds_seen & _CLASSIC_KINDS)
93
+ transport = (
94
+ "both" if ble and classic
95
+ else "ble" if ble
96
+ else "classic-spp" if classic
97
+ else "unknown"
98
+ )
99
+ return ApkAnalysis(
100
+ transport=transport,
101
+ gatt_uuids=sorted(uuids),
102
+ has_spp_uuid=has_spp,
103
+ findings=findings,
104
+ )
105
+
106
+
107
+ def decompile_apk(apk_path: str | Path, out_dir: str | Path, *, jadx: str = "jadx") -> Path:
108
+ """Run ``jadx -d <out_dir> <apk>`` and return the output directory."""
109
+ import subprocess
110
+
111
+ out = Path(out_dir)
112
+ p = subprocess.run( # noqa: S603
113
+ [jadx, "-d", str(out), str(apk_path)], capture_output=True, text=True
114
+ )
115
+ # jadx returns non-zero on partial-decompile warnings but still produces usable sources.
116
+ if not any(out.rglob("*.java")):
117
+ raise RuntimeError(f"jadx produced no sources: {p.stderr[:300]}")
118
+ return out
119
+
120
+
121
+ def pull_apk(package: str, dest: str | Path, *, runner: Runner | None = None) -> Path:
122
+ """Pull an installed app's base APK off a connected device via adb."""
123
+ adb = runner if runner is not None else AdbRunner()
124
+ paths = adb.run("shell", "pm", "path", package)
125
+ base = None
126
+ for line in paths.splitlines():
127
+ line = line.strip()
128
+ if line.startswith("package:") and line.endswith("base.apk"):
129
+ base = line[len("package:") :]
130
+ break
131
+ if base is None: # no explicit base.apk (single non-split) — take the first
132
+ for line in paths.splitlines():
133
+ if line.startswith("package:"):
134
+ base = line.strip()[len("package:") :]
135
+ break
136
+ if base is None:
137
+ raise RuntimeError(f"package {package} not found on device")
138
+ dest = Path(dest)
139
+ adb.run("pull", base, str(dest))
140
+ return dest
141
+
142
+
143
+ def analyze_apk(apk_path: str | Path, *, jadx: str = "jadx") -> ApkAnalysis:
144
+ """Decompile an APK (to a temp dir) and analyze it."""
145
+ import tempfile
146
+
147
+ with tempfile.TemporaryDirectory() as d:
148
+ out = decompile_apk(apk_path, d, jadx=jadx)
149
+ return analyze_tree(out)
untether_bt/bluez.py ADDED
@@ -0,0 +1,34 @@
1
+ """Live SDP browsing on Linux via BlueZ (optional, needs pybluez).
2
+
3
+ The only first-class way to *issue* a Classic SDP query from Python is a Classic-capable host stack.
4
+ On Linux/BlueZ that's pybluez (``pip install untether-bt[bluez]``; Linux only). This is a thin
5
+ wrapper that returns the dynamic RFCOMM/SPP channel — so you don't hardcode it. (On other hosts,
6
+ recover the channel from a capture via ``Capture.sdp_records()`` + ``sdp.spp_channel``, or let the
7
+ ``untether_spp`` ESP32 bridge SDP-discover it on-device with ``channel: 0``.)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ SPP_CLASS = "1101"
13
+
14
+
15
+ def browse_services(mac: str) -> list[dict]:
16
+ """All advertised services for a device (raw pybluez records)."""
17
+ import bluetooth # pybluez — Linux only
18
+
19
+ return bluetooth.find_service(address=mac)
20
+
21
+
22
+ def _is_spp(svc: dict) -> bool:
23
+ classes = svc.get("service-classes") or []
24
+ return any(SPP_CLASS in str(c).upper() for c in classes)
25
+
26
+
27
+ def spp_channel(mac: str) -> int | None:
28
+ """The RFCOMM channel of the device's Serial Port service (falls back to any RFCOMM port)."""
29
+ services = browse_services(mac)
30
+ rfcomm = [s for s in services if s.get("protocol") == "RFCOMM" and s.get("port")]
31
+ for s in rfcomm:
32
+ if _is_spp(s):
33
+ return int(s["port"])
34
+ return int(rfcomm[0]["port"]) if rfcomm else None