los-bootstrap 0.9.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.
Files changed (54) hide show
  1. los_bootstrap/__init__.py +3 -0
  2. los_bootstrap/adb.py +160 -0
  3. los_bootstrap/apply.py +187 -0
  4. los_bootstrap/audit/__init__.py +6 -0
  5. los_bootstrap/audit/checks.py +226 -0
  6. los_bootstrap/audit/models.py +34 -0
  7. los_bootstrap/bootstrap.py +33 -0
  8. los_bootstrap/camera/__init__.py +35 -0
  9. los_bootstrap/camera/models.py +38 -0
  10. los_bootstrap/camera/profiles.py +285 -0
  11. los_bootstrap/camera/report.py +64 -0
  12. los_bootstrap/cli.py +731 -0
  13. los_bootstrap/device.py +49 -0
  14. los_bootstrap/fetch.py +94 -0
  15. los_bootstrap/flash/__init__.py +83 -0
  16. los_bootstrap/flash/checks.py +115 -0
  17. los_bootstrap/flash/fastboot.py +109 -0
  18. los_bootstrap/flash/flash.py +105 -0
  19. los_bootstrap/flash/guide.py +313 -0
  20. los_bootstrap/flash/heimdall.py +81 -0
  21. los_bootstrap/flash/models.py +73 -0
  22. los_bootstrap/flash/plan.py +212 -0
  23. los_bootstrap/flash/report.py +150 -0
  24. los_bootstrap/harden/__init__.py +21 -0
  25. los_bootstrap/harden/checks.py +353 -0
  26. los_bootstrap/harden/interactive.py +101 -0
  27. los_bootstrap/harden/models.py +38 -0
  28. los_bootstrap/harden/report.py +103 -0
  29. los_bootstrap/location/__init__.py +26 -0
  30. los_bootstrap/location/checks.py +278 -0
  31. los_bootstrap/location/compat.py +137 -0
  32. los_bootstrap/location/models.py +36 -0
  33. los_bootstrap/location/report.py +131 -0
  34. los_bootstrap/logo.py +25 -0
  35. los_bootstrap/plan.py +225 -0
  36. los_bootstrap/profiles.py +190 -0
  37. los_bootstrap/profiles_data/camera.yml +32 -0
  38. los_bootstrap/profiles_data/max-tools.yml +177 -0
  39. los_bootstrap/profiles_data/messaging-light.yml +29 -0
  40. los_bootstrap/profiles_data/minimal.yml +28 -0
  41. los_bootstrap/profiles_data/privacy-default.yml +57 -0
  42. los_bootstrap/py.typed +0 -0
  43. los_bootstrap/report.py +118 -0
  44. los_bootstrap/wizard/__init__.py +9 -0
  45. los_bootstrap/wizard/menu.py +474 -0
  46. los_bootstrap/wizard/prompt.py +80 -0
  47. los_bootstrap/wizard/prose.py +396 -0
  48. los_bootstrap/wizard/render.py +141 -0
  49. los_bootstrap-0.9.0.dist-info/METADATA +1115 -0
  50. los_bootstrap-0.9.0.dist-info/RECORD +54 -0
  51. los_bootstrap-0.9.0.dist-info/WHEEL +5 -0
  52. los_bootstrap-0.9.0.dist-info/entry_points.txt +2 -0
  53. los_bootstrap-0.9.0.dist-info/licenses/LICENSE +674 -0
  54. los_bootstrap-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """los-bootstrap: CLI-first post-install assistant for LineageOS."""
2
+
3
+ __version__ = "0.9.0"
los_bootstrap/adb.py ADDED
@@ -0,0 +1,160 @@
1
+ """Thin, testable wrapper around the `adb` binary.
2
+
3
+ All ADB IO funnels through `Adb`. Tests inject a fake `runner` so no real
4
+ `adb` is required. Read-only methods are always available. Mutating
5
+ methods (`install_apk`, `setting_put`) are used by the Phase 2 applier
6
+ and only ever run after the user passes `--confirm`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from typing import Callable, Optional, Sequence
16
+
17
+
18
+ CommandRunner = Callable[[Sequence[str]], "AdbResult"]
19
+
20
+
21
+ class AdbNotFoundError(RuntimeError):
22
+ """Raised when the `adb` binary cannot be located on PATH."""
23
+
24
+
25
+ class AdbCommandError(RuntimeError):
26
+ """Raised when an `adb` invocation exits non-zero."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AdbResult:
31
+ returncode: int
32
+ stdout: str
33
+ stderr: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AdbDevice:
38
+ serial: str
39
+ state: str # "device", "unauthorized", "offline", ...
40
+
41
+ @property
42
+ def ready(self) -> bool:
43
+ return self.state == "device"
44
+
45
+
46
+ def _default_runner(argv: Sequence[str]) -> AdbResult:
47
+ if shutil.which(argv[0]) is None:
48
+ raise AdbNotFoundError(f"`{argv[0]}` not found on PATH")
49
+ proc = subprocess.run(argv, capture_output=True, text=True, check=False)
50
+ return AdbResult(proc.returncode, proc.stdout, proc.stderr)
51
+
52
+
53
+ class Adb:
54
+ """Wrapper around `adb`. Inject `runner` in tests."""
55
+
56
+ def __init__(
57
+ self,
58
+ serial: Optional[str] = None,
59
+ runner: Optional[CommandRunner] = None,
60
+ binary: str = "adb",
61
+ ) -> None:
62
+ self.serial = serial
63
+ self.binary = binary
64
+ self._run = runner or _default_runner
65
+
66
+ def _argv(self, *args: str) -> list[str]:
67
+ argv = [self.binary]
68
+ if self.serial:
69
+ argv += ["-s", self.serial]
70
+ argv += list(args)
71
+ return argv
72
+
73
+ def raw(self, *args: str) -> AdbResult:
74
+ return self._run(self._argv(*args))
75
+
76
+ def shell(self, command: str) -> str:
77
+ result = self.raw("shell", command)
78
+ if result.returncode != 0:
79
+ raise AdbCommandError(
80
+ f"adb shell `{command}` failed: {result.stderr.strip()}"
81
+ )
82
+ return result.stdout
83
+
84
+ def list_devices(self) -> list[AdbDevice]:
85
+ result = self.raw("devices")
86
+ if result.returncode != 0:
87
+ raise AdbCommandError(f"adb devices failed: {result.stderr.strip()}")
88
+ return parse_devices(result.stdout)
89
+
90
+ def getprop(self, key: str) -> str:
91
+ return self.shell(f"getprop {key}").strip()
92
+
93
+ def package_installed(self, package: str) -> bool:
94
+ # `pm list packages <pkg>` returns lines of form `package:<name>`.
95
+ out = self.shell(f"pm list packages {package}")
96
+ for line in out.splitlines():
97
+ if line.strip() == f"package:{package}":
98
+ return True
99
+ return False
100
+
101
+ def setting_get(self, namespace: str, key: str) -> str:
102
+ """Return the current value of `settings get <namespace> <key>`.
103
+
104
+ The `settings` CLI prints `null` when a key is unset; we
105
+ normalize that to an empty string for easier comparison.
106
+ """
107
+ out = self.shell(f"settings get {namespace} {key}").strip()
108
+ return "" if out == "null" else out
109
+
110
+ def install_apk(self, apk_path: str, replace: bool = True) -> str:
111
+ """Install an APK from a local path. Mutating; applier-only."""
112
+ args = ["install"]
113
+ if replace:
114
+ args.append("-r")
115
+ args.append(apk_path)
116
+ result = self.raw(*args)
117
+ if result.returncode != 0:
118
+ raise AdbCommandError(
119
+ f"adb install {apk_path} failed: "
120
+ f"{(result.stderr or result.stdout).strip()}"
121
+ )
122
+ return result.stdout
123
+
124
+ def setting_put(self, namespace: str, key: str, value: str) -> None:
125
+ """Run `settings put <namespace> <key> <value>`. Mutating."""
126
+ self.shell(
127
+ f"settings put {namespace} {key} {shlex.quote(value)}"
128
+ )
129
+
130
+ def reboot(self, target: Optional[str] = None) -> None:
131
+ """Reboot device, optionally to 'bootloader' or 'recovery'. Mutating."""
132
+ args = ["reboot"]
133
+ if target:
134
+ args.append(target)
135
+ result = self.raw(*args)
136
+ if result.returncode != 0:
137
+ raise AdbCommandError(f"adb reboot failed: {result.stderr.strip()}")
138
+
139
+ def sideload(self, zip_path: str) -> str:
140
+ """Run `adb sideload <zip>`. Device must be in recovery sideload mode. Mutating."""
141
+ result = self.raw("sideload", zip_path)
142
+ if result.returncode != 0:
143
+ raise AdbCommandError(
144
+ f"adb sideload {zip_path} failed: "
145
+ f"{(result.stderr or result.stdout).strip()}"
146
+ )
147
+ return result.stdout
148
+
149
+
150
+ def parse_devices(stdout: str) -> list[AdbDevice]:
151
+ """Parse the output of `adb devices`."""
152
+ devices: list[AdbDevice] = []
153
+ for line in stdout.splitlines():
154
+ line = line.strip()
155
+ if not line or line.startswith("List of devices"):
156
+ continue
157
+ parts = line.split()
158
+ if len(parts) >= 2:
159
+ devices.append(AdbDevice(serial=parts[0], state=parts[1]))
160
+ return devices
los_bootstrap/apply.py ADDED
@@ -0,0 +1,187 @@
1
+ """Execute a `Plan`.
2
+
3
+ The applier only ever runs after the user passes `--confirm`. Each
4
+ executable step is written to stdout before it runs so the user has a
5
+ last chance to spot something they did not expect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sys
12
+ import tempfile
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional, TextIO
16
+
17
+ from .adb import Adb, AdbCommandError
18
+ from . import fetch
19
+ from .fetch import FetchError
20
+ from .plan import Plan, PlanStep, StepKind
21
+
22
+
23
+ @dataclass
24
+ class StepResult:
25
+ step: PlanStep
26
+ status: str # "ok" | "skipped" | "manual" | "missing_apk" | "error"
27
+ detail: str = ""
28
+
29
+
30
+ @dataclass
31
+ class ApplyResult:
32
+ profile_name: str
33
+ results: list[StepResult] = field(default_factory=list)
34
+
35
+ def counts(self) -> dict[str, int]:
36
+ out: dict[str, int] = {}
37
+ for r in self.results:
38
+ out[r.status] = out.get(r.status, 0) + 1
39
+ return out
40
+
41
+ def had_errors(self) -> bool:
42
+ return any(r.status == "error" for r in self.results)
43
+
44
+
45
+ def _extract_apk_path(command: str) -> Optional[Path]:
46
+ # `adb install -r /abs/path/to.apk` — pull out the last token.
47
+ m = re.match(r"^adb install(?:\s+-r)?\s+(.+)$", command)
48
+ if not m:
49
+ return None
50
+ return Path(m.group(1))
51
+
52
+
53
+ def _extract_setting(command: str) -> Optional[tuple[str, str, str]]:
54
+ m = re.match(
55
+ r"^adb shell settings put (\w+) (\S+) (.+)$",
56
+ command,
57
+ )
58
+ if not m:
59
+ return None
60
+ return m.group(1), m.group(2), m.group(3)
61
+
62
+
63
+ def apply_plan(
64
+ adb: Adb,
65
+ plan: Plan,
66
+ *,
67
+ dry_run: bool = False,
68
+ out: TextIO = sys.stdout,
69
+ apk_dir: Optional[Path] = None,
70
+ ) -> ApplyResult:
71
+ """Run the executable steps in `plan` against `adb`.
72
+
73
+ The caller is responsible for gating on `--confirm`. `dry_run`
74
+ prints the commands without running them. `apk_dir` is used as the
75
+ cache directory for downloaded APKs; a temp dir is created if needed.
76
+ """
77
+ result = ApplyResult(profile_name=plan.profile_name)
78
+ _dl_dir: Optional[Path] = apk_dir # resolved on first download if still None
79
+
80
+ out.write(f"Applying profile: {plan.profile_name}\n")
81
+ if dry_run:
82
+ out.write("(dry run — no commands will be executed)\n")
83
+ out.write("\n")
84
+
85
+ for i, step in enumerate(plan.steps, 1):
86
+ prefix = f"[{i:2d}/{len(plan.steps)}]"
87
+
88
+ if step.kind == StepKind.SKIP:
89
+ out.write(f"{prefix} skip : {step.summary}\n")
90
+ result.results.append(
91
+ StepResult(step=step, status="skipped", detail=step.skipped_reason or "")
92
+ )
93
+ continue
94
+
95
+ if step.kind == StepKind.MANUAL_INSTALL:
96
+ out.write(f"{prefix} manual : {step.summary}\n")
97
+ result.results.append(
98
+ StepResult(step=step, status="manual", detail="user must install via store")
99
+ )
100
+ continue
101
+
102
+ if step.kind == StepKind.INSTALL_APK:
103
+ if step.download_url:
104
+ if dry_run:
105
+ out.write(f"{prefix} fetch : {step.download_url} (dry run)\n")
106
+ result.results.append(StepResult(step=step, status="ok", detail="dry-run"))
107
+ continue
108
+ if _dl_dir is None:
109
+ _dl_dir = Path(tempfile.mkdtemp())
110
+ out.write(f"note: downloading APKs to {_dl_dir}\n")
111
+ out.write(f"{prefix} fetch : {step.download_url}\n")
112
+ try:
113
+ apk_path = fetch.download_apk(step.download_url, _dl_dir)
114
+ except FetchError as exc:
115
+ out.write(f" error : {exc}\n")
116
+ result.results.append(StepResult(step=step, status="error", detail=str(exc)))
117
+ continue
118
+ out.write(f" run : adb install -r {apk_path}\n")
119
+ try:
120
+ stdout = adb.install_apk(str(apk_path))
121
+ result.results.append(StepResult(step=step, status="ok", detail=stdout.strip()))
122
+ except AdbCommandError as exc:
123
+ out.write(f" error : {exc}\n")
124
+ result.results.append(StepResult(step=step, status="error", detail=str(exc)))
125
+ continue
126
+
127
+ if step.missing_apk_path is not None or step.command is None:
128
+ out.write(
129
+ f"{prefix} skip : {step.summary} "
130
+ f"(missing APK: {step.missing_apk_path})\n"
131
+ )
132
+ result.results.append(
133
+ StepResult(
134
+ step=step,
135
+ status="missing_apk",
136
+ detail=step.missing_apk_path or "no command",
137
+ )
138
+ )
139
+ continue
140
+ apk_path = _extract_apk_path(step.command)
141
+ out.write(f"{prefix} run : {step.command}\n")
142
+ if dry_run or apk_path is None:
143
+ result.results.append(
144
+ StepResult(step=step, status="ok", detail="dry-run")
145
+ )
146
+ continue
147
+ try:
148
+ stdout = adb.install_apk(str(apk_path))
149
+ result.results.append(
150
+ StepResult(step=step, status="ok", detail=stdout.strip())
151
+ )
152
+ except AdbCommandError as exc:
153
+ out.write(f" error : {exc}\n")
154
+ result.results.append(
155
+ StepResult(step=step, status="error", detail=str(exc))
156
+ )
157
+ continue
158
+
159
+ if step.kind == StepKind.SET_SETTING:
160
+ if step.command is None:
161
+ continue
162
+ parsed = _extract_setting(step.command)
163
+ out.write(f"{prefix} run : {step.command}\n")
164
+ if dry_run or parsed is None:
165
+ result.results.append(
166
+ StepResult(step=step, status="ok", detail="dry-run")
167
+ )
168
+ continue
169
+ namespace, key, value = parsed
170
+ try:
171
+ adb.setting_put(namespace, key, value)
172
+ result.results.append(StepResult(step=step, status="ok"))
173
+ except AdbCommandError as exc:
174
+ out.write(f" error : {exc}\n")
175
+ result.results.append(
176
+ StepResult(step=step, status="error", detail=str(exc))
177
+ )
178
+ continue
179
+
180
+ counts = result.counts()
181
+ out.write("\n")
182
+ out.write(
183
+ "Done: "
184
+ + ", ".join(f"{v} {k}" for k, v in sorted(counts.items()))
185
+ + ".\n"
186
+ )
187
+ return result
@@ -0,0 +1,6 @@
1
+ """Audit module: read-only privacy/degoogle inspection."""
2
+
3
+ from .models import AuditFinding, AuditReport, Severity
4
+ from .checks import run_audit
5
+
6
+ __all__ = ["AuditFinding", "AuditReport", "Severity", "run_audit"]
@@ -0,0 +1,226 @@
1
+ """Individual audit checks plus the orchestrator.
2
+
3
+ Each check takes the `Adb` wrapper plus already-collected `DeviceFacts`
4
+ and returns zero or more `AuditFinding`s. Checks must be read-only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Callable, Iterable
10
+
11
+ from ..adb import Adb
12
+ from ..device import DeviceFacts
13
+ from .models import AuditFinding, AuditReport, Severity
14
+
15
+
16
+ # Packages we care about. Keep this list short and curated; long lists
17
+ # rot fast and add noise. Phase 2 can move this to a YAML profile.
18
+ GMS_PACKAGE = "com.google.android.gms"
19
+ GSF_PACKAGE = "com.google.android.gsf"
20
+
21
+ COMMON_GOOGLE_PACKAGES = (
22
+ "com.android.vending", # Play Store
23
+ "com.google.android.apps.maps", # Maps
24
+ "com.google.android.gm", # Gmail
25
+ "com.google.android.youtube", # YouTube
26
+ "com.google.android.inputmethod.latin", # Gboard
27
+ )
28
+
29
+
30
+ CheckFn = Callable[[Adb, DeviceFacts], Iterable[AuditFinding]]
31
+
32
+
33
+ def check_lineage_identity(_adb: Adb, facts: DeviceFacts) -> Iterable[AuditFinding]:
34
+ if facts.is_lineage:
35
+ yield AuditFinding(
36
+ check="rom.lineage",
37
+ title="LineageOS detected",
38
+ severity=Severity.INFO,
39
+ detail=f"ro.lineage.version = {facts.lineage_version!r}.",
40
+ )
41
+ else:
42
+ yield AuditFinding(
43
+ check="rom.lineage",
44
+ title="Not a LineageOS build",
45
+ severity=Severity.INFO,
46
+ detail=(
47
+ "No ro.lineage.version property found. los-bootstrap will "
48
+ "still work on AOSP-derived ROMs, but device-specific "
49
+ "guidance assumes LineageOS conventions."
50
+ ),
51
+ )
52
+
53
+
54
+ def check_gms(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
55
+ if adb.package_installed(GMS_PACKAGE):
56
+ yield AuditFinding(
57
+ check="gms.present",
58
+ title="Google Mobile Services is installed",
59
+ severity=Severity.WARN,
60
+ detail=(
61
+ f"{GMS_PACKAGE} is installed. This indicates a GApps build "
62
+ "or microG. If you intended a fully degoogled setup, "
63
+ "investigate which build was flashed."
64
+ ),
65
+ recommendation=(
66
+ "If unintended: reflash a vanilla LineageOS build. If this "
67
+ "is microG, ignore this finding."
68
+ ),
69
+ )
70
+ else:
71
+ yield AuditFinding(
72
+ check="gms.present",
73
+ title="No Google Mobile Services",
74
+ severity=Severity.OK,
75
+ detail=f"{GMS_PACKAGE} is not installed. Degoogled-friendly.",
76
+ )
77
+
78
+
79
+ def check_gsf(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
80
+ if adb.package_installed(GSF_PACKAGE):
81
+ yield AuditFinding(
82
+ check="gsf.present",
83
+ title="Google Services Framework is installed",
84
+ severity=Severity.WARN,
85
+ detail=f"{GSF_PACKAGE} is installed.",
86
+ recommendation="See GMS guidance — same root cause.",
87
+ )
88
+
89
+
90
+ def check_common_google_packages(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
91
+ found = [p for p in COMMON_GOOGLE_PACKAGES if adb.package_installed(p)]
92
+ if not found:
93
+ yield AuditFinding(
94
+ check="google.client_packages",
95
+ title="No common Google client packages installed",
96
+ severity=Severity.OK,
97
+ detail="None of the curated Google client packages were detected.",
98
+ )
99
+ return
100
+ yield AuditFinding(
101
+ check="google.client_packages",
102
+ title=f"{len(found)} Google client package(s) installed",
103
+ severity=Severity.WARN,
104
+ detail="Detected: " + ", ".join(found),
105
+ recommendation=(
106
+ "Consider replacements: F-Droid + Aurora Store, Organic Maps, "
107
+ "K-9 Mail / FairEmail, NewPipe / LibreTube, FlorisBoard / "
108
+ "HeliBoard."
109
+ ),
110
+ )
111
+
112
+
113
+ def check_adb_tcp(_adb: Adb, facts: DeviceFacts) -> Iterable[AuditFinding]:
114
+ port = facts.adb_tcp_port
115
+ if port and port.strip() and port != "0":
116
+ yield AuditFinding(
117
+ check="adb.tcp",
118
+ title="ADB-over-network is enabled",
119
+ severity=Severity.HIGH,
120
+ detail=(
121
+ f"service.adb.tcp.port = {port!r}. The device is exposing "
122
+ "ADB on the network. Anyone on the same network can attempt "
123
+ "to connect."
124
+ ),
125
+ recommendation=(
126
+ "Disable wireless ADB in Developer options unless you "
127
+ "actively need it on a trusted network."
128
+ ),
129
+ )
130
+ else:
131
+ yield AuditFinding(
132
+ check="adb.tcp",
133
+ title="ADB-over-network is not enabled",
134
+ severity=Severity.OK,
135
+ detail="service.adb.tcp.port is unset.",
136
+ )
137
+
138
+
139
+ def check_private_dns(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
140
+ mode = adb.shell("settings get global private_dns_mode").strip()
141
+ specifier = adb.shell("settings get global private_dns_specifier").strip()
142
+ if mode in ("", "null", "off"):
143
+ yield AuditFinding(
144
+ check="dns.private",
145
+ title="Private DNS is off",
146
+ severity=Severity.WARN,
147
+ detail=(
148
+ f"global private_dns_mode = {mode!r}. DNS lookups go to "
149
+ "whatever the network hands you, in cleartext."
150
+ ),
151
+ recommendation=(
152
+ "Set Private DNS to a hostname (DoT) like dns.quad9.net "
153
+ "in Settings > Network > Private DNS."
154
+ ),
155
+ )
156
+ return
157
+ if mode == "opportunistic":
158
+ yield AuditFinding(
159
+ check="dns.private",
160
+ title="Private DNS is opportunistic",
161
+ severity=Severity.INFO,
162
+ detail=(
163
+ "Android will use DoT when the upstream resolver supports "
164
+ "it, otherwise fall back to cleartext."
165
+ ),
166
+ )
167
+ return
168
+ if mode == "hostname":
169
+ yield AuditFinding(
170
+ check="dns.private",
171
+ title="Private DNS is enforced (DoT)",
172
+ severity=Severity.OK,
173
+ detail=(
174
+ f"global private_dns_mode = 'hostname', specifier = "
175
+ f"{specifier or '(unset)'!r}."
176
+ ),
177
+ )
178
+ return
179
+ yield AuditFinding(
180
+ check="dns.private",
181
+ title=f"Private DNS in unknown mode {mode!r}",
182
+ severity=Severity.INFO,
183
+ detail="Unrecognised private_dns_mode value; not interpreting.",
184
+ )
185
+
186
+
187
+ def check_screen_lock(adb: Adb, _facts: DeviceFacts) -> Iterable[AuditFinding]:
188
+ # `lockscreen.disabled` true means no lock at all on many AOSP ROMs.
189
+ raw = adb.shell("settings get secure lockscreen.disabled").strip()
190
+ disabled = raw == "1"
191
+ if disabled:
192
+ yield AuditFinding(
193
+ check="lockscreen.present",
194
+ title="Screen lock is disabled",
195
+ severity=Severity.HIGH,
196
+ detail="settings secure lockscreen.disabled = 1.",
197
+ recommendation=(
198
+ "Set a PIN, password, or passphrase. Without a screen lock, "
199
+ "device encryption keys at rest are weakened."
200
+ ),
201
+ )
202
+ else:
203
+ yield AuditFinding(
204
+ check="lockscreen.present",
205
+ title="Screen lock appears enabled",
206
+ severity=Severity.OK,
207
+ detail=f"settings secure lockscreen.disabled = {raw!r}.",
208
+ )
209
+
210
+
211
+ CHECKS: tuple[CheckFn, ...] = (
212
+ check_lineage_identity,
213
+ check_gms,
214
+ check_gsf,
215
+ check_common_google_packages,
216
+ check_adb_tcp,
217
+ check_private_dns,
218
+ check_screen_lock,
219
+ )
220
+
221
+
222
+ def run_audit(adb: Adb, facts: DeviceFacts) -> AuditReport:
223
+ findings: list[AuditFinding] = []
224
+ for check in CHECKS:
225
+ findings.extend(check(adb, facts))
226
+ return AuditReport(findings=tuple(findings))
@@ -0,0 +1,34 @@
1
+ """Data models for audit findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+
10
+ class Severity(str, Enum):
11
+ INFO = "info"
12
+ OK = "ok"
13
+ WARN = "warn"
14
+ HIGH = "high"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AuditFinding:
19
+ check: str # short id, e.g. "gms.present"
20
+ title: str # human-readable
21
+ severity: Severity
22
+ detail: str # one-paragraph explanation
23
+ recommendation: Optional[str] = None # actionable hint for `recommend`
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AuditReport:
28
+ findings: tuple[AuditFinding, ...] = field(default_factory=tuple)
29
+
30
+ def by_severity(self, sev: Severity) -> tuple[AuditFinding, ...]:
31
+ return tuple(f for f in self.findings if f.severity == sev)
32
+
33
+ def has_concerns(self) -> bool:
34
+ return any(f.severity in (Severity.WARN, Severity.HIGH) for f in self.findings)
@@ -0,0 +1,33 @@
1
+ """Phase 1 bootstrap recommendations.
2
+
3
+ Phase 1 is non-binding: we surface suggestions derived from audit
4
+ findings. We do not install anything. Phase 2 turns this into an
5
+ applyable plan.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .audit.models import AuditReport, Severity
11
+
12
+
13
+ GENERIC_TIPS: tuple[str, ...] = (
14
+ "Install F-Droid (https://f-droid.org) — verify the APK signature.",
15
+ "Install Aurora Store from F-Droid for proprietary apps without a Google account.",
16
+ "Set up a non-Google DNS (e.g. NextDNS, Quad9) under Settings > Network > Private DNS.",
17
+ "Set automatic time/timezone source to NITZ-only or NTP, not Google.",
18
+ "Disable connectivity check or point it at a non-Google endpoint if your ROM supports it.",
19
+ )
20
+
21
+
22
+ def recommendations(report: AuditReport) -> list[str]:
23
+ """Map findings to short, actionable suggestions."""
24
+ out: list[str] = []
25
+ seen: set[str] = set()
26
+ for finding in report.findings:
27
+ if finding.severity in (Severity.WARN, Severity.HIGH) and finding.recommendation:
28
+ if finding.check not in seen:
29
+ out.append(f"[{finding.severity.value}] {finding.title}: {finding.recommendation}")
30
+ seen.add(finding.check)
31
+ out.append("--- general tips ---")
32
+ out.extend(GENERIC_TIPS)
33
+ return out
@@ -0,0 +1,35 @@
1
+ """Camera / GCam port profiles — Phase 5.
2
+
3
+ Public surface:
4
+ CAMERA_PROFILES — tuple of CameraProfile
5
+ find_camera_profile(str) -> CameraProfile | None
6
+ render_profile_list() -> str
7
+ render_profile(profile) -> str
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from .models import CameraPort, CameraProfile, XmlConfig
15
+ from .profiles import CAMERA_PROFILES
16
+ from .report import render_profile, render_profile_list
17
+
18
+ __all__ = [
19
+ "CAMERA_PROFILES",
20
+ "CameraPort",
21
+ "CameraProfile",
22
+ "XmlConfig",
23
+ "find_camera_profile",
24
+ "render_profile",
25
+ "render_profile_list",
26
+ ]
27
+
28
+
29
+ def find_camera_profile(codename: str) -> Optional[CameraProfile]:
30
+ """Return the camera profile for the given device codename, or None."""
31
+ codename_lower = codename.lower()
32
+ for profile in CAMERA_PROFILES:
33
+ if profile.codename.lower() == codename_lower:
34
+ return profile
35
+ return None