17 Commits
dev ... test

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
eric
898238b3e0 's' 2026-04-04 15:33:17 -05:00
eric
f0bfb952e8 's' 2026-04-04 15:26:33 -05:00
eric
bf4a268526 's' 2026-04-04 15:21:05 -05:00
eric
0591362f8f 'ssh' 2026-04-04 15:11:19 -05:00
eric
2024daf22e Fix Kitsune Magisk download when HuskyDG CDN is unavailable
Made-with: Cursor
2026-04-04 14:31:28 -05:00
eric
97fe83cd3a 's' 2026-04-04 00:21:41 -05:00
eric
08b532236d ''s 2026-04-04 00:03:05 -05:00
23 changed files with 1349 additions and 152 deletions

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ Dockerfile
houdini/ houdini/
magisk/ magisk/
mindthegapps/ mindthegapps/
# offline/downloads/*
# !offline/downloads/.gitkeep
# offline/images/*
# !offline/images/.gitkeep

253
README.md
View File

@@ -1,48 +1,140 @@
# redroid-android13 # redroid-android13
这个仓库只保留了 `5570` 那套优化过的 Android 13 redroid 最小集,不再带 `gapps/litegapps/ndk/widevine` 这些无关入口。 This repo is the minimal Android 13 redroid bundle that matches the optimized `5570` instance.
目标很明确: It keeps only the parts that were actually used:
- 基于 `redroid/redroid:13.0.0` - `redroid/redroid:13.0.0`
- 集成 `MindTheGapps` - `MindTheGapps`
- 集成 `libhoudini` - `libhoudini`
- 集成 `Kitsune Mask / Magisk Delta` - `Kitsune Mask / Magisk Delta`
- 启动后伪装成 `Pixel 7 Pro` - `Pixel 7 Pro` device profile
- 修复 `Magisk Installed N/A` 和设置页不可用的问题 - the `post-fs-data` fix that keeps `Magisk Installed` and the settings page healthy
## 保留的文件 ## Included files
- `redroid.py` - `redroid.py`
- `run_redroid.py` - `run_redroid.py`
- `bootstrap_redroid.py` - `bootstrap_redroid.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/manifest.json`
- `stuff/mindthegapps.py` - `stuff/mindthegapps.py`
- `stuff/houdini.py` - `stuff/houdini.py`
- `stuff/houdini_hack.py` - `stuff/houdini_hack.py`
- `stuff/magisk.py` - `stuff/magisk.py`
- `assets/redroid_extreme_post_fs_data.sh` - `assets/redroid_extreme_post_fs_data.sh`
- local `offline/downloads/`
- local `offline/images/`
## 依赖 ## Offline archive
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](offline/manifest.json) for checksums and source metadata, and [offline/README.md](offline/README.md) for the expected local layout.
Expected local files:
- `offline/downloads/mindthegapps.zip`
- `offline/downloads/libhoudini.zip`
- `offline/downloads/libhoudini_hack.zip`
- `offline/downloads/magisk.apk`
- `offline/downloads/magisk.version`
- `offline/images/redroid-redroid-13.0.0-latest.tar` (optional if the base image is already in local Docker storage)
### 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
```bash ```bash
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
``` ```
容器侧默认按 `docker` 写,`podman` 也能用。 ## Compatibility checks
## 构建 5570 对应镜像 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
python tools/compat.py
python tools/compat.py -c podman --gpu-mode guest
```
## Build the 5570 image
```bash ```bash
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:
```bash ```bash
redroid/redroid:13.0.0_mindthegapps_houdini_magisk redroid/redroid:13.0.0_mindthegapps_houdini_magisk
``` ```
## 启动容器 ## 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 \
@@ -51,10 +143,97 @@ 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
``` ```
## 启动后做 5570 硬化 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
```bash ```bash
python bootstrap_redroid.py \ python bootstrap_redroid.py \
@@ -64,39 +243,39 @@ python bootstrap_redroid.py \
--install-extreme-script --install-extreme-script
``` ```
这一步会做 5 件事: This post-boot step:
- 跳过 SetupWizard - disables SetupWizard
- 标记系统已完成初始化 - marks the device provisioned
- 开启高精度定位模式 - enables high accuracy location mode
- 允许 `com.android.shell` 使用 mock location - allows `com.android.shell` to use mock location
- 应用 `Pixel 7 Pro / user / release-keys / locked / green` 画像 - applies the `Pixel 7 Pro / user / release-keys / locked / green` identity
如果带上 `--install-extreme-script`,还会把 [`assets/redroid_extreme_post_fs_data.sh`](C:\Users\admin\Desktop\redroid-android13\assets\redroid_extreme_post_fs_data.sh) 安装到 `/data/adb/post-fs-data.d/20-redroid-extreme.sh`,让下面两件事在重启后继续成立: 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` 指向 Magisk applet应用命名空间可见 - `vendor/bin/su` stays visible to app namespaces after reboot
- `Pixel 7 Pro` 属性在每次开机时重新注入 - `Pixel 7 Pro` properties are re-applied on every boot
## 已验证结果 ## Verified 5570 state
这套仓库对应的 `5570` 实例已经验证过: The matching `5570` instance was already validated with:
- `ro.product.model=Pixel 7 Pro` - `ro.product.model=Pixel 7 Pro`
- `ro.build.type=user` - `ro.build.type=user`
- `ro.build.tags=release-keys` - `ro.build.tags=release-keys`
- `ro.boot.vbmeta.device_state=locked` - `ro.boot.vbmeta.device_state=locked`
- `ro.boot.verifiedbootstate=green` - `ro.boot.verifiedbootstate=green`
- 应用 UID `su -V` 返回 `27001` - app UID `su -V = 27001`
- 应用 UID `su -mm -c id` 能拿到 `uid=0(root)` - app UID `su -mm -c id = uid=0(root)`
- `Kitsune` 首页的 `Installed` 正常显示 - `Kitsune` home page shows a normal `Installed` state
- `Kitsune` 设置页正常打开,`SuList / MagiskHide` 可配置 - `Kitsune` settings page opens normally and `SuList / MagiskHide` is configurable
## 当前范围 ## Current scope
这个仓库只负责把 `5570` 的系统环境复现出来,不包含: This repo only reproduces the system environment for the optimized `5570` build.
- 虚拟摄像头 It does not yet include:
- virtual camera
- camera HAL - camera HAL
- TikTok Live 专用补丁 - TikTok Live specific patches
后续如果要继续推进直播链路,直接在这个仓库往上叠加即可。

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)

31
offline/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Offline Bundle
This directory describes the local offline bundle required by the current Android 13 `5570` build path.
The large bundle payloads are intentionally not tracked in Git. Keep them in the local `downloads/` and `images/` directories and use `manifest.json` plus `verify_offline_bundle.py` to validate what is on disk.
## Layout
- `downloads/`
- `mindthegapps.zip`
- `libhoudini.zip`
- `libhoudini_hack.zip`
- `magisk.apk`
- `magisk.version`
- `images/`
- `redroid-redroid-13.0.0-latest.tar`
- `manifest.json`
## Verification
```bash
python verify_offline_bundle.py
```
## Offline build flow
1. Make sure your container runtime can load local image archives.
2. Populate `downloads/` and `images/` with the files listed in `manifest.json`.
3. Run `python redroid.py -mtg -i -m` (add `--offline` or set `REDROID_OFFLINE=1` to forbid any network use).
4. The build prefers a local container image, then `images/redroid-redroid-13.0.0-latest.tar`, then a registry pull (skipped when offline-only).
5. Archives in `downloads/` are preferred when present and match the expected checksum; otherwise the build downloads from upstream (skipped when offline-only).

View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
b149cd26

Binary file not shown.

53
offline/manifest.json Normal file
View File

@@ -0,0 +1,53 @@
{
"generated_at": "2026-04-03",
"artifacts": [
{
"name": "mindthegapps.zip",
"relative_path": "offline/downloads/mindthegapps.zip",
"size": 197927264,
"md5": "eee87a540b6e778f3a114fff29e133aa",
"sha256": "9fcb1531cb72df521bd49af5443f7a1b269ebb9c06d8f7dfa37b975da9ec0af1",
"source": "https://github.com/s1204IT/MindTheGappsBuilder/releases/download/20240226/MindTheGapps-13.0.0-x86_64-20240226.zip"
},
{
"name": "libhoudini.zip",
"relative_path": "offline/downloads/libhoudini.zip",
"size": 75100163,
"md5": "cb7ffac26d47ec7c89df43818e126b47",
"sha256": "7c78abbcced1f7c5c1cc794a0afb4010a2e5bb0bb18eadd6de3af2fe13e9dc25",
"source": "https://github.com/rote66/vendor_intel_proprietary_houdini/archive/debc3dc91cf12b5c5b8a1c546a5b0b7bf7f838a8.zip"
},
{
"name": "libhoudini_hack.zip",
"relative_path": "offline/downloads/libhoudini_hack.zip",
"size": 88394,
"md5": "8f71a58f3e54eca879a2f7de64dbed58",
"sha256": "5c904d6db5e15adc37ffadde5f21437ccabf86c1e94a3a0e4f77371a90776983",
"source": "https://github.com/rote66/redroid_libhoudini_hack/archive/a2194c5e294cbbfdfe87e51eb9eddb4c3621d8c3.zip"
},
{
"name": "magisk.apk",
"relative_path": "offline/downloads/magisk.apk",
"size": 21099562,
"md5": "bb730e78db0bb9dde2fccfa7bfb1b35c",
"sha256": "af43697f67a229cf824494686e8ee07cb5668b57c9671a555e2a869c5bb64649",
"source": "Pulled from the validated 5570 instance: /vendor/etc/magisk/magisk.apk"
},
{
"name": "magisk.version",
"relative_path": "offline/downloads/magisk.version",
"size": 10,
"md5": "57de9facce3180844a0460e9680c3836",
"sha256": "e82ddd5337dd7a7da9f2215c7272f5f26a4fd3b50d9676dea23d6d69a62c46ee",
"source": "Version name from the validated 5570 instance: b149cd26"
},
{
"name": "redroid-redroid-13.0.0-latest.tar",
"relative_path": "offline/images/redroid-redroid-13.0.0-latest.tar",
"size": 2028835328,
"md5": "a2c44b2d90fb3445be9c2d6df867f960",
"sha256": "4474a84a78c62a793ca24c20b7159dc678196bd88a1ef4d9511b83e7090d1179",
"source": "docker save redroid/redroid:13.0.0-latest"
}
]
}

View File

@@ -1,18 +1,75 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
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
SUPPORTED_ANDROID_VERSIONS = ["13.0.0"] SUPPORTED_ANDROID_VERSIONS = ["13.0.0"]
def ensure_base_image(container_runtime, android_version):
base_image = "redroid/redroid:{}-latest".format(android_version)
inspect = subprocess.run(
[container_runtime, "image", "inspect", base_image],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
local_present = inspect.returncode == 0
tar_name = "redroid-redroid-{}-latest.tar".format(android_version)
tar_path = os.path.join(helper.get_image_dir(), tar_name)
tar_exists = os.path.isfile(tar_path)
offline_only = helper.is_offline_only()
if local_present:
helper.print_color(
"Using existing local base image {} ...".format(base_image),
helper.bcolors.GREEN,
)
return "present"
if tar_exists:
helper.print_color(
"Loading base image from offline archive {} ...".format(tar_path),
helper.bcolors.GREEN,
)
subprocess.run([container_runtime, "load", "-i", tar_path], check=True)
return tar_path
if offline_only:
raise FileNotFoundError(
"REDROID_OFFLINE is set but {} is not in local storage and {} was not found".format(
base_image,
tar_path,
)
)
helper.print_color(
"No local base image or offline .tar; pulling {} ...".format(base_image),
helper.bcolors.YELLOW,
)
pull = subprocess.run([container_runtime, "pull", base_image], check=False)
if pull.returncode == 0:
return "pulled"
raise FileNotFoundError(
"Pull failed for {} and offline archive {} was not found".format(
base_image,
tar_path,
)
)
def build_layers(args): def build_layers(args):
dockerfile = "FROM redroid/redroid:{}-latest\n".format(args.android) dockerfile = "FROM redroid/redroid:{}-latest\n".format(args.android)
tags = [args.android] tags = [args.android]
@@ -32,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")
@@ -40,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 {} "
@@ -48,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(
@@ -101,8 +160,28 @@ def main():
choices=["docker", "podman"], choices=["docker", "podman"],
help="Container runtime", help="Container runtime",
) )
parser.add_argument(
"--offline",
dest="offline",
action="store_true",
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:
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)
dockerfile, tags = build_layers(args) dockerfile, tags = build_layers(args)
print("\nDockerfile\n{}".format(dockerfile)) print("\nDockerfile\n{}".format(dockerfile))
@@ -115,6 +194,7 @@ def main():
"Successfully built {}".format(image_name), "Successfully built {}".format(image_name),
helper.bcolors.GREEN, helper.bcolors.GREEN,
) )
print("Base image source: {}".format(base_image_source))
if args.houdini: if args.houdini:
print_recommended_flow(image_name) print_recommended_flow(image_name)

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

@@ -1,24 +1,54 @@
import os import os
import zipfile import zipfile
import hashlib
from tools.helper import bcolors, download_file, print_color from tools.helper import bcolors, download_file, file_md5, is_offline_only, print_color
class General: class General:
def _local_md5(self):
if not os.path.isfile(self.dl_file_name):
return ""
return file_md5(self.dl_file_name)
def _has_valid_local_file(self):
return os.path.isfile(self.dl_file_name) and self._local_md5() == self.act_md5
def download(self): def download(self):
loc_md5 = "" temp_path = self.dl_file_name + ".tmp"
if os.path.isfile(self.dl_file_name): local_valid = self._has_valid_local_file()
with open(self.dl_file_name,"rb") as f:
bytes = f.read() if local_valid:
loc_md5 = hashlib.md5(bytes).hexdigest() print_color(
while not os.path.isfile(self.dl_file_name) or loc_md5 != self.act_md5: "Using validated offline bundle {} ...".format(self.dl_file_name),
if os.path.isfile(self.dl_file_name): bcolors.GREEN,
os.remove(self.dl_file_name) )
print_color("md5 mismatches, redownloading now ....",bcolors.YELLOW) return
loc_md5 = download_file(self.dl_link, self.dl_file_name)
if is_offline_only():
raise FileNotFoundError(
"REDROID_OFFLINE is set but offline file is missing or MD5 mismatch: {}".format(
self.dl_file_name
)
)
try:
if os.path.isfile(temp_path):
os.remove(temp_path)
print_color("Local bundle not found or MD5 mismatch; downloading ...", bcolors.YELLOW)
remote_md5 = download_file(self.dl_link, temp_path)
if remote_md5 != self.act_md5:
raise ValueError(
"Downloaded file md5 mismatch: expected {}, got {}".format(
self.act_md5,
remote_md5,
)
)
os.replace(temp_path, self.dl_file_name)
except Exception:
if os.path.isfile(temp_path):
os.remove(temp_path)
raise
def extract(self): def extract(self):
print_color("Extracting archive...", bcolors.GREEN) print_color("Extracting archive...", bcolors.GREEN)
print(self.dl_file_name) print(self.dl_file_name)

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

@@ -4,14 +4,31 @@ import re
import zipfile import zipfile
import requests import requests
from stuff.general import General from stuff.general import General
from tools.helper import bcolors, download_file, host, print_color, run, get_download_dir from tools.helper import (
bcolors,
download_file,
get_download_dir,
host,
is_offline_only,
print_color,
run,
)
class Magisk(General): class Magisk(General):
stable_json = "https://raw.githubusercontent.com/os-fork/HuskyDG-magisk-files/main/stable.json" _default_stable_json = (
"https://raw.githubusercontent.com/os-fork/HuskyDG-magisk-files/main/stable.json"
)
# HuskyDG/download was removed from GitHub; stable.json still points there.
_kitsune_26_4_mirror_apk = (
"https://raw.githubusercontent.com/Scratch2033Alt/KitsuneMagisk/main/26.4-kitsune.apk"
)
machine = host() machine = host()
def __init__(self): def __init__(self):
self.download_loc = get_download_dir() self.download_loc = get_download_dir()
self.stable_json_url = (
os.environ.get("REDROID_MAGISK_STABLE_JSON", "").strip() or self._default_stable_json
)
self.dl_file_name = os.path.join(self.download_loc, "magisk.apk") self.dl_file_name = os.path.join(self.download_loc, "magisk.apk")
self.release_meta_file = os.path.join(self.download_loc, "magisk.version") self.release_meta_file = os.path.join(self.download_loc, "magisk.version")
self.extract_to = os.path.join(self.download_loc, "magisk_unpack") self.extract_to = os.path.join(self.download_loc, "magisk_unpack")
@@ -137,16 +154,55 @@ on property:init.svc.zygote=stopped
with open(self.release_meta_file, "w", encoding="utf-8") as meta_file: with open(self.release_meta_file, "w", encoding="utf-8") as meta_file:
meta_file.write(self.release_version) meta_file.write(self.release_version)
def _validate_archive(self): def _validate_archive(self, path=None):
if not zipfile.is_zipfile(self.dl_file_name): archive_path = path or self.dl_file_name
if not zipfile.is_zipfile(archive_path):
raise ValueError( raise ValueError(
"Downloaded Magisk/Kitsune archive is not a valid APK/ZIP. " "Downloaded Magisk/Kitsune archive is not a valid APK/ZIP. "
"Set REDROID_MAGISK_APK to a local APK or REDROID_MAGISK_URL to a working direct APK URL." "Set REDROID_MAGISK_APK to a local APK or REDROID_MAGISK_URL to a working direct APK URL."
) )
def _has_valid_local_archive(self):
if not os.path.isfile(self.dl_file_name):
return False
try:
self._validate_archive()
except ValueError:
return False
return True
def _magisk_download_candidates(self):
primary = self.dl_link
if "HuskyDG/download" in primary and str(self.release_version).startswith("26.4"):
return [self._kitsune_26_4_mirror_apk, primary]
return [primary]
def _download_remote_archive(self):
temp_path = self.dl_file_name + ".tmp"
last_error = None
print_color(
"Trying online download for Kitsune Mask {} ...".format(self.release_version),
bcolors.GREEN,
)
for candidate_url in self._magisk_download_candidates():
try:
if os.path.isfile(temp_path):
os.remove(temp_path)
print_color(" from {} ...".format(candidate_url), bcolors.GREEN)
download_file(candidate_url, temp_path, timeout=120)
self._validate_archive(temp_path)
os.replace(temp_path, self.dl_file_name)
self._write_version()
return
except Exception as exc:
last_error = exc
if os.path.isfile(temp_path):
os.remove(temp_path)
raise last_error
def resolve_release(self): def resolve_release(self):
print_color("Resolving Kitsune Mask stable release ...", bcolors.GREEN) print_color("Resolving Kitsune Mask stable release ...", bcolors.GREEN)
response = requests.get(self.stable_json, timeout=30) response = requests.get(self.stable_json_url, timeout=30)
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
magisk_info = payload["magisk"] magisk_info = payload["magisk"]
@@ -174,32 +230,35 @@ on property:init.svc.zygote=stopped
self._validate_archive() self._validate_archive()
return return
local_valid = self._has_valid_local_archive()
local_version = self._cached_version() or "bundled"
if local_valid:
self.release_version = local_version
print_color(
"Using offline Magisk/Kitsune APK {} ({}) ...".format(
self.dl_file_name,
self.release_version,
),
bcolors.GREEN,
)
self._validate_archive()
return
if is_offline_only():
raise FileNotFoundError(
"REDROID_OFFLINE is set but no valid offline magisk.apk at {}".format(
self.dl_file_name
)
)
if self.custom_url: if self.custom_url:
self.dl_link = self.custom_url self.dl_link = self.custom_url
self.release_version = "url:" + os.path.basename(self.custom_url) self.release_version = "url:" + os.path.basename(self.custom_url)
else: else:
self.resolve_release() self.resolve_release()
if ( self._download_remote_archive()
os.path.isfile(self.dl_file_name)
and self._cached_version() == self.release_version
):
print_color(
"Using cached Kitsune Mask {} ...".format(self.release_version),
bcolors.GREEN,
)
self._validate_archive()
return
print_color(
"Downloading Kitsune Mask {} now .....".format(self.release_version),
bcolors.GREEN,
)
if os.path.isfile(self.dl_file_name):
os.remove(self.dl_file_name)
download_file(self.dl_link, self.dl_file_name)
self._validate_archive()
self._write_version()
def extract(self): def extract(self):
print_color("Extracting archive...", bcolors.GREEN) print_color("Extracting archive...", bcolors.GREEN)

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))

View File

@@ -1,39 +1,44 @@
import hashlib
import os import os
from pathlib import Path
import platform import platform
import subprocess import subprocess
import requests import requests
from tqdm import tqdm from tqdm import tqdm
import hashlib
REPO_ROOT = Path(__file__).resolve().parents[1]
OFFLINE_ROOT = REPO_ROOT / "offline"
DOWNLOAD_ROOT = OFFLINE_ROOT / "downloads"
IMAGE_ROOT = OFFLINE_ROOT / "images"
def ensure_dir(path):
path.mkdir(parents=True, exist_ok=True)
return str(path)
def get_download_dir(): def get_download_dir():
download_loc = "" return ensure_dir(DOWNLOAD_ROOT)
current_user = (
os.environ.get("SUDO_USER")
or os.environ.get("USER") def get_image_dir():
or os.environ.get("USERNAME") return ensure_dir(IMAGE_ROOT)
or "user"
)
if os.environ.get("XDG_CACHE_HOME", None) is None: def is_offline_only():
if os.name == "nt": """If true, redroid build must not use the network (env REDROID_OFFLINE or CLI --offline)."""
download_loc = os.path.join( v = os.environ.get("REDROID_OFFLINE", "").strip().lower()
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), return v in ("1", "true", "yes", "on")
"redroid",
"downloads",
) def file_md5(path, block_size=1024 * 1024):
else: hasher = hashlib.md5()
download_loc = os.path.join( with open(path, "rb") as handle:
'/', for chunk in iter(lambda: handle.read(block_size), b""):
"home", hasher.update(chunk)
current_user, return hasher.hexdigest()
".cache",
"redroid",
"downloads",
)
else:
download_loc = os.path.join(os.environ["XDG_CACHE_HOME"], "redroid", "downloads")
if not os.path.exists(download_loc):
os.makedirs(download_loc)
return download_loc
def run(args): def run(args):
result = subprocess.run(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@@ -46,23 +51,31 @@ def run(args):
) )
return result return result
def download_file(url, f_name):
md5 = "" def download_file(url, f_name, timeout=60):
response = requests.get(url, stream=True) response = requests.get(url, stream=True, timeout=timeout)
total_size_in_bytes = int(response.headers.get('content-length', 0)) response.raise_for_status()
block_size = 1024 # 1 Kibibyte
progress_bar = tqdm(total=total_size_in_bytes, unit='iB', unit_scale=True) total_size_in_bytes = int(response.headers.get("content-length", 0))
with open(f_name, 'wb') as file: block_size = 1024 * 1024
for data in response.iter_content(block_size): progress_bar = tqdm(total=total_size_in_bytes, unit="iB", unit_scale=True)
progress_bar.update(len(data)) hasher = hashlib.md5()
file.write(data)
progress_bar.close() try:
with open(f_name, "rb") as f: with open(f_name, "wb") as file:
bytes = f.read() for data in response.iter_content(block_size):
md5 = hashlib.md5(bytes).hexdigest() if not data:
continue
progress_bar.update(len(data))
file.write(data)
hasher.update(data)
finally:
progress_bar.close()
response.close()
if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes: if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes:
raise ValueError("Something went wrong while downloading") raise ValueError("Something went wrong while downloading")
return md5 return hasher.hexdigest()
def host(): def host():
machine = platform.machine().lower() machine = platform.machine().lower()

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()

80
verify_offline_bundle.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / "offline" / "manifest.json"
BASE_IMAGE = "redroid/redroid:13.0.0-latest"
def digest(path, algorithm):
hasher = hashlib.new(algorithm)
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
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():
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
failures = []
base_image_present, base_runtime = local_base_image_present()
for entry in manifest["artifacts"]:
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 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"]))
continue
if path.stat().st_size != entry["size"]:
failures.append("{} size mismatch".format(entry["relative_path"]))
continue
sha256 = digest(path, "sha256")
if sha256 != entry["sha256"]:
failures.append("{} sha256 mismatch".format(entry["relative_path"]))
continue
print("OK {}".format(entry["relative_path"]))
if failures:
for item in failures:
print("FAIL {}".format(item))
sys.exit(1)
print("Offline bundle is complete.")
if __name__ == "__main__":
main()