2 Commits

Author SHA1 Message Date
eric
0712fcdb84 's' 2026-04-04 00:21:41 -05:00
eric
415fdd3238 ''s 2026-04-04 00:03:05 -05:00
11 changed files with 399 additions and 120 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

View File

@@ -1,48 +1,74 @@
# 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`
- `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](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/`.
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.
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`
`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`.
## Dependencies
```bash ```bash
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
``` ```
容器侧默认按 `docker` 写,`podman` 也能用。 ## Verify the offline bundle
## 构建 5570 对应镜像 ```bash
python verify_offline_bundle.py
```
## Build the 5570 image
```bash ```bash
python redroid.py -mtg -i -m python redroid.py -mtg -i -m
``` ```
这会生成并构建: Expected image tag:
```bash ```bash
redroid/redroid:13.0.0_mindthegapps_houdini_magisk redroid/redroid:13.0.0_mindthegapps_houdini_magisk
``` ```
## 启动容器 ## Launch the container
```bash ```bash
python run_redroid.py \ python run_redroid.py \
@@ -54,7 +80,7 @@ python run_redroid.py \
--replace --replace
``` ```
## 启动后做 5570 硬化 ## Apply the 5570 hardening
```bash ```bash
python bootstrap_redroid.py \ python bootstrap_redroid.py \
@@ -64,39 +90,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](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:
- `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
后续如果要继续推进直播链路,直接在这个仓库往上叠加即可。

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`.
4. The build first tries to pull the base image and download dependency archives from their online sources.
5. If an online pull/download fails, the build falls back to the validated files in `downloads/` and `images/`.

View File

@@ -0,0 +1 @@

1
offline/images/.gitkeep Normal file
View File

@@ -0,0 +1 @@

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,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import os
import subprocess import subprocess
from stuff.houdini import Houdini from stuff.houdini import Houdini
@@ -13,6 +14,49 @@ 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
helper.print_color(
"Trying to pull base image {} first ...".format(base_image),
helper.bcolors.GREEN,
)
pull = subprocess.run([container_runtime, "pull", base_image], check=False)
if pull.returncode == 0:
return "pulled"
if local_present:
helper.print_color(
"Online pull failed, falling back to local image cache ...",
helper.bcolors.YELLOW,
)
return "present"
tar_name = "redroid-redroid-{}-latest.tar".format(android_version)
tar_path = os.path.join(helper.get_image_dir(), tar_name)
if not os.path.isfile(tar_path):
raise FileNotFoundError(
"Online pull for {} failed, local image cache is missing, and offline archive {} was not found".format(
base_image,
tar_path,
)
)
helper.print_color(
"Online pull failed, loading local image archive {} ...".format(tar_path),
helper.bcolors.YELLOW,
)
subprocess.run([container_runtime, "load", "-i", tar_path], check=True)
return 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]
@@ -103,6 +147,7 @@ def main():
) )
args = parser.parse_args() args = parser.parse_args()
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 +160,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,24 +1,49 @@
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, 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() try:
loc_md5 = hashlib.md5(bytes).hexdigest() if os.path.isfile(temp_path):
while not os.path.isfile(self.dl_file_name) or loc_md5 != self.act_md5: os.remove(temp_path)
if os.path.isfile(self.dl_file_name): print_color("Trying online download first ...", bcolors.GREEN)
os.remove(self.dl_file_name) remote_md5 = download_file(self.dl_link, temp_path)
print_color("md5 mismatches, redownloading now ....",bcolors.YELLOW) if remote_md5 != self.act_md5:
loc_md5 = download_file(self.dl_link, self.dl_file_name) raise ValueError(
"Downloaded file md5 mismatch: expected {}, got {}".format(
self.act_md5,
remote_md5,
)
)
os.replace(temp_path, self.dl_file_name)
except Exception as exc:
if os.path.isfile(temp_path):
os.remove(temp_path)
if local_valid:
print_color(
"Online download failed, falling back to local {} ({})".format(
self.dl_file_name,
exc,
),
bcolors.YELLOW,
)
return
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

@@ -137,13 +137,41 @@ 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 _download_remote_archive(self):
temp_path = self.dl_file_name + ".tmp"
try:
if os.path.isfile(temp_path):
os.remove(temp_path)
print_color(
"Trying online download for Kitsune Mask {} ...".format(self.release_version),
bcolors.GREEN,
)
download_file(self.dl_link, temp_path)
self._validate_archive(temp_path)
os.replace(temp_path, self.dl_file_name)
self._write_version()
except Exception:
if os.path.isfile(temp_path):
os.remove(temp_path)
raise
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, timeout=30)
@@ -174,32 +202,42 @@ on property:init.svc.zygote=stopped
self._validate_archive() self._validate_archive()
return return
if self.custom_url: local_valid = self._has_valid_local_archive()
self.dl_link = self.custom_url local_version = self._cached_version() or "bundled"
self.release_version = "url:" + os.path.basename(self.custom_url)
else:
self.resolve_release()
if ( try:
os.path.isfile(self.dl_file_name) if self.custom_url:
and self._cached_version() == self.release_version self.dl_link = self.custom_url
): self.release_version = "url:" + os.path.basename(self.custom_url)
print_color( else:
"Using cached Kitsune Mask {} ...".format(self.release_version), self.resolve_release()
bcolors.GREEN, except Exception as exc:
) if local_valid:
self._validate_archive() self.release_version = local_version
return print_color(
"Resolving Kitsune Mask online failed, falling back to local {} ({})".format(
self.release_version,
exc,
),
bcolors.YELLOW,
)
return
raise
print_color( try:
"Downloading Kitsune Mask {} now .....".format(self.release_version), self._download_remote_archive()
bcolors.GREEN, except Exception as exc:
) if local_valid:
if os.path.isfile(self.dl_file_name): self.release_version = local_version
os.remove(self.dl_file_name) print_color(
download_file(self.dl_link, self.dl_file_name) "Downloading Kitsune Mask online failed, falling back to local {} ({})".format(
self._validate_archive() self.release_version,
self._write_version() exc,
),
bcolors.YELLOW,
)
return
raise
def extract(self): def extract(self):
print_color("Extracting archive...", bcolors.GREEN) print_color("Extracting archive...", bcolors.GREEN)

View File

@@ -1,39 +1,38 @@
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 file_md5(path, block_size=1024 * 1024):
if os.name == "nt": hasher = hashlib.md5()
download_loc = os.path.join( with open(path, "rb") as handle:
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")), for chunk in iter(lambda: handle.read(block_size), b""):
"redroid", hasher.update(chunk)
"downloads", return hasher.hexdigest()
)
else:
download_loc = os.path.join(
'/',
"home",
current_user,
".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 +45,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()

47
verify_offline_bundle.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import hashlib
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / "offline" / "manifest.json"
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 main():
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
failures = []
for entry in manifest["artifacts"]:
path = ROOT / entry["relative_path"]
if not path.is_file():
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()