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/
magisk/
mindthegapps/
offline/downloads/*
!offline/downloads/.gitkeep
offline/images/*
!offline/images/.gitkeep

View File

@@ -1,48 +1,74 @@
# 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`
- 集成 `MindTheGapps`
- 集成 `libhoudini`
- 集成 `Kitsune Mask / Magisk Delta`
- 启动后伪装成 `Pixel 7 Pro`
- 修复 `Magisk Installed N/A` 和设置页不可用的问题
- `redroid/redroid:13.0.0`
- `MindTheGapps`
- `libhoudini`
- `Kitsune Mask / Magisk Delta`
- `Pixel 7 Pro` device profile
- the `post-fs-data` fix that keeps `Magisk Installed` and the settings page healthy
## 保留的文件
## Included files
- `redroid.py`
- `run_redroid.py`
- `bootstrap_redroid.py`
- `verify_offline_bundle.py`
- `offline/README.md`
- `offline/manifest.json`
- `stuff/mindthegapps.py`
- `stuff/houdini.py`
- `stuff/houdini_hack.py`
- `stuff/magisk.py`
- `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
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
python redroid.py -mtg -i -m
```
这会生成并构建:
Expected image tag:
```bash
redroid/redroid:13.0.0_mindthegapps_houdini_magisk
```
## 启动容器
## Launch the container
```bash
python run_redroid.py \
@@ -54,7 +80,7 @@ python run_redroid.py \
--replace
```
## 启动后做 5570 硬化
## Apply the 5570 hardening
```bash
python bootstrap_redroid.py \
@@ -64,39 +90,39 @@ python bootstrap_redroid.py \
--install-extreme-script
```
这一步会做 5 件事:
This post-boot step:
- 跳过 SetupWizard
- 标记系统已完成初始化
- 开启高精度定位模式
- 允许 `com.android.shell` 使用 mock location
- 应用 `Pixel 7 Pro / user / release-keys / locked / green` 画像
- disables SetupWizard
- marks the device provisioned
- enables high accuracy location mode
- allows `com.android.shell` to use mock location
- 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应用命名空间可见
- `Pixel 7 Pro` 属性在每次开机时重新注入
- `vendor/bin/su` stays visible to app namespaces after reboot
- `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.build.type=user`
- `ro.build.tags=release-keys`
- `ro.boot.vbmeta.device_state=locked`
- `ro.boot.verifiedbootstate=green`
- 应用 UID `su -V` 返回 `27001`
- 应用 UID `su -mm -c id` 能拿到 `uid=0(root)`
- `Kitsune` 首页的 `Installed` 正常显示
- `Kitsune` 设置页正常打开,`SuList / MagiskHide` 可配置
- app UID `su -V = 27001`
- app UID `su -mm -c id = uid=0(root)`
- `Kitsune` home page shows a normal `Installed` state
- `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
- 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
import argparse
import os
import subprocess
from stuff.houdini import Houdini
@@ -13,6 +14,49 @@ import tools.helper as helper
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):
dockerfile = "FROM redroid/redroid:{}-latest\n".format(args.android)
tags = [args.android]
@@ -103,6 +147,7 @@ def main():
)
args = parser.parse_args()
base_image_source = ensure_base_image(args.container, args.android)
dockerfile, tags = build_layers(args)
print("\nDockerfile\n{}".format(dockerfile))
@@ -115,6 +160,7 @@ def main():
"Successfully built {}".format(image_name),
helper.bcolors.GREEN,
)
print("Base image source: {}".format(base_image_source))
if args.houdini:
print_recommended_flow(image_name)

View File

@@ -1,23 +1,48 @@
import os
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:
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):
loc_md5 = ""
if os.path.isfile(self.dl_file_name):
with open(self.dl_file_name,"rb") as f:
bytes = f.read()
loc_md5 = hashlib.md5(bytes).hexdigest()
while not os.path.isfile(self.dl_file_name) or loc_md5 != self.act_md5:
if os.path.isfile(self.dl_file_name):
os.remove(self.dl_file_name)
print_color("md5 mismatches, redownloading now ....",bcolors.YELLOW)
loc_md5 = download_file(self.dl_link, self.dl_file_name)
temp_path = self.dl_file_name + ".tmp"
local_valid = self._has_valid_local_file()
try:
if os.path.isfile(temp_path):
os.remove(temp_path)
print_color("Trying online download first ...", bcolors.GREEN)
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 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):
print_color("Extracting archive...", bcolors.GREEN)

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:
meta_file.write(self.release_version)
def _validate_archive(self):
if not zipfile.is_zipfile(self.dl_file_name):
def _validate_archive(self, path=None):
archive_path = path or self.dl_file_name
if not zipfile.is_zipfile(archive_path):
raise ValueError(
"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."
)
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):
print_color("Resolving Kitsune Mask stable release ...", bcolors.GREEN)
response = requests.get(self.stable_json, timeout=30)
@@ -174,32 +202,42 @@ on property:init.svc.zygote=stopped
self._validate_archive()
return
local_valid = self._has_valid_local_archive()
local_version = self._cached_version() or "bundled"
try:
if self.custom_url:
self.dl_link = self.custom_url
self.release_version = "url:" + os.path.basename(self.custom_url)
else:
self.resolve_release()
if (
os.path.isfile(self.dl_file_name)
and self._cached_version() == self.release_version
):
except Exception as exc:
if local_valid:
self.release_version = local_version
print_color(
"Using cached Kitsune Mask {} ...".format(self.release_version),
bcolors.GREEN,
"Resolving Kitsune Mask online failed, falling back to local {} ({})".format(
self.release_version,
exc,
),
bcolors.YELLOW,
)
self._validate_archive()
return
raise
try:
self._download_remote_archive()
except Exception as exc:
if local_valid:
self.release_version = local_version
print_color(
"Downloading Kitsune Mask {} now .....".format(self.release_version),
bcolors.GREEN,
"Downloading Kitsune Mask online failed, falling back to local {} ({})".format(
self.release_version,
exc,
),
bcolors.YELLOW,
)
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()
return
raise
def extract(self):
print_color("Extracting archive...", bcolors.GREEN)

View File

@@ -1,39 +1,38 @@
import hashlib
import os
from pathlib import Path
import platform
import subprocess
import requests
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():
download_loc = ""
current_user = (
os.environ.get("SUDO_USER")
or os.environ.get("USER")
or os.environ.get("USERNAME")
or "user"
)
if os.environ.get("XDG_CACHE_HOME", None) is None:
if os.name == "nt":
download_loc = os.path.join(
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
"redroid",
"downloads",
)
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
return ensure_dir(DOWNLOAD_ROOT)
def get_image_dir():
return ensure_dir(IMAGE_ROOT)
def file_md5(path, block_size=1024 * 1024):
hasher = hashlib.md5()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(block_size), b""):
hasher.update(chunk)
return hasher.hexdigest()
def run(args):
result = subprocess.run(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@@ -46,23 +45,31 @@ def run(args):
)
return result
def download_file(url, f_name):
md5 = ""
response = requests.get(url, stream=True)
total_size_in_bytes = int(response.headers.get('content-length', 0))
block_size = 1024 # 1 Kibibyte
progress_bar = tqdm(total=total_size_in_bytes, unit='iB', unit_scale=True)
with open(f_name, 'wb') as file:
def download_file(url, f_name, timeout=60):
response = requests.get(url, stream=True, timeout=timeout)
response.raise_for_status()
total_size_in_bytes = int(response.headers.get("content-length", 0))
block_size = 1024 * 1024
progress_bar = tqdm(total=total_size_in_bytes, unit="iB", unit_scale=True)
hasher = hashlib.md5()
try:
with open(f_name, "wb") as file:
for data in response.iter_content(block_size):
if not data:
continue
progress_bar.update(len(data))
file.write(data)
hasher.update(data)
finally:
progress_bar.close()
with open(f_name, "rb") as f:
bytes = f.read()
md5 = hashlib.md5(bytes).hexdigest()
response.close()
if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes:
raise ValueError("Something went wrong while downloading")
return md5
return hasher.hexdigest()
def host():
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()