10 Commits

Author SHA1 Message Date
eric
8e224ac193 Fix-Magisk-bootstrap-compatibility 2026-04-09 03:29:37 +00:00
eric
604e7d60f0 Fix offline bundle verification 2026-04-09 02:33:32 +00:00
eric
8a1eebcb02 's' 2026-04-04 16:24:48 -05:00
eric
a48db482a8 's' 2026-04-04 16:18:52 -05:00
eric
9672f63cd9 's' 2026-04-04 16:08:32 -05:00
eric
e56629bcc2 's' 2026-04-04 16:02:19 -05:00
eric
78bbbc1303 's' 2026-04-04 15:58:27 -05:00
eric
e141bc000f 's' 2026-04-04 15:52:20 -05:00
eric
c59cd2da52 's' 2026-04-04 15:47:25 -05:00
eric
0e11bd85ff 's' 2026-04-04 15:42:13 -05:00
12 changed files with 908 additions and 41 deletions

169
README.md
View File

@@ -17,6 +17,8 @@ It keeps only the parts that were actually used:
- `run_redroid.py` - `run_redroid.py`
- `bootstrap_redroid.py` - `bootstrap_redroid.py`
- `verify_offline_bundle.py` - `verify_offline_bundle.py`
- `tools/compat.py` (host / GPU / runtime checks)
- `tools/ssh_remote.py` (Paramiko SSH: remote `docker` / `adb` diagnostics)
- `offline/README.md` - `offline/README.md`
- `offline/manifest.json` - `offline/manifest.json`
- `stuff/mindthegapps.py` - `stuff/mindthegapps.py`
@@ -29,9 +31,9 @@ It keeps only the parts that were actually used:
## Offline archive ## Offline archive
The repo tracks the offline bundle metadata under [offline](C:/Users/admin/Desktop/redroid-android13/offline). The large zip/apk/tar payloads are intentionally kept out of Git and should be stored locally under `offline/downloads/` and `offline/images/`. The repo tracks the offline bundle metadata under [offline](offline). The large zip/apk/tar payloads are intentionally kept out of Git and should be stored locally under `offline/downloads/` and `offline/images/`.
Use [offline/manifest.json](C:/Users/admin/Desktop/redroid-android13/offline/manifest.json) for checksums and source metadata, and [offline/README.md](C:/Users/admin/Desktop/redroid-android13/offline/README.md) for the expected local layout. Use [offline/manifest.json](offline/manifest.json) for checksums and source metadata, and [offline/README.md](offline/README.md) for the expected local layout.
Expected local files: Expected local files:
@@ -40,9 +42,22 @@ Expected local files:
- `offline/downloads/libhoudini_hack.zip` - `offline/downloads/libhoudini_hack.zip`
- `offline/downloads/magisk.apk` - `offline/downloads/magisk.apk`
- `offline/downloads/magisk.version` - `offline/downloads/magisk.version`
- `offline/images/redroid-redroid-13.0.0-latest.tar` - `offline/images/redroid-redroid-13.0.0-latest.tar` (optional if the base image is already in local Docker storage)
`redroid.py` now tries to pull `redroid/redroid:13.0.0-latest` first. If that fails, it falls back to the local image cache, then to `offline/images/redroid-redroid-13.0.0-latest.tar`. ### Build resource order (`redroid.py`)
- **Base image:** prefers an existing local `redroid/redroid:<ver>-latest`, then `offline/images/redroid-redroid-13.0.0-latest.tar` (`docker load`), then a registry pull (skipped when offline-only).
- **Zips / APK:** prefers files under `offline/downloads/` whose **MD5** matches the pinned upstream artifacts; downloads only if missing or mismatch (skipped when offline-only).
- **Strict no-network builds:** pass `--offline` or set `REDROID_OFFLINE=1` so missing bundles fail fast instead of downloading.
### Verify script notes
```bash
python verify_offline_bundle.py
```
- `magisk.version` is checked against exact **size** and **sha256** from `manifest.json`. A short version string without the expected newline/padding will report `size mismatch` even when `magisk.apk` is fine for the image build.
- A missing `offline/images/*.tar` is expected to **FAIL** here if you rely on a pre-pulled Docker image instead; that does not block `redroid.py` when the base image already exists locally.
## Dependencies ## Dependencies
@@ -50,10 +65,26 @@ Expected local files:
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
``` ```
## Verify the offline bundle ## Compatibility checks
The project probes the **host** before critical steps (no separate manual checklist required):
| Step | What it checks | Opt-out |
|------|----------------|---------|
| `redroid.py` | Container runtime (`docker`/`podman`) responds; OS is plausible for redroid | `--no-compat-check` |
| `run_redroid.py` | Same + Linux **`/dev/dri`** for GPU; **`binder_linux`** module hint on Linux | `--no-compat-check` |
**GPU mode (`run_redroid.py`):**
- **`--gpu-mode guest` (default):** software GLES — most stable on headless / server GPUs where host EGL inside the guest often breaks boot or **adbd**.
- **`--gpu-mode auto`:** stays **`guest`** unless **`REDROID_USE_HOST_GPU=1`** (or `true`/`yes`/`on`) **and** `/dev/dri` nodes exist; then **`host`** and `docker run --device …` per DRI node.
- **`--gpu-mode host`:** pass-through when DRI exists; otherwise **falls back to `guest`** with a warning.
Quick probe only:
```bash ```bash
python verify_offline_bundle.py python tools/compat.py
python tools/compat.py -c podman --gpu-mode guest
``` ```
## Build the 5570 image ## Build the 5570 image
@@ -62,6 +93,24 @@ python verify_offline_bundle.py
python redroid.py -mtg -i -m python redroid.py -mtg -i -m
``` ```
Build flags (short options):
| Flag | Long form | Meaning |
|------|-----------|---------|
| **`-mtg`** | `--install-mindthegapps` | Bake **MindTheGapps** (Google apps framework) into the image |
| **`-i`** | `--install-houdini` | Bake **libhoudini** + **redroid houdini hack** (ARM translation on x86) |
| **`-m`** | `--install-magisk` | Bake **Kitsune Mask / Magisk** bootless payload into the image |
Together they produce `redroid/redroid:13.0.0_mindthegapps_houdini_magisk`. None of these control the **screen**; display is set when you **run** the container (`run_redroid.py`).
Air-gapped (requires a complete `offline/` tree):
```bash
REDROID_OFFLINE=1 python redroid.py -mtg -i -m
# or
python redroid.py -mtg -i -m --offline
```
Expected image tag: Expected image tag:
```bash ```bash
@@ -70,6 +119,23 @@ redroid/redroid:13.0.0_mindthegapps_houdini_magisk
## Launch the container ## Launch the container
Headless servers have **no screen** to approve the USB debugging RSA dialog. Always pass **`--adb-insecure`** so boot props disable ADB RSA checks and apply **before** the Pixel user profile (`androidboot.adb.secure=0`, `ro.adb.secure=0`, `ro.secure=0`, `ro.debuggable=1`). **Do not expose the ADB port on a public network.**
### Persist after reboot (default)
By default **`run_redroid.py` does not pass `--rm`**: the container stays on disk when it stops, and uses **`--restart unless-stopped`** so **Docker/Podman will start it again after the daemon or host reboots** (unless you ran `docker stop <name>`).
- **Always restart** even after explicit stop: `--restart always`
- **No auto-restart** but still keep the container (no `--rm`): `--restart no`
- **One-off / dev only** (old behavior, removed on stop): pass **`--rm`**
On Ubuntu, ensure the engine starts on boot:
```bash
sudo systemctl enable docker --now
# or: sudo systemctl enable podman.socket --now # if you use Podman rootful similarly
```
```bash ```bash
python run_redroid.py \ python run_redroid.py \
--image redroid/redroid:13.0.0_mindthegapps_houdini_magisk \ --image redroid/redroid:13.0.0_mindthegapps_houdini_magisk \
@@ -77,9 +143,96 @@ python run_redroid.py \
--device-profile pixel-7-pro \ --device-profile pixel-7-pro \
--data-dir ~/data-redroid13 \ --data-dir ~/data-redroid13 \
--port 5570 \ --port 5570 \
--replace --replace \
--adb-insecure
``` ```
On the **same machine as Docker**, one-shot status + logs + `logcat` after start:
```bash
python run_redroid.py ... --adb-insecure --diagnose --diagnose-settle 20
```
Then:
```bash
adb kill-server && adb start-server
adb connect 127.0.0.1:5570
adb devices
```
If you still see `unauthorized` after an upgrade, stop the container, remove persisted keys on the host, and retry:
```bash
rm -rf ~/data-redroid13/misc/adb
```
## Remote diagnostics over SSH (Paramiko)
When redroid runs on **another Linux machine**, you can drive checks from your PC with **`tools/ssh_remote.py`** (uses [Paramiko](https://www.paramiko.org/), declared in `requirements.txt`).
**Do not put passwords in Git.** Use a key file or a one-time prompt:
```bash
pip install -r requirements.txt
export REDROID_SSH_HOST=192.168.2.179
export REDROID_SSH_USER=eric
python tools/ssh_remote.py --password-prompt diagnose
```
Key-based:
```bash
export REDROID_SSH_HOST=... REDROID_SSH_USER=eric REDROID_SSH_KEY=$HOME/.ssh/id_ed25519
python tools/ssh_remote.py diagnose
```
Optional env: **`REDROID_CONTAINER`** (default `redroid_13.0.0_mindthegapps_houdini_magisk`), **`REDROID_ADB_PORT`** (default `5570`). You can also pass **`--container-name`** / **`--adb-port`**.
Arbitrary remote command (note the **`--`** before the shell snippet):
```bash
python tools/ssh_remote.py -H 192.168.2.179 -U eric --password-prompt exec -- bash -lc 'docker ps -a | head -20'
```
The `shell` subcommand is a minimal pseudo-shell only; for a full TTY, use normal OpenSSH **`ssh`**.
## ADB `failed to connect` / connection refused
This means **nothing is listening on the host port** (not the same as `unauthorized`). Typical causes:
1. **Container exited** — if you used **`--rm`**, a crash removes the instance. By default the container is **kept**; use `docker ps -a` and `docker logs` to inspect. Ensure **`--restart`** is not `no` if you want auto-start after reboot.
2. **`androidboot.redroid_gpu_mode=host`** — Mesa/EGL or DRI inside the guest can fail on some hosts; Android never reaches **adbd**. **Workaround:** force software rendering:
```bash
python run_redroid.py ... --adb-insecure --gpu-mode guest
```
3. **Still booting** — wait longer, or use **`--wait-tcp 90`** on `run_redroid.py` to block until TCP opens and print logs if it does not.
## Black screen or flickering
With **`--gpu-mode guest`** (the default), the display uses **software GLES** (SwiftShader). On many **headless servers**, SurfaceFlinger may still show **black screen** or **flicker** — try **`--soft-display`** and more RAM. Host GPU needs **`REDROID_USE_HOST_GPU=1`** with **`--gpu-mode auto`** or **`--gpu-mode host`** when DRI works inside the guest.
**What to try**
1. **GPU on the host:** set **`REDROID_USE_HOST_GPU=1`** and **`--gpu-mode auto`**, or **`--gpu-mode host`**. If **ADB never connects**, host EGL is likely broken — use default **`--gpu-mode guest`**. Manual devices example:
```bash
docker run ... --device /dev/dri ...
```
The exact device path varies (Intel/AMD/NVIDIA, `nvidia-docker`, etc.); see [redroid-doc](https://github.com/remote-android/redroid-doc) GPU notes.
2. **Software GLES (`guest`)** — force low load on the virtual display (often reduces black screen / flicker):
```bash
python run_redroid.py ... --adb-insecure --gpu-mode guest --soft-display --memory 4g
```
`--soft-display` sets 720×1280, 15 FPS, dpi 280. You can tune further with `--extra-prop` if needed.
3. **First boot / SetupWizard** can sit on a dark screen for a while. Run **`bootstrap_redroid.py`** once `adb` shows `device` so provisioning and wizard are skipped as intended.
4. **Debug with logcat** (on the host, after `adb connect`):
```bash
adb -s 127.0.0.1:5570 logcat -d | grep -iE 'SurfaceFlinger|EGL|GLES|redroid'
```
## Apply the 5570 hardening ## Apply the 5570 hardening
```bash ```bash
@@ -98,7 +251,7 @@ This post-boot step:
- allows `com.android.shell` to use mock location - allows `com.android.shell` to use mock location
- applies the `Pixel 7 Pro / user / release-keys / locked / green` identity - applies the `Pixel 7 Pro / user / release-keys / locked / green` identity
With `--install-extreme-script`, the validated script [assets/redroid_extreme_post_fs_data.sh](C:/Users/admin/Desktop/redroid-android13/assets/redroid_extreme_post_fs_data.sh) is copied to `/data/adb/post-fs-data.d/20-redroid-extreme.sh` so that: With `--install-extreme-script`, the validated script [assets/redroid_extreme_post_fs_data.sh](assets/redroid_extreme_post_fs_data.sh) is copied to `/data/adb/post-fs-data.d/20-redroid-extreme.sh` so that:
- `vendor/bin/su` stays visible to app namespaces after reboot - `vendor/bin/su` stays visible to app namespaces after reboot
- `Pixel 7 Pro` properties are re-applied on every boot - `Pixel 7 Pro` properties are re-applied on every boot

View File

@@ -43,20 +43,47 @@ class Backend:
if self.mode == "adb" and ":" in self.serial: if self.mode == "adb" and ":" in self.serial:
run(["adb", "connect", self.serial], check=False) run(["adb", "connect", self.serial], check=False)
def _root_probe_args(self):
return [
["shell", "su", "root", "id"],
["shell", "su", "0", "id"],
]
def _root_shell_args(self, command):
return [
["shell", "su", "root", "sh", "-c", command],
["shell", "su", "0", "sh", "-c", command],
]
def has_root(self): def has_root(self):
if self.mode == "container": if self.mode == "container":
return True return True
if not self._root_checked: if not self._root_checked or not self._root_available:
result = adb( for args in self._root_probe_args():
self.serial, result = adb(
["shell", "su", "-mm", "-c", "id"], self.serial,
check=False, args,
capture_output=True, check=False,
) capture_output=True,
self._root_available = result.returncode == 0 and "uid=0(" in result.stdout )
self._root_checked = True if result.returncode == 0 and "uid=0(" in result.stdout:
self._root_available = True
self._root_checked = True
return True
self._root_available = False
self._root_checked = False
return self._root_available return self._root_available
def wait_for_root(self, timeout):
if self.mode == "container":
return True
deadline = time.time() + timeout
while time.time() < deadline:
if self.has_root():
return True
time.sleep(2)
return False
def exec_shell(self, command, check=True, capture_output=False, root=False): def exec_shell(self, command, check=True, capture_output=False, root=False):
if self.mode == "container": if self.mode == "container":
return run( return run(
@@ -72,12 +99,20 @@ class Backend:
capture_output=capture_output, capture_output=capture_output,
) )
if root and self.has_root(): if root and self.has_root():
return adb( last_result = None
self.serial, for args in self._root_shell_args(command):
["shell", "su", "-mm", "-c", command], result = adb(
check=check, self.serial,
capture_output=capture_output, args,
) check=False,
capture_output=capture_output,
)
last_result = result
if result.returncode == 0:
return result
if check and last_result is not None:
last_result.check_returncode()
return last_result
return adb( return adb(
self.serial, self.serial,
["shell", command], ["shell", command],
@@ -160,6 +195,8 @@ def shell_quote(value):
def apply_device_profile(backend, profile): def apply_device_profile(backend, profile):
if profile == "none": if profile == "none":
return "skipped" return "skipped"
if backend.mode == "adb" and not backend.has_root():
return "skipped (root unavailable)"
props = DEVICE_PROFILES[profile] props = DEVICE_PROFILES[profile]
resetprop = backend.exec_shell( resetprop = backend.exec_shell(
"if [ -x /debug_ramdisk/resetprop ]; then echo /debug_ramdisk/resetprop; " "if [ -x /debug_ramdisk/resetprop ]; then echo /debug_ramdisk/resetprop; "
@@ -263,6 +300,12 @@ def main():
default="none", default="none",
help="Apply a runtime device profile through resetprop when available", help="Apply a runtime device profile through resetprop when available",
) )
parser.add_argument(
"--root-timeout",
type=int,
default=120,
help="Seconds to wait for Magisk root in ADB mode before root-only tweaks",
)
parser.add_argument( parser.add_argument(
"--install-extreme-script", "--install-extreme-script",
action="store_true", action="store_true",
@@ -278,14 +321,25 @@ def main():
backend.connect() backend.connect()
backend.wait_for_boot(args.timeout) backend.wait_for_boot(args.timeout)
if backend.mode == "adb" and (args.device_profile != "none" or args.install_extreme_script):
root_ready = backend.wait_for_root(min(args.root_timeout, args.timeout))
if not root_ready:
print("Root: unavailable after waiting; continuing without root-only tweaks")
put_setting(backend, "global", "device_provisioned", "1") put_setting(backend, "global", "device_provisioned", "1")
put_setting(backend, "secure", "user_setup_complete", "1") put_setting(backend, "secure", "user_setup_complete", "1")
put_setting(backend, "secure", "location_mode", "3") put_setting(backend, "secure", "location_mode", "3")
put_setting(backend, "global", "package_verifier_enable", "0") put_setting(backend, "global", "package_verifier_enable", "0")
put_setting(backend, "global", "verifier_verify_adb_installs", "0") put_setting(backend, "global", "verifier_verify_adb_installs", "0")
try_shell(backend, "pm disable-user --user 0 com.android.setupwizard") for package in ("com.android.setupwizard", "com.google.android.setupwizard"):
try_shell(backend, "pm disable-user --user 0 com.google.android.setupwizard") packages = backend.exec_shell(
"pm list packages --user 0",
check=False,
capture_output=True,
).stdout
if package in packages:
try_shell(backend, "pm disable-user --user 0 {}".format(shell_quote(package)))
try_shell(backend, "appops set com.android.shell android:mock_location allow") try_shell(backend, "appops set com.android.shell android:mock_location allow")
proxy_state = configure_proxy(backend, args.proxy_host, args.proxy_port) proxy_state = configure_proxy(backend, args.proxy_host, args.proxy_port)

View File

@@ -1 +1 @@
b149cd26 b149cd26

View File

@@ -3,11 +3,13 @@
import argparse import argparse
import os import os
import subprocess import subprocess
import sys
from stuff.houdini import Houdini from stuff.houdini import Houdini
from stuff.houdini_hack import Houdini_Hack from stuff.houdini_hack import Houdini_Hack
from stuff.magisk import Magisk from stuff.magisk import Magisk
from stuff.mindthegapps import MindTheGapps from stuff.mindthegapps import MindTheGapps
import tools.compat as compat
import tools.helper as helper import tools.helper as helper
@@ -87,6 +89,7 @@ def build_layers(args):
tags.append("houdini") tags.append("houdini")
if args.magisk: if args.magisk:
os.environ.setdefault("REDROID_MAGISK_BOOT_COMPLETE", "1")
Magisk().install() Magisk().install()
dockerfile += "COPY magisk /\n" dockerfile += "COPY magisk /\n"
tags.append("magisk") tags.append("magisk")
@@ -95,7 +98,7 @@ def build_layers(args):
def print_recommended_flow(image_name): def print_recommended_flow(image_name):
print("\nRecommended 5570 launch") print("\nRecommended 5570 launch (headless: always pass --adb-insecure)")
print( print(
"python run_redroid.py " "python run_redroid.py "
"--image {} " "--image {} "
@@ -103,7 +106,8 @@ def print_recommended_flow(image_name):
"--device-profile pixel-7-pro " "--device-profile pixel-7-pro "
"--data-dir ~/data-redroid13 " "--data-dir ~/data-redroid13 "
"--port 5570 " "--port 5570 "
"--replace\n".format(image_name) "--replace "
"--adb-insecure\n".format(image_name)
) )
print("Recommended post-boot hardening") print("Recommended post-boot hardening")
print( print(
@@ -162,10 +166,21 @@ def main():
action="store_true", action="store_true",
help="Do not pull images or download archives; require offline/downloads and offline/images", help="Do not pull images or download archives; require offline/downloads and offline/images",
) )
parser.add_argument(
"--no-compat-check",
dest="no_compat_check",
action="store_true",
help="Skip container-runtime / OS compatibility checks before build",
)
args = parser.parse_args() args = parser.parse_args()
if args.offline: if args.offline:
os.environ["REDROID_OFFLINE"] = "1" os.environ["REDROID_OFFLINE"] = "1"
if not args.no_compat_check:
build_report = compat.analyze_for_build(args.container)
compat.emit_compat_report(build_report)
if build_report.errors:
sys.exit(1)
base_image_source = ensure_base_image(args.container, args.android) base_image_source = ensure_base_image(args.container, args.android)
dockerfile, tags = build_layers(args) dockerfile, tags = build_layers(args)

View File

@@ -1,2 +1,3 @@
requests==2.28.1 requests==2.28.1
tqdm==4.64.1 tqdm==4.64.1
paramiko>=3.4.0,<4

View File

@@ -4,8 +4,12 @@ import argparse
import os import os
import re import re
import shlex import shlex
import socket
import subprocess import subprocess
import sys
import time
import tools.compat as compat
from tools.helper import bcolors, print_color from tools.helper import bcolors, print_color
@@ -179,6 +183,70 @@ def quote_cmd(args):
return " ".join(shlex.quote(part) for part in args) return " ".join(shlex.quote(part) for part in args)
def wait_tcp_port(host, port, timeout_sec, interval=1.0):
"""Return True once TCP connect succeeds, False on timeout."""
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=2.0):
return True
except OSError:
time.sleep(interval)
return False
def local_post_diagnose(container_runtime, name, port, settle_sec):
"""On the Docker host: container status, logs, adb, short logcat."""
print_color(
"\n========== --diagnose (local): waiting {}s ==========".format(settle_sec),
bcolors.YELLOW,
)
time.sleep(settle_sec)
serial = "127.0.0.1:{}".format(port)
print_color("--- {} ps -a ---".format(container_runtime), bcolors.GREEN)
subprocess.run(
[container_runtime, "ps", "-a", "--filter", "name={}".format(name), "--no-trunc"],
check=False,
)
print_color("--- {} inspect ---".format(container_runtime), bcolors.GREEN)
subprocess.run(
[
container_runtime,
"inspect",
"--format",
"status={{.State.Status}} exit={{.State.ExitCode}} err={{.State.Error}}",
name,
],
check=False,
)
print_color("--- {} logs (tail 120) ---".format(container_runtime), bcolors.GREEN)
subprocess.run([container_runtime, "logs", "--tail", "120", name], check=False)
print_color("--- adb ---", bcolors.GREEN)
subprocess.run(["adb", "connect", serial], check=False)
subprocess.run(["adb", "devices", "-l"], check=False)
subprocess.run(["adb", "-s", serial, "shell", "getprop", "sys.boot_completed"], check=False)
print_color("--- adb logcat (display/GLES, last 80 lines) ---", bcolors.GREEN)
subprocess.run(
[
"adb",
"-s",
serial,
"logcat",
"-d",
"-t",
"80",
"*:S",
"SurfaceFlinger:V",
"EGL:V",
"OpenGLRenderer:V",
"redroid:V",
"AndroidRuntime:E",
],
check=False,
)
print_color("========== end --diagnose ==========\n", bcolors.YELLOW)
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Launch a redroid container with a tested native bridge configuration." description="Launch a redroid container with a tested native bridge configuration."
@@ -225,7 +293,11 @@ def main():
parser.add_argument( parser.add_argument(
"--gpu-mode", "--gpu-mode",
default="guest", default="guest",
help="Value for androidboot.redroid_gpu_mode", help=(
"androidboot.redroid_gpu_mode: default guest (stable on headless). "
"auto=guest unless REDROID_USE_HOST_GPU=1 and DRI exists, then host. "
"host=GPU (adds --device per DRI node when present)"
),
) )
parser.add_argument( parser.add_argument(
"--device-profile", "--device-profile",
@@ -242,22 +314,80 @@ def main():
action="store_true", action="store_true",
help="Remove an existing container with the same name before running", help="Remove an existing container with the same name before running",
) )
parser.add_argument(
"--rm",
dest="ephemeral",
action="store_true",
help="Ephemeral: delete container when it stops (disables auto-restart; not for production)",
)
parser.add_argument(
"--restart",
dest="restart_policy",
default="unless-stopped",
choices=("no", "always", "unless-stopped", "on-failure"),
help=(
"Docker/Podman restart policy when not using --rm (default: unless-stopped = "
"start after daemon reboot unless you docker stop'd it)"
),
)
parser.add_argument( parser.add_argument(
"--keep-container", "--keep-container",
action="store_true", action="store_true",
help="Do not pass --rm to the runtime", help="Legacy: containers are persistent by default; this flag is a no-op",
) )
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
help="Print the generated command without executing it", help="Print the generated command without executing it",
) )
parser.add_argument(
"--soft-display",
action="store_true",
help=(
"Lower software-GLES load: 720x1280, fps=15, dpi=280 (helps black screen / flicker with guest GPU)"
),
)
parser.add_argument( parser.add_argument(
"--extra-prop", "--extra-prop",
action="append", action="append",
default=[], default=[],
help="Additional redroid property, repeatable", help="Additional redroid property, repeatable",
) )
parser.add_argument(
"--adb-insecure",
action="store_true",
help=(
"Headless ADB: early androidboot.adb.secure=0, ro.adb.secure=0, ro.secure=0, ro.debuggable=1 "
"(before device profile; see redroid-doc README / #579). Overrides user-build secure props."
),
)
parser.add_argument(
"--no-compat-check",
action="store_true",
help="Skip compatibility messages and docker/podman probe (still resolves --gpu-mode auto from DRI)",
)
parser.add_argument(
"--wait-tcp",
type=int,
default=0,
metavar="SEC",
help=(
"After start, wait up to SEC seconds for 127.0.0.1:PORT to accept TCP (Android adbd). "
"If it never opens, print container logs. Use 90 when debugging failed adb connect."
),
)
parser.add_argument(
"--diagnose",
action="store_true",
help="After start: wait, then print docker ps/inspect/logs + adb + logcat (same host as Docker)",
)
parser.add_argument(
"--diagnose-settle",
type=int,
default=15,
metavar="SEC",
help="Seconds to sleep before --diagnose probes (default 15)",
)
args = parser.parse_args() args = parser.parse_args()
image = args.image image = args.image
@@ -270,6 +400,16 @@ def main():
os.makedirs(data_dir, exist_ok=True) os.makedirs(data_dir, exist_ok=True)
run_report, effective_gpu_mode, dri_device_args = compat.analyze_for_run(
args.container,
args.gpu_mode,
skip_check=args.no_compat_check,
)
if not args.no_compat_check:
compat.emit_compat_report(run_report)
if run_report.errors:
sys.exit(1)
cmd = [ cmd = [
args.container, args.container,
"run", "run",
@@ -284,17 +424,38 @@ def main():
] ]
if memory: if memory:
cmd.extend(["--memory", memory]) cmd.extend(["--memory", memory])
if not args.keep_container: if args.ephemeral:
cmd.append("--rm") cmd.append("--rm")
cmd.extend( elif args.restart_policy != "no":
[ cmd.extend(["--restart", args.restart_policy])
image, cmd.extend(dri_device_args)
"androidboot.redroid_gpu_mode={}".format(args.gpu_mode), cmd.append(image)
] # Init applies read-only props in order; later ro.adb.secure=0 is ignored if adb.secure
) # was already set (redroid-doc #579). Put these immediately after the image name.
if args.adb_insecure:
# Must precede device_profile_props (ro.secure=1 / ro.debuggable=0): init keeps first ro.* value.
# redroid-doc README: ro.secure=0 yields working adb shell without RSA prompt.
cmd.extend(
[
"androidboot.adb.secure=0",
"ro.adb.secure=0",
"ro.secure=0",
"ro.debuggable=1",
]
)
cmd.append("androidboot.redroid_gpu_mode={}".format(effective_gpu_mode))
cmd.extend(bridge_props(bridge)) cmd.extend(bridge_props(bridge))
cmd.extend(preset_props(preset)) cmd.extend(preset_props(preset))
cmd.extend(device_profile_props(device_profile)) cmd.extend(device_profile_props(device_profile))
if args.soft_display:
cmd.extend(
[
"androidboot.redroid_width=720",
"androidboot.redroid_height=1280",
"androidboot.redroid_fps=15",
"androidboot.redroid_dpi=280",
]
)
cmd.extend(args.extra_prop) cmd.extend(args.extra_prop)
if args.replace and not args.dry_run: if args.replace and not args.dry_run:
@@ -305,11 +466,23 @@ def main():
check=False, check=False,
) )
print_color(
"Selected GPU mode: {} (androidboot.redroid_gpu_mode)".format(effective_gpu_mode),
bcolors.GREEN,
)
print_color("Selected native bridge: {}".format(bridge), bcolors.GREEN) print_color("Selected native bridge: {}".format(bridge), bcolors.GREEN)
if preset != "none": if preset != "none":
print_color("Selected launch preset: {}".format(preset), bcolors.GREEN) print_color("Selected launch preset: {}".format(preset), bcolors.GREEN)
if device_profile != "none": if device_profile != "none":
print_color("Selected device profile: {}".format(device_profile), bcolors.GREEN) print_color("Selected device profile: {}".format(device_profile), bcolors.GREEN)
if args.soft_display:
print_color("Soft display: 720x1280 @ 15fps (reduced GLES load)", bcolors.GREEN)
if args.adb_insecure:
print_color(
"ADB insecure: injecting boot props immediately after image name "
"(check Command: line starts with androidboot.adb.secure=0 …).",
bcolors.GREEN,
)
print("Command:\n{}\n".format(quote_cmd(cmd))) print("Command:\n{}\n".format(quote_cmd(cmd)))
if args.dry_run: if args.dry_run:
@@ -317,7 +490,52 @@ def main():
subprocess.run(cmd, check=True) subprocess.run(cmd, check=True)
print_color("Container started as {}".format(name), bcolors.GREEN) print_color("Container started as {}".format(name), bcolors.GREEN)
if args.ephemeral:
print_color("Ephemeral (--rm): container is removed when it stops.", bcolors.YELLOW)
else:
print_color(
"Persistent: restart policy is {!r} (survives Docker daemon restart; "
"use `docker stop {}` to opt out of auto-start).".format(args.restart_policy, name),
bcolors.GREEN,
)
if args.diagnose:
local_post_diagnose(args.container, name, args.port, args.diagnose_settle)
if args.wait_tcp > 0:
print_color(
"Waiting up to {}s for 127.0.0.1:{} (adbd TCP) ...".format(args.wait_tcp, args.port),
bcolors.GREEN,
)
if not wait_tcp_port("127.0.0.1", args.port, args.wait_tcp):
print_color(
"Port {} never opened. Common with androidboot.redroid_gpu_mode=host when EGL/GPU fails inside "
"the guest — try: python run_redroid.py ... --gpu-mode guest (and drop DRI if needed).".format(
args.port
),
bcolors.RED,
)
print_color("Last logs from container {!r}:".format(name), bcolors.YELLOW)
subprocess.run([args.container, "logs", "--tail", "100", name], check=False)
print(
"\nIf the container already exited with --rm, logs are gone; re-run without --rm "
"for a persistent container."
)
print("ADB: adb connect 127.0.0.1:{}".format(args.port)) print("ADB: adb connect 127.0.0.1:{}".format(args.port))
if effective_gpu_mode == "host":
print(
"Note: host GPU mode can prevent full boot; if connect fails, use --gpu-mode guest or "
"--wait-tcp 90 to diagnose."
)
if not args.adb_insecure:
print(
"If the device shows as unauthorized, stop the container and re-run with --adb-insecure "
"(headless instances cannot tap the USB debugging authorization dialog)."
)
else:
print(
"If adb still shows unauthorized: stop the container, delete host folder "
"{}/misc/adb (persisted keys from an older boot), then start again. "
"Also try: adb kill-server && adb start-server".format(data_dir)
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -66,7 +66,7 @@ on property:sys.boot_completed=1
"No available libhoudini for Android {}".format(version)) "No available libhoudini for Android {}".format(version))
def download(self): def download(self):
print_color("Downloading libhoudini now .....", bcolors.GREEN) print_color("Resolving libhoudini bundle ...", bcolors.GREEN)
super().download() super().download()
def copy(self): def copy(self):

View File

@@ -17,7 +17,7 @@ class Houdini_Hack(General):
self.act_md5 = "8f71a58f3e54eca879a2f7de64dbed58" self.act_md5 = "8f71a58f3e54eca879a2f7de64dbed58"
def download(self): def download(self):
print_color("Downloading libhoudini_hack now .....", bcolors.GREEN) print_color("Resolving libhoudini_hack bundle ...", bcolors.GREEN)
super().download() super().download()
def copy(self): def copy(self):

View File

@@ -110,7 +110,7 @@ class MindTheGapps(General):
self.act_md5 = self.dl_links[self.version][self.arch[0]][1] self.act_md5 = self.dl_links[self.version][self.arch[0]][1]
def download(self): def download(self):
print_color("Downloading MindTheGapps now .....", bcolors.GREEN) print_color("Resolving MindTheGapps bundle ...", bcolors.GREEN)
super().download() super().download()
def copy(self): def copy(self):

188
tools/compat.py Normal file
View File

@@ -0,0 +1,188 @@
"""
Host and container-runtime compatibility checks for redroid-android13.
"""
from __future__ import annotations
import glob
import os
import platform
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Tuple
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from tools.helper import bcolors, print_color
@dataclass
class CompatReport:
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
def _runtime_available(runtime: str) -> bool:
try:
result = subprocess.run(
[runtime, "version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=30,
check=False,
)
return result.returncode == 0
except OSError:
return False
def detect_dri_devices() -> List[str]:
"""Paths suitable for docker/podman --device (Linux DRM)."""
found: List[str] = []
for pattern in ("/dev/dri/card*", "/dev/dri/renderD*"):
found.extend(glob.glob(pattern))
return sorted(set(found))
def _linux_binder_notes() -> List[str]:
if platform.system() != "Linux":
return []
notes: List[str] = []
try:
with open("/proc/modules", "r", encoding="utf-8", errors="ignore") as handle:
loaded = handle.read()
if "binder_linux" not in loaded:
notes.append(
"binder_linux not loaded; redroid usually needs: "
"modprobe binder_linux devices=binder,hwbinder,vndbinder"
)
except OSError:
pass
return notes
def analyze_for_build(container_runtime: str) -> CompatReport:
report = CompatReport()
if not _runtime_available(container_runtime):
report.errors.append(
"Container runtime {!r} not found or not working (install Docker/Podman and check PATH).".format(
container_runtime
)
)
system = platform.system()
if system == "Windows":
report.warnings.append(
"Running on Windows: redroid containers are meant for Linux hosts (binder/ashmem); "
"building files here is fine, running the container typically happens on Linux."
)
elif system != "Linux":
report.warnings.append(
"Host OS is not Linux; upstream redroid expects Linux with binder and related kernel pieces."
)
return report
def resolve_gpu_mode(requested: str, dri_devices: List[str]) -> Tuple[str, List[str]]:
"""
Map user --gpu-mode to an effective redroid mode and docker --device arguments.
auto -> guest by default (many servers have /dev/dri but host-EGL still breaks boot/adbd).
Set REDROID_USE_HOST_GPU=1 (or true/yes/on) with --gpu-mode auto to pass DRI + use host when nodes exist.
"""
req = (requested or "auto").strip().lower()
if req == "auto":
want_host = os.environ.get("REDROID_USE_HOST_GPU", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
effective = "host" if (want_host and dri_devices) else "guest"
elif req == "host":
effective = "host" if dri_devices else "guest"
elif req == "guest":
effective = "guest"
else:
effective = "guest"
device_args: List[str] = []
if effective == "host" and dri_devices:
for path in dri_devices:
device_args.extend(["--device", path])
return effective, device_args
def analyze_for_run(
container_runtime: str,
gpu_mode: str,
skip_check: bool,
) -> Tuple[CompatReport, str, List[str]]:
"""
Returns (report, effective_gpu_mode, flat docker --device args).
"""
report = CompatReport()
dri = detect_dri_devices()
if not skip_check:
build = analyze_for_build(container_runtime)
report.errors.extend(build.errors)
report.warnings.extend(build.warnings)
report.notes.extend(build.notes)
if dri:
report.notes.append(
"DRI present: {} — for host GPU use --gpu-mode host or REDROID_USE_HOST_GPU=1 with --gpu-mode auto.".format(
", ".join(dri)
)
)
elif platform.system() == "Linux":
report.warnings.append(
"No /dev/dri render nodes: software GLES (guest) often black-screens or flickers on headless servers."
)
report.notes.extend(_linux_binder_notes())
effective, device_args = resolve_gpu_mode(gpu_mode, dri)
req = (gpu_mode or "auto").strip().lower()
if req == "host" and effective == "guest":
report.warnings.append(
"--gpu-mode host requested but no DRI devices found; falling back to guest."
)
if req == "auto" and not skip_check:
report.notes.append(
"Resolved --gpu-mode auto -> {} (set REDROID_USE_HOST_GPU=1 to prefer host when DRI exists).".format(
effective
)
)
if effective == "host" and not skip_check:
report.notes.append(
"GPU host mode: if ADB cannot connect (connection refused), Android may not have finished boot "
"or init crashed (EGL/driver mismatch); try --gpu-mode guest or docker logs on the container."
)
return report, effective, device_args
def emit_compat_report(report: CompatReport, prefix: str = "[compat] ") -> None:
for msg in report.errors:
print_color(prefix + msg, bcolors.RED)
for msg in report.warnings:
print_color(prefix + msg, bcolors.YELLOW)
for msg in report.notes:
print_color(prefix + msg, bcolors.GREEN)
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="Host/runtime compatibility probe (same logic as run_redroid.py)")
ap.add_argument("-c", "--container", default="docker", help="docker or podman")
ap.add_argument("--gpu-mode", default="guest", help="guest, host, or auto")
ns = ap.parse_args()
rep, gpu_eff, dev_args = analyze_for_run(ns.container, ns.gpu_mode, skip_check=False)
emit_compat_report(rep)
print("effective_gpu_mode={}".format(gpu_eff))
print("flat_device_args={}".format(dev_args))

205
tools/ssh_remote.py Normal file
View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
SSH to a Linux host (where Docker + redroid run) and run diagnostics or shell commands.
Authentication (pick one): private key (--key-file / REDROID_SSH_KEY), password
(REDROID_SSH_PASSWORD), or --password-prompt. Do not commit secrets.
Examples:
REDROID_SSH_HOST=192.168.2.179 REDROID_SSH_USER=eric python tools/ssh_remote.py --password-prompt diagnose
python tools/ssh_remote.py -H 192.168.2.179 -U eric --key-file ~/.ssh/id_ed25519 diagnose
python tools/ssh_remote.py -H ... -U eric --password-prompt exec -- bash -lc 'docker ps -a | head'
"""
from __future__ import annotations
import argparse
import getpass
import os
import shlex
import sys
import threading
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
import paramiko
def connect_client(args: argparse.Namespace) -> paramiko.SSHClient:
host = (args.host or "").strip()
user = (args.user or "").strip()
if not host or not user:
print("Set -H/--host and -U/--user (or REDROID_SSH_HOST / REDROID_SSH_USER).", file=sys.stderr)
sys.exit(2)
password = (getattr(args, "password", None) or os.environ.get("REDROID_SSH_PASSWORD", "")).strip() or None
key_file = (args.key_file or os.environ.get("REDROID_SSH_KEY", "")).strip() or None
if getattr(args, "password_prompt", False):
password = getpass.getpass("SSH password: ") or None
if not password:
print("Empty password.", file=sys.stderr)
sys.exit(2)
if not password and not key_file:
print(
"Need auth: --key-file / REDROID_SSH_KEY, or REDROID_SSH_PASSWORD, or --password-prompt.",
file=sys.stderr,
)
sys.exit(2)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
kw: dict = {
"hostname": host,
"port": int(args.port),
"username": user,
"timeout": float(args.timeout),
"allow_agent": bool(key_file),
"look_for_keys": bool(key_file),
}
if key_file:
kw["key_filename"] = os.path.expanduser(key_file)
if password:
kw["password"] = password
client.connect(**kw)
return client
def run_remote(client: paramiko.SSHClient, bash_script: str, timeout: float = 300.0) -> int:
cmd = "bash -lc {}".format(shlex.quote(bash_script))
stdin, stdout, stderr = client.exec_command(cmd)
stdout.channel.settimeout(timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
if out:
sys.stdout.write(out)
if not out.endswith("\n"):
sys.stdout.write("\n")
if err:
sys.stderr.write(err)
if not err.endswith("\n"):
sys.stderr.write("\n")
return int(stdout.channel.recv_exit_status())
def cmd_diagnose(args: argparse.Namespace) -> int:
cname = args.container_name
port = int(args.adb_port)
qc = shlex.quote(cname)
script = """
set +e
echo "=== docker ps (name filter) ==="
docker ps -a --filter name={qc} --no-trunc 2>/dev/null | head -35
echo "=== docker inspect ==="
docker inspect --format 'status={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} err={{{{.State.Error}}}}' {qc} 2>/dev/null || echo "(no such container)"
echo "=== docker logs (tail 150) ==="
docker logs --tail 150 {qc} 2>/dev/null || echo "(no logs)"
echo "=== adb ==="
adb connect 127.0.0.1:{port}
adb devices -l
adb -s 127.0.0.1:{port} shell getprop sys.boot_completed 2>/dev/null || true
echo "=== adb logcat (display/GLES, last 80) ==="
adb -s 127.0.0.1:{port} logcat -d -t 80 '*:S' SurfaceFlinger:V EGL:V OpenGLRenderer:V redroid:V AndroidRuntime:E 2>/dev/null || true
""".format(
qc=qc,
port=port,
)
client = connect_client(args)
try:
return run_remote(client, script)
finally:
client.close()
def cmd_exec(args: argparse.Namespace) -> int:
remote = " ".join(args.remote_words).strip()
if not remote:
print("exec: provide a remote command after --", file=sys.stderr)
return 2
client = connect_client(args)
try:
return run_remote(client, remote, timeout=float(args.timeout))
finally:
client.close()
def cmd_shell(args: argparse.Namespace) -> int:
"""Minimal interactive shell (no full PTY); useful for quick typing. Ctrl+D to exit."""
client = connect_client(args)
try:
chan = client.invoke_shell()
chan.send("export TERM=dumb\n")
def read_loop():
try:
while True:
data = chan.recv(4096)
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
except EOFError:
pass
t = threading.Thread(target=read_loop, daemon=True)
t.start()
try:
while True:
line = sys.stdin.readline()
if not line:
break
chan.send(line)
except KeyboardInterrupt:
chan.close()
return 0
finally:
client.close()
def main() -> None:
ap = argparse.ArgumentParser(description="SSH helper for remote Docker/adb diagnostics.")
ap.add_argument("-H", "--host", default=os.environ.get("REDROID_SSH_HOST", ""))
ap.add_argument("-U", "--user", default=os.environ.get("REDROID_SSH_USER", ""))
ap.add_argument("-P", "--port", type=int, default=int(os.environ.get("REDROID_SSH_PORT", "22")))
ap.add_argument("--key-file", default=os.environ.get("REDROID_SSH_KEY", ""))
ap.add_argument(
"--password",
default="",
help="Unsafe on shell history; prefer REDROID_SSH_PASSWORD or --password-prompt.",
)
ap.add_argument("--password-prompt", action="store_true")
ap.add_argument("--timeout", type=float, default=60.0, help="SSH connect / exec timeout (seconds)")
sub = ap.add_subparsers(dest="command", required=True)
d = sub.add_parser("diagnose", help="docker ps/inspect/logs + adb on the remote host")
d.add_argument(
"--container-name",
default=os.environ.get("REDROID_CONTAINER", "redroid_13.0.0_mindthegapps_houdini_magisk"),
)
d.add_argument("--adb-port", type=int, default=int(os.environ.get("REDROID_ADB_PORT", "5570")))
e = sub.add_parser("exec", help="Run one remote bash -lc string (pass command after --)")
e.add_argument("remote_words", nargs=argparse.REMAINDER, help="e.g. -- bash -lc 'docker ps'")
s = sub.add_parser("shell", help="Interactive SSH shell (basic; prefer OpenSSH ssh for full TTY)")
ns = ap.parse_args()
if ns.command == "diagnose":
sys.exit(cmd_diagnose(ns))
if ns.command == "exec":
sys.exit(cmd_exec(ns))
if ns.command == "shell":
sys.exit(cmd_shell(ns))
sys.exit(2)
if __name__ == "__main__":
main()

View File

@@ -2,12 +2,15 @@
import hashlib import hashlib
import json import json
import shutil
import subprocess
from pathlib import Path from pathlib import Path
import sys import sys
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / "offline" / "manifest.json" MANIFEST = ROOT / "offline" / "manifest.json"
BASE_IMAGE = "redroid/redroid:13.0.0-latest"
def digest(path, algorithm): def digest(path, algorithm):
@@ -18,12 +21,42 @@ def digest(path, algorithm):
return hasher.hexdigest() return hasher.hexdigest()
def runtime_available(runtime):
return shutil.which(runtime) is not None
def local_base_image_present():
for runtime in ("docker", "podman"):
if not runtime_available(runtime):
continue
result = subprocess.run(
[runtime, "image", "inspect", BASE_IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode == 0:
return True, runtime
return False, ""
def main(): def main():
manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
failures = [] failures = []
base_image_present, base_runtime = local_base_image_present()
for entry in manifest["artifacts"]: for entry in manifest["artifacts"]:
path = ROOT / entry["relative_path"] path = ROOT / entry["relative_path"]
if path.name == "redroid-redroid-13.0.0-latest.tar" and base_image_present:
print("SKIP {} ({} already present locally)".format(entry["relative_path"], BASE_IMAGE))
continue
if not path.is_file(): if not path.is_file():
if path.name == "redroid-redroid-13.0.0-latest.tar":
failures.append(
"{} missing (and {} not found in local {} storage)".format(
entry["relative_path"], BASE_IMAGE, base_runtime or "docker/podman"
)
)
continue
failures.append("{} missing".format(entry["relative_path"])) failures.append("{} missing".format(entry["relative_path"]))
continue continue
if path.stat().st_size != entry["size"]: if path.stat().st_size != entry["size"]: