1
0
mirror of git://f0xx.org/ac/ac-scripts synced 2026-07-29 02:19:10 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:20:53 +02:00
commit 3c197ed9dd
30 changed files with 3510 additions and 0 deletions

6
README.md Normal file
View File

@@ -0,0 +1,6 @@
# ac-scripts
CI helpers, OTA, native codec scripts, PDF driver wrappers.
- **Remote:** `git://f0xx.org/ac/ac-scripts`
- **Consumed by:** `ac-deploy` (submodule), local dev

497
adb-reconnect.sh Executable file
View File

@@ -0,0 +1,497 @@
#!/usr/bin/env bash
# Wireless adb discovery + reconnect helpers (sourced by rebuild.sh).
# Priority: app HTTP :5039/adb.json → avahi → cache → mdns → :5555 hints.
# Only IPv4:PORT is used for adb connect (no mDNS service names).
: "${ADB_CONNECT_RETRIES:=3}"
: "${ADB_CONNECT_DELAY_SEC:=1}"
: "${ADB_TCP_PROBE:=yes}"
: "${ADB_TCP_PROBE_SEC:=1}"
: "${DEV_ADB_HTTP_PORT:=5039}"
: "${ADB_CONNECT_CACHE:=$ROOT/.adb_connect_cache}"
: "${ADB_HTTP_CACHE_DIR:=$ROOT/.adb_http_cache}"
: "${ADB_RECONNECT_POKE_DELAY_SEC:=2}"
: "${ADB_MDNS_AUTO_CONNECT:=adb-tls-connect,adb}"
: "${ADB_KILL_SERVER:=no}"
: "${ADB_DISCONNECT_BEFORE_CONNECT:=no}"
declare -gA CONNECTIONS_LIST_HINTS
adb_reconnect_init_hints() {
CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
}
adb_hint_key_for_ip() {
local ip=${1}
ip="$(echo "${ip}" | sed -e 's@\.@_@g' -e 's@:.*@@' | tr -d '\n')"
printf '%s' "${ip}"
}
adb_ip_for_hint_key() {
local ip=${1}
ip="$(echo "${ip}" | sed -e 's/_/./g' -e 's/:.*//' | tr -d '\n')"
printf '%s' "${ip}"
}
adb_hint_for_endpoint() {
local endpoint=${1}
local key
key="$(adb_hint_key_for_ip "${endpoint}")"
printf '%s' "${CONNECTIONS_LIST_HINTS[${key}]:-}"
}
adb_endpoint_host() {
local endpoint=${1}
if [[ "$endpoint" =~ ^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+): ]]; then
printf '%s' "${BASH_REMATCH[1]}"
fi
}
adb_is_connectable_ip_port() {
local endpoint=${1}
[[ "$endpoint" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+$ ]]
}
adb_tcp_port_open() {
local endpoint=${1}
local host port
host="$(adb_endpoint_host "$endpoint")"
port="${endpoint##*:}"
[[ -n "$host" && "$port" =~ ^[0-9]+$ ]] || return 1
if command -v nc >/dev/null 2>&1; then
nc -z -w "${ADB_TCP_PROBE_SEC}" "$host" "$port" >/dev/null 2>&1
return $?
fi
timeout "${ADB_TCP_PROBE_SEC}" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
}
adb_collect_candidate_ips() {
local ip endpoint key
if [[ -f "$ADB_CONNECT_CACHE" ]]; then
while IFS= read -r endpoint; do
[[ -z "$endpoint" ]] && continue
ip="${endpoint%%:*}"
[[ -n "$ip" ]] && printf '%s\n' "$ip"
done < "$ADB_CONNECT_CACHE"
fi
for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
adb_ip_for_hint_key "$key"
done
adb devices -l 2>/dev/null | awk '/^[^ ]+ +device/ { print $1 }' | while read -r serial; do
[[ "$serial" == *:* ]] || continue
printf '%s\n' "${serial%%:*}"
done
}
adb_json_valid() {
local json=${1}
[[ -n "$json" ]] || return 1
printf '%s' "$json" | python3 -c 'import json,sys; json.loads(sys.stdin.read())' 2>/dev/null
}
adb_json_connect_lines() {
local json=${1}
[[ -n "$json" ]] || return 0
printf '%s' "$json" | python3 -c '
import json, sys
raw = sys.stdin.read()
if not raw.strip():
raise SystemExit(0)
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise SystemExit(0)
tls = int(data.get("tls_port") or 0)
if tls > 0:
for ip in data.get("ips") or []:
if ip:
print(f"{ip}:{tls}")
import re
IP_PORT = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}:\d+$")
def ok(ep):
return bool(ep and IP_PORT.match(ep))
for item in data.get("connect") or []:
if ok(item):
print(item)
for item in data.get("commands") or []:
if isinstance(item, str) and item.startswith("adb connect "):
ep = item[len("adb connect "):]
if ok(ep):
print(ep)
' 2>/dev/null || true
}
adb_curl_adb_json() {
local ip=${1}
local json cached="${ADB_HTTP_CACHE_DIR}/${ip}.json"
json="$(curl -m 5 -sS "http://${ip}:${DEV_ADB_HTTP_PORT}/adb.json" 2>/dev/null || true)"
if adb_json_valid "$json"; then
mkdir -p "$ADB_HTTP_CACHE_DIR"
printf '%s' "$json" >"$cached"
printf '%s' "$json"
return 0
fi
if [[ -f "$cached" ]] && adb_json_valid "$(cat "$cached")"; then
cat "$cached"
return 0
fi
return 1
}
adb_fetch_http_endpoints() {
local ip json
for ip in $(adb_collect_candidate_ips | sort -u); do
[[ -z "$ip" ]] && continue
json="$(adb_curl_adb_json "$ip" 2>/dev/null || true)"
[[ -z "$json" ]] && continue
adb_json_connect_lines "$json"
done
}
adb_discover_mdns_endpoints() {
local line hostport
while IFS= read -r line; do
[[ "$line" == List* ]] && continue
[[ "$line" == error:* ]] && continue
[[ "$line" == *"_adb"* ]] || continue
hostport="$(awk '{print $NF}' <<<"$line")"
if adb_is_connectable_ip_port "$hostport"; then
printf '%s\n' "$hostport"
fi
done < <(adb mdns services 2>&1 || true)
}
adb_discover_avahi_endpoints() {
command -v avahi-browse >/dev/null 2>&1 || return 0
local svc
for svc in _adb-tls-connect._tcp _adb._tcp; do
avahi-browse -rpt "$svc" 2>/dev/null | awk -F';' '
$1 == "=" && $3 == "IPv4" && $8 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && $9 ~ /^[0-9]+$/ {
print $8 ":" $9
}'
done
}
adb_pull_device_json_endpoints() {
local serial json tmp
while IFS= read -r serial; do
[[ -z "$serial" ]] && continue
tmp="$(mktemp)"
if adb -s "$serial" exec-out run-as com.foxx.androidcast cat files/dev/adb_connect.json \
>"$tmp" 2>/dev/null; then
json="$(cat "$tmp")"
rm -f "$tmp"
adb_json_connect_lines "$json"
else
rm -f "$tmp"
fi
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
}
adb_endpoint_rank() {
local e=${1}
if [[ "$e" == *":5555" ]]; then
printf '9'
else
printf '2'
fi
}
# One endpoint per IPv4 host; keep best rank (HTTP/avahi TLS beats :5555).
adb_discover_endpoints() {
local endpoint host rank best_rank existing
declare -A seen=()
declare -A host_best=()
declare -A host_rank=()
local -a ordered=()
consider_endpoint() {
local e=${1}
[[ -z "$e" ]] && return 0
if [[ "$e" == *_* && "$e" != *":"* ]]; then
e="$(adb_ip_for_hint_key "$e")"
fi
[[ -z "$e" ]] && return 0
if ! adb_is_connectable_ip_port "$e"; then
return 0
fi
host="$(adb_endpoint_host "$e")"
rank="$(adb_endpoint_rank "$e")"
if [[ -n "$host" ]]; then
existing="${host_best[$host]:-}"
if [[ -n "$existing" ]]; then
best_rank="${host_rank[$host]}"
if [[ "$rank" -ge "$best_rank" ]]; then
return 0
fi
seen["$existing"]=0
for i in "${!ordered[@]}"; do
if [[ "${ordered[$i]}" == "$existing" ]]; then
unset 'ordered[i]'
fi
done
ordered=("${ordered[@]}")
fi
host_best["$host"]="$e"
host_rank["$host"]="$rank"
fi
[[ -n "${seen[$e]:-}" ]] && return 0
seen["$e"]=1
ordered+=("$e")
}
# HTTP first — matches curl the user runs manually.
while IFS= read -r endpoint; do
consider_endpoint "$endpoint"
done < <(adb_fetch_http_endpoints)
while IFS= read -r endpoint; do
consider_endpoint "$endpoint"
done < <(adb_discover_avahi_endpoints)
if [[ -f "$ADB_CONNECT_CACHE" ]]; then
while IFS= read -r endpoint; do
consider_endpoint "$endpoint"
done < "$ADB_CONNECT_CACHE"
fi
while IFS= read -r endpoint; do
consider_endpoint "$endpoint"
done < <(adb_pull_device_json_endpoints)
while IFS= read -r endpoint; do
consider_endpoint "$endpoint"
done < <(adb_discover_mdns_endpoints)
local key ip
for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
ip="$(adb_ip_for_hint_key "$key")"
[[ -n "${host_best[$ip]:-}" ]] && continue
consider_endpoint "${ip}:5555"
done
adb_filter_endpoints_by_tcp "${ordered[@]}"
}
# Drop closed ports (fast nc/bash probe). If none open, return candidates anyway.
adb_filter_endpoints_by_tcp() {
local -a all=("$@")
local -a open=()
local e
if [[ "${ADB_TCP_PROBE}" =~ ^[nN] ]]; then
printf '%s\n' "${all[@]}"
return 0
fi
for e in "${all[@]}"; do
[[ -z "$e" ]] && continue
if adb_tcp_port_open "$e"; then
open+=("$e")
else
echo " probe ${e} — closed (skip)" >&2
fi
done
if [[ ${#open[@]} -gt 0 ]]; then
printf '%s\n' "${open[@]}"
else
printf '%s\n' "${all[@]}"
fi
}
adb_poke_refresh_broadcast() {
local serial
while IFS= read -r serial; do
[[ -z "$serial" ]] && continue
adb -s "$serial" shell am broadcast \
-a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
-n com.foxx.androidcast/.dev.DevAdbWifiReceiver >/dev/null 2>&1 || true
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
}
# Wireless TLS adb often shows as adb-xxx._adb-tls-connect._tcp, not IP:port.
adb_resolve_device_serial() {
local endpoint=${1}
local serial state host count=0 last=""
if adb -s "$endpoint" get-state 2>/dev/null | grep -q '^device$'; then
printf '%s' "$endpoint"
return 0
fi
host="$(adb_endpoint_host "$endpoint")"
while IFS= read -r serial state; do
[[ "$state" != "device" ]] && continue
count=$((count + 1))
last="$serial"
if [[ "$serial" == "$endpoint" ]]; then
printf '%s' "$serial"
return 0
fi
if [[ -n "$host" && "$serial" == *"$host"* ]]; then
printf '%s' "$serial"
return 0
fi
done < <(adb devices 2>/dev/null | awk 'NR > 1 && NF >= 2 { print $1, $2 }')
if [[ "$count" -eq 1 && -n "$last" ]]; then
printf '%s' "$last"
return 0
fi
return 1
}
adb_parse_connect_serial() {
local out=${1}
if [[ "$out" =~ [Cc]onnected\ to\ ([^[:space:]]+) ]]; then
printf '%s' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
adb_host_has_device() {
local host=${1}
local serial
serial="$(adb_resolve_device_serial "${host}:1" 2>/dev/null || true)"
[[ -n "$serial" ]]
}
adb_reconnect_endpoint() {
local endpoint="$1"
[[ -z "$endpoint" ]] && return 1
if ! adb_is_connectable_ip_port "$endpoint"; then
echo " skip ${endpoint} — not IPv4:PORT" >&2
return 1
fi
local attempt out hint resolved
hint="$(adb_hint_for_endpoint "${endpoint}")"
[[ -n "${hint}" ]] && hint="(${hint})"
if [[ "${ADB_TCP_PROBE}" =~ [yY] ]] && ! adb_tcp_port_open "$endpoint"; then
echo " skip ${endpoint} — TCP closed ${hint}" >&2
return 1
fi
resolved="$(adb_resolve_device_serial "$endpoint" 2>/dev/null || true)"
if [[ -n "$resolved" ]]; then
echo " ${endpoint} already connected as ${resolved} ${hint}" >&2
printf '%s' "$resolved"
return 0
fi
echo " adb connect ${endpoint} ${hint}" >&2
if [[ "${ADB_DISCONNECT_BEFORE_CONNECT}" =~ [yY] ]]; then
adb disconnect "$endpoint" >/dev/null 2>&1 || true
fi
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
out="$(adb connect "$endpoint" 2>&1)" || true
sleep 1
resolved="$(adb_parse_connect_serial "$out" 2>/dev/null || true)"
if [[ -z "$resolved" ]]; then
resolved="$(adb_resolve_device_serial "$endpoint" 2>/dev/null || true)"
fi
if [[ -n "$resolved" ]]; then
echo " ${resolved} — ready (${out})" >&2
printf '%s' "$resolved"
return 0
fi
if [[ "$attempt" -lt "$ADB_CONNECT_RETRIES" ]]; then
sleep "$ADB_CONNECT_DELAY_SEC"
fi
done
echo " ${endpoint} — not reachable (${out})" >&2
return 1
}
adb_update_connect_cache() {
local serial
mkdir -p "$(dirname "$ADB_CONNECT_CACHE")"
: >"$ADB_CONNECT_CACHE"
while IFS= read -r serial; do
adb_is_connectable_ip_port "$serial" || continue
echo "$serial" >>"$ADB_CONNECT_CACHE"
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
}
adb_reconnect_prepare() {
export ADB_MDNS_AUTO_CONNECT
adb start-server >/dev/null 2>&1 || true
if [[ "${ADB_KILL_SERVER}" =~ [yY] ]]; then
adb kill-server >/dev/null 2>&1 || true
adb start-server >/dev/null 2>&1 || true
fi
adb reconnect offline >/dev/null 2>&1 || true
}
adb_reconnect_all() {
local label=${1:-post-build}
echo "==> Reconnecting wireless adb targets (${label})"
adb_reconnect_prepare
adb_poke_refresh_broadcast
if [[ "${ADB_RECONNECT_POKE_DELAY_SEC:-0}" -gt 0 ]]; then
sleep "$ADB_RECONNECT_POKE_DELAY_SEC"
fi
local endpoint connected=0 host resolved
declare -A connected_hosts=()
while IFS= read -r serial; do
host="$(adb_endpoint_host "$serial")"
[[ -n "$host" ]] && connected_hosts["$host"]="$serial"
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
while IFS= read -r endpoint; do
[[ -z "$endpoint" ]] && continue
host="$(adb_endpoint_host "$endpoint")"
if [[ -n "$host" && -n "${connected_hosts[$host]:-}" ]]; then
echo " skip ${endpoint}${host} already on ${connected_hosts[$host]}"
continue
fi
if resolved="$(adb_reconnect_endpoint "$endpoint")"; then
connected=$((connected + 1))
host="$(adb_endpoint_host "$resolved")"
[[ -z "$host" ]] && host="$(adb_endpoint_host "$endpoint")"
[[ -n "$host" ]] && connected_hosts["$host"]="$resolved"
fi
done < <(adb_discover_endpoints)
if [[ "$connected" -eq 0 ]]; then
echo " retry: HTTP-only pass"
while IFS= read -r endpoint; do
[[ -z "$endpoint" ]] && continue
if resolved="$(adb_reconnect_endpoint "$endpoint")"; then
connected=$((connected + 1))
fi
done < <(adb_fetch_http_endpoints)
fi
adb_update_connect_cache
adb devices -l
if [[ "$connected" -gt 0 ]]; then
return 0
fi
return 1
}
adb_count_ready_devices() {
adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { c++ } END { print c+0 }'
}
adb_prepare_device_for_deploy() {
local serial=${1:-}
local -a adb_args=()
[[ -n "$serial" ]] && adb_args=(-s "$serial")
adb "${adb_args[@]}" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 \
|| adb "${adb_args[@]}" shell input keyevent 224 >/dev/null 2>&1 \
|| true
adb "${adb_args[@]}" shell am broadcast \
-a com.foxx.androidcast.dev.PREPARE_FOR_DEPLOY \
-n com.foxx.androidcast/.dev.DebugDeployWakeReceiver >/dev/null 2>&1 || true
}
adb_http_status() {
local ip=${1}
local json
json="$(adb_curl_adb_json "$ip" 2>/dev/null || true)"
if [[ -z "$json" ]]; then
echo "${ip}: no response (app not running or port ${DEV_ADB_HTTP_PORT} closed)"
return 1
fi
echo "${ip}: ok"
adb_json_connect_lines "$json" | sed 's/^/ connect /'
}

47
alpha-qa-logcat.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Capture paired sender/receiver logcat for alpha QA (run from repo root).
set -euo pipefail
SENDER="${1:-}"
RECEIVER="${2:-}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT_DIR="$(dirname "$0")/../alpha-logs-${STAMP}"
mkdir -p "$OUT_DIR"
TAGS='ScreenCastService:* ReceiverCastService:* AndroidCast:* SenderScreenPreview:* CastProtocol:*'
adb_cmd() {
local serial="$1"
shift
if [[ -n "$serial" ]]; then
adb -s "$serial" "$@"
else
adb "$@"
fi
}
echo "Writing logs to $OUT_DIR"
echo "Press Ctrl+C to stop."
if [[ -n "$SENDER" ]]; then
adb_cmd "$SENDER" logcat -c
fi
if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then
adb_cmd "$RECEIVER" logcat -c
fi
trap 'echo; echo "Stopped."' INT
if [[ -n "$SENDER" ]]; then
adb_cmd "$SENDER" logcat $TAGS | tee "$OUT_DIR/sender.log" &
PID_S=$!
fi
if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then
adb_cmd "$RECEIVER" logcat $TAGS | tee "$OUT_DIR/receiver.log" &
PID_R=$!
elif [[ -z "$SENDER" ]]; then
adb logcat $TAGS | tee "$OUT_DIR/combined.log"
exit 0
fi
wait ${PID_S:-} ${PID_R:-}

91
android-ndk.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Android NDK discovery for AndroidCast build scripts.
# Source this file: source "$(dirname "$0")/android-ndk.sh"
#
# Optional env:
# ANDROIDCAST_NDK_VERSION — pin a version under $HOME/Android/Sdk/ndk (e.g. 29.0.14206865)
# ANDROID_NDK_HOME / ANDROID_NDK_ROOT — used when valid
#
set -euo pipefail
_androidcast_sdk_base() {
printf '%s\n' "${ANDROIDCAST_SDK_BASE:-${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}}"
}
# Print absolute NDK root (directory containing ndk-build). Errors on stderr.
androidcast_find_ndk_root() {
local project_root="${1:-}"
local ndk="" ndk_root ndk_ver parent pinned
if [[ -n "$project_root" && -f "$project_root/local.properties" ]]; then
ndk="$(sed -n 's/^[[:space:]]*ndk\.dir=//p' "$project_root/local.properties" | tr -d '\r' | head -1)"
ndk="${ndk%/}"
while [[ -n "$ndk" ]]; do
if [[ -x "$ndk/ndk-build" ]]; then
realpath "$ndk"
return 0
fi
parent="$(dirname "$ndk")"
[[ "$parent" == "$ndk" ]] && break
ndk="$parent"
done
fi
for ndk in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}"; do
ndk="${ndk%/}"
if [[ -n "$ndk" && -x "$ndk/ndk-build" ]]; then
realpath "$ndk"
return 0
fi
done
ndk_root="$(_androidcast_sdk_base)/ndk"
if [[ ! -d "$ndk_root" ]]; then
echo "ERROR: No Android NDK found. Install via SDK Manager ($ndk_root) or set ANDROID_NDK_HOME." >&2
return 1
fi
pinned="${ANDROIDCAST_NDK_VERSION:-}"
if [[ -n "$pinned" ]]; then
if [[ -d "$ndk_root/$pinned" ]]; then
ndk_ver="$pinned"
else
echo "ERROR: ANDROIDCAST_NDK_VERSION=$pinned not found under $ndk_root" >&2
return 1
fi
else
ndk_ver="$(ls -1 "$ndk_root" 2>/dev/null | sort -V | tail -n 1 || true)"
if [[ -z "$ndk_ver" ]]; then
echo "ERROR: No NDK versions under $ndk_root" >&2
return 1
fi
fi
ndk="$ndk_root/$ndk_ver"
if [[ ! -x "$ndk/ndk-build" ]]; then
echo "ERROR: ndk-build missing under $ndk" >&2
return 1
fi
realpath "$ndk"
}
# Print llvm prebuilt bin directory for the current host.
androidcast_ndk_host_bin() {
local ndk_root="${1:?NDK root required}"
local os arch host_tag
case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) os="$(uname -s | tr '[:upper:]' '[:lower:]')" ;;
esac
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) arch=x86_64 ;;
aarch64 | arm64) arch=aarch64 ;;
esac
host_tag="${os}-${arch}"
if [[ ! -d "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin" ]]; then
host_tag="linux-x86_64"
fi
realpath "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin"
}

86
apply-project-headers.py Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Apply HEADER.txt blocks to all project sources."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from header_lib import (
REPO_ROOT,
apply_header_to_content,
has_valid_header,
header_needs_identity_refresh,
is_excluded_path,
iter_source_files,
)
def main() -> int:
parser = argparse.ArgumentParser(description="Apply project headers to source files")
parser.add_argument("--check", action="store_true", help="Only report files missing headers")
parser.add_argument(
"--fix-identity",
action="store_true",
help="Re-apply headers where contributors lack email but git has one",
)
parser.add_argument("--path", type=str, default="", help="Limit to one file or directory")
args = parser.parse_args()
root = REPO_ROOT
targets = list(iter_source_files(root))
if args.path:
p = Path(args.path)
if not p.is_absolute():
p = REPO_ROOT / p
if p.is_file():
targets = [p]
else:
targets = [f for f in iter_source_files(root) if str(f).startswith(str(p))]
missing = []
identity_fix = []
changed = 0
skipped_vendor = 0
for path in sorted(targets):
if is_excluded_path(path):
skipped_vendor += 1
continue
text = path.read_text(encoding="utf-8", errors="replace")
if args.check:
if not has_valid_header(text):
missing.append(path.relative_to(REPO_ROOT))
elif header_needs_identity_refresh(text, path):
identity_fix.append(path.relative_to(REPO_ROOT))
continue
if args.fix_identity:
if not header_needs_identity_refresh(text, path) and has_valid_header(text):
continue
new_text = apply_header_to_content(path, text)
if new_text != text:
path.write_text(new_text, encoding="utf-8", newline="\n")
changed += 1
if args.check:
if missing:
print("Missing headers:", len(missing))
for m in missing[:50]:
print(" ", m)
if len(missing) > 50:
print(" ...")
if identity_fix:
print("Identity refresh suggested:", len(identity_fix))
for m in identity_fix[:30]:
print(" ", m)
if not missing and not identity_fix:
print("All", len(targets) - skipped_vendor, "tracked files OK (third-party skipped).")
return 0
return 1 if missing else 0
note = f" (skipped {skipped_vendor} under third-party/vendor paths)" if skipped_vendor else ""
print(f"Updated {changed} / {len(targets) - skipped_vendor} source files{note}.")
return 0
if __name__ == "__main__":
sys.exit(main())

15
build-all-docs-pdf.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Render docs/**/*.md to sibling .pdf, then diagram/special PDF builders.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PDF_BUILD="$ROOT_DIR/docs/_pdf_build"
python3 "$PDF_BUILD/build_all_docs_pdf.py"
for extra in \
build_git_flow_pdf.py \
build_graphvis_pivot_pdf.py \
build_egress_review_pdf.py; do
if [[ -f "$PDF_BUILD/$extra" ]]; then
python3 "$PDF_BUILD/$extra" || echo "WARN: $extra failed (optional)" >&2
fi
done
echo "Done: PDFs under $ROOT_DIR/docs/ (recursive + diagram builders)"

7
build-graphvis-pdf.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
python3 "$ROOT_DIR/docs/_pdf_build/build_graphvis_pivot_pdf.py"
echo "Generated: $ROOT_DIR/docs/GRAFANA_vs_others_graphvis_pivot.pdf"

342
build-native-codecs.sh Executable file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env bash
# Build libvpx, libopus, and libspeex for Android ABIs → build/native/<abi>/*.a
# CMake in ndk/ autolinks when those archives exist (ANDROIDCAST_HAVE_* = 1).
#
# Prerequisites:
# bash scripts/init-third-party-submodules.sh
# Android NDK (auto-detected via scripts/android-ndk.sh)
#
# Usage:
# ./scripts/build-native-codecs.sh arm64-v8a
# ANDROIDCAST_LIBVPX_LIB=$PWD/build/native/arm64-v8a/libvpx.a ./gradlew assembleDebug
#
# Produces (when sources present): libvpx.a, libopus.a, libspeex.a under build/native/<abi>/
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ABI="${1:-arm64-v8a}"
VPX_SRC_REAL="$ROOT/third-party/libvpx"
PROJECT_OUT="$ROOT/build/native/$ABI"
OUT="$PROJECT_OUT"
BUILD_DIR="$OUT/libvpx-build"
if [[ ! -d "$VPX_SRC_REAL" ]]; then
echo "ERROR: $VPX_SRC_REAL missing. Run:"
echo " bash scripts/init-third-party-submodules.sh"
exit 1
fi
# shellcheck source=android-ndk.sh
source "$ROOT/scripts/android-ndk.sh"
if ! NDK="$(androidcast_find_ndk_root "$ROOT")"; then
exit 1
fi
NDK_HOST_BIN="$(androidcast_ndk_host_bin "$NDK")"
if [[ ! -d "$NDK_HOST_BIN" ]]; then
echo "ERROR: NDK host toolchain bin not found under $NDK"
exit 1
fi
case "$ABI" in
arm64-v8a) VPX_TARGET=arm64-android-gcc ;;
armeabi-v7a) VPX_TARGET=armv7-android-gcc ;;
x86_64) VPX_TARGET=x86_64-android-gcc ;;
x86) VPX_TARGET=x86-android-gcc ;;
*)
echo "Unsupported ABI: $ABI"
exit 1
;;
esac
# libvpx Makefiles break on spaces in SRC_PATH_BARE.
path_has_space() {
case "$1" in
*" "*) return 0 ;;
*) return 1 ;;
esac
}
VPX_SRC="$VPX_SRC_REAL"
STAGING=""
if path_has_space "$ROOT" || path_has_space "$VPX_SRC_REAL"; then
STAGING="/tmp/androidcast-libvpx-${USER:-build}"
VPX_SRC="$STAGING/src"
BUILD_DIR="$STAGING/build/$ABI"
OUT="$STAGING/out/$ABI"
mkdir -p "$STAGING/build" "$STAGING/out"
ln -sfn "$VPX_SRC_REAL" "$VPX_SRC"
echo "Repo path contains spaces; using space-free staging under $STAGING"
fi
mkdir -p "$BUILD_DIR" "$OUT" "$PROJECT_OUT"
export ANDROID_NDK_HOME="$NDK"
export ANDROID_NDK_ROOT="$NDK"
export PATH="$NDK_HOST_BIN:$PATH"
# shellcheck source=native-build-cache.sh
source "$ROOT/scripts/native-build-cache.sh"
androidcast_apply_native_build_accelerators "$ROOT" "$ABI" "$NDK" "$NDK_HOST_BIN"
VPX_CONFIGURE_EXTRA=()
APP_CFLAGS_EXTRA=""
case "$ABI" in
x86)
# NDK does not infer -msse4.1/-mavx from libvpx source names; AVX-512 is unused on Android x86.
VPX_CONFIGURE_EXTRA+=(--disable-avx512)
APP_CFLAGS_EXTRA="-msse4.1 -mavx"
;;
x86_64)
VPX_CONFIGURE_EXTRA+=(--disable-avx512)
APP_CFLAGS_EXTRA="-msse4.1 -mavx -mavx2"
;;
esac
## common native optimization options
APP_CFLAGS_EXTRA+=" -O2 -fdata-sections -ffunction-sections -Wl,--gc-sections -ftree-vectorize -O2 -s -funroll-loops -fomit-frame-pointer "
echo "Building libvpx for $ABI (target=$VPX_TARGET) ..."
echo " source: $VPX_SRC"
echo " build: $BUILD_DIR"
cd "$BUILD_DIR"
# Out-of-tree build fails if someone previously ran configure inside third-party/libvpx.
if [[ -f "$VPX_SRC/vpx_config.h" ]]; then
src_real="$(cd "$VPX_SRC" && pwd)"
build_real="$(pwd)"
if [[ "$src_real" != "$build_real" ]]; then
echo "Cleaning in-tree configure artifacts from libvpx source (needed for out-of-tree build)..."
make -C "$VPX_SRC" distclean >/dev/null 2>&1 || true
rm -f "$VPX_SRC"/vpx_config.h "$VPX_SRC"/vpx_config.c "$VPX_SRC"/vpx_version.h \
"$VPX_SRC"/config.mk "$VPX_SRC"/Makefile
rm -f "$VPX_SRC"/libs-*.mk "$VPX_SRC"/vp*_rtcd.h "$VPX_SRC"/libvpx*.a 2>/dev/null || true
fi
fi
LIBS_MK="$BUILD_DIR/libs-${VPX_TARGET}.mk"
need_configure=false
if [[ ! -f Makefile ]]; then
need_configure=true
elif [[ ! -f "$LIBS_MK" ]] || ! grep -q "^TOOLCHAIN := ${VPX_TARGET}\$" "$BUILD_DIR/config.mk" 2>/dev/null; then
echo "Re-configuring (stale or wrong ABI in $BUILD_DIR)..."
make distclean >/dev/null 2>&1 || rm -f Makefile config.mk "$LIBS_MK" vpx_config.h vpx_config.c vpx_config.asm \
vpx_version.h vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
need_configure=true
elif [[ "$ABI" == x86 || "$ABI" == x86_64 ]] && [[ -f "$BUILD_DIR/vpx_config.h" ]] \
&& grep -q '#define HAVE_AVX512 1' "$BUILD_DIR/vpx_config.h"; then
echo "Re-configuring (AVX-512 disabled for Android NDK on $ABI)..."
make distclean >/dev/null 2>&1 || rm -f Makefile config.mk "$LIBS_MK" vpx_config.h vpx_config.c vpx_config.asm \
vpx_version.h vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
need_configure=true
fi
if $need_configure; then
# CC/CXX may be ccache-wrapped NDK clang (see native-build-cache.sh).
"$VPX_SRC/configure" \
--target="$VPX_TARGET" \
--disable-examples \
--disable-tools \
--disable-unit-tests \
--disable-webm-io \
--enable-vp8 \
--enable-vp9 \
--enable-static \
--disable-shared \
--extra-cflags="-fPIC -O2" \
--enable-multi-res-encoding \
--enable-vp9-temporal-denoising \
--enable-onthefly-bitpacking \
--enable-error-concealment \
--enable-realtime-only \
--enable-postproc \
--enable-vp9-postproc \
--enable-multithread \
--enable-spatial-resampling \
--disable-debug-libs \
--disable-docs \
--enable-external-build \
"${VPX_CONFIGURE_EXTRA[@]}"
fi
# armeabi-v7a ndk-build runs make in ndk-app/ and needs vpx_config.asm (not required for arm64).
make vpx_config.asm 2>/dev/null || true
if [[ -f "$BUILD_DIR/vpx_config.asm" ]] && [[ ! -s "$BUILD_DIR/vpx_config.asm" ]]; then
rm -f "$BUILD_DIR/vpx_config.asm"
make vpx_config.asm
fi
# RTCD headers (configure creates them; refresh if missing).
make -j1 vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
# NDK project root must not contain libvpx's top-level Makefile (ndk-build would invoke it).
NDK_ROOT="$BUILD_DIR/ndk-app"
JNI_DIR="$NDK_ROOT/jni"
rm -rf "$NDK_ROOT"
mkdir -p "$JNI_DIR"
ln -sfn "$VPX_SRC" "$JNI_DIR/libvpx"
# NDK Android.mk prepends jni/ to ASM_CONVERSION; keep the configure-generated absolute path
# in BUILD_DIR for `make vpx_config.asm`, and use a jni-only copy for ndk-build.
LIBS_MK_NDK="$BUILD_DIR/libs-ndk-${VPX_TARGET}.mk"
if [[ -f "$LIBS_MK" ]]; then
cp -f "$LIBS_MK" "$LIBS_MK_NDK"
sed -i 's|^ASM_CONVERSION=.*|ASM_CONVERSION=libvpx/build/make/ads2gas.pl|' "$LIBS_MK_NDK"
fi
for f in config.mk vpx_config.c vpx_config.h vpx_config.asm vpx_version.h \
vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h; do
if [[ -f "$BUILD_DIR/$f" ]]; then
ln -sfn "$BUILD_DIR/$f" "$JNI_DIR/$f"
# ndk-build may invoke make in ndk-app/ (not jni/) for RTCD / vpx_config.asm on armeabi-v7a.
ln -sfn "$BUILD_DIR/$f" "$NDK_ROOT/$f"
fi
done
if [[ -f "$LIBS_MK_NDK" ]]; then
ln -sfn "$LIBS_MK_NDK" "$JNI_DIR/libs-${VPX_TARGET}.mk"
fi
{
echo "APP_ABI := $ABI"
echo "APP_PLATFORM := android-29"
echo "APP_STL := c++_static"
if [[ -n "$APP_CFLAGS_EXTRA" ]]; then
echo "APP_CFLAGS += $APP_CFLAGS_EXTRA"
fi
} > "$JNI_DIR/Application.mk"
cat > "$JNI_DIR/Android.mk" <<'EOF'
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
# configure outputs (RTCD headers, vpx_config.h) live in jni/ — not inside libvpx sources
LOCAL_CFLAGS += -I$(LOCAL_PATH)
include $(LOCAL_PATH)/libvpx/build/make/Android.mk
EOF
"$NDK/ndk-build" -C "$NDK_ROOT" -j"$(nproc)"
LIBVPX_A="$NDK_ROOT/obj/local/$ABI/libvpx.a"
if [[ ! -f "$LIBVPX_A" ]]; then
echo "ERROR: expected $LIBVPX_A after ndk-build"
exit 1
fi
cp -f "$LIBVPX_A" "$OUT/libvpx.a"
if [[ "$OUT" != "$PROJECT_OUT" ]]; then
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
fi
androidcast_ndk_api() { echo 29; }
androidcast_ndk_clang() {
local abi="$1"
local api
api="$(androidcast_ndk_api)"
case "$abi" in
arm64-v8a) echo "$NDK_HOST_BIN/aarch64-linux-android${api}-clang" ;;
armeabi-v7a) echo "$NDK_HOST_BIN/armv7a-linux-androideabi${api}-clang" ;;
x86_64) echo "$NDK_HOST_BIN/x86_64-linux-android${api}-clang" ;;
x86) echo "$NDK_HOST_BIN/i686-linux-android${api}-clang" ;;
*) echo "ERROR: unsupported ABI $abi" >&2; return 1 ;;
esac
}
androidcast_ndk_configure_host() {
case "$1" in
arm64-v8a) echo aarch64-linux-android ;;
armeabi-v7a) echo armv7a-linux-androideabi ;;
x86_64) echo x86_64-linux-android ;;
x86) echo i686-linux-android ;;
*) echo "$1" ;;
esac
}
build_opus_for_abi() {
local opus_src="$ROOT/third-party/opus"
if [[ ! -f "$opus_src/CMakeLists.txt" ]]; then
echo "Skip libopus: $opus_src missing (bash scripts/init-third-party-submodules.sh)"
return 0
fi
local opus_build="$BUILD_DIR/opus-cmake"
rm -rf "$opus_build"
echo "Building libopus for $ABI (CMake) ..."
cmake -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI="$ABI" \
-DANDROID_PLATFORM="android-$(androidcast_ndk_api)" \
-DBUILD_SHARED_LIBS=OFF \
-DOPUS_BUILD_SHARED_LIBRARY=OFF \
-DOPUS_BUILD_TESTING=OFF \
-DOPUS_BUILD_PROGRAMS=OFF \
-DCMAKE_BUILD_TYPE=Release \
-B "$opus_build" -S "$opus_src"
cmake --build "$opus_build" --target opus -j"$(nproc)"
local opus_a="$opus_build/libopus.a"
if [[ ! -f "$opus_a" ]]; then
opus_a="$(find "$opus_build" -name 'libopus.a' | head -1)"
fi
if [[ ! -f "$opus_a" ]]; then
echo "ERROR: libopus.a not found after Opus build"
exit 1
fi
cp -f "$opus_a" "$PROJECT_OUT/libopus.a"
echo "Built: $PROJECT_OUT/libopus.a (size $(ls -lah "${PROJECT_OUT}/libopus.a" | awk '{print $5}'))"
}
build_speex_for_abi() {
local speex_src_real="$ROOT/third-party/speex"
if [[ ! -d "$speex_src_real" ]]; then
echo "Skip libspeex: $speex_src_real missing (bash scripts/init-third-party-submodules.sh)"
return 0
fi
local speex_src="$speex_src_real"
local speex_build="$BUILD_DIR/speex-autotools"
if path_has_space "$speex_src_real"; then
speex_src="$STAGING/speex-src"
ln -sfn "$speex_src_real" "$speex_src"
fi
if [[ ! -f "$speex_src/configure" ]]; then
echo "Running speex autogen.sh ..."
(cd "$speex_src" && ./autogen.sh)
fi
rm -rf "$speex_build"
mkdir -p "$speex_build"
local cc host
cc="$(androidcast_ndk_clang "$ABI")"
host="$(androidcast_ndk_configure_host "$ABI")"
echo "Building libspeex for $ABI (autotools) ..."
(
cd "$speex_build"
"$speex_src/configure" \
--host="$host" \
--prefix="$speex_build/install" \
--disable-shared \
--enable-static \
--disable-binaries \
--with-pic \
CC="$cc" \
CFLAGS="-fPIC -O2"
make -j"$(nproc)"
)
local speex_a="$speex_build/libspeex/.libs/libspeex.a"
if [[ ! -f "$speex_a" ]]; then
speex_a="$(find "$speex_build" -name 'libspeex.a' | head -1)"
fi
if [[ ! -f "$speex_a" ]]; then
echo "ERROR: libspeex.a not found after Speex build"
exit 1
fi
cp -f "$speex_a" "$PROJECT_OUT/libspeex.a"
echo "Built: $PROJECT_OUT/libspeex.a (size $(ls -lah "${PROJECT_OUT}/libspeex.a" | awk '{print $5}'))"
}
build_opus_for_abi
build_speex_for_abi
echo ""
echo "Built: $PROJECT_OUT/libvpx.a (size $(ls -lah "${PROJECT_OUT}/libvpx.a" | awk '{print $5}'))"
if [[ -n "$STAGING" ]]; then
echo "(staged under $STAGING because of spaces in repo path)"
fi
echo "Build other ABIs as needed, e.g.:"
echo " ./scripts/build-native-codecs.sh armeabi-v7a"
echo "Then rebuild the APK (CMake autolinks build/native/<abi>/*.a per ABI):"
echo " ./gradlew assembleDebug"

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
python3 "$ROOT_DIR/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py"
echo "Generated: $ROOT_DIR/docs/20260602_REVERSE_SSH_proposals_summary.pdf"

41
capture-vp9-fec-logs.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Capture VP9+FEC cast logs from sender (.106) and receiver (.39).
# Usage: ./scripts/capture-vp9-fec-logs.sh [label]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CAP="$ROOT/.log_capture"
TS="$(date +%Y%m%d_%H%M%S)"
LABEL="${1:-fec}"
mkdir -p "$CAP"
SENDER="192.168.33.106:5555"
RECEIVER="192.168.33.39:5555"
adb connect "$SENDER" >/dev/null 2>&1 || true
adb connect "$RECEIVER" >/dev/null 2>&1 || true
FILTER='FEC|Fec|fec|UdpCast|StreamProtection|Protection|reasm|ReedSolomon|shard|ScreenCast|Libvpx|ReceiverCast|ReceiverSession|VideoDecoder|NativeCodec|CodecPriority|CastFanout|CastSendPump|NetworkFeedback|transportLoss|decode|keyframe|awaiting|AudioDecoder|drop'
echo "==> Clearing logcat buffers"
adb -s "$SENDER" logcat -c 2>/dev/null || true
adb -s "$RECEIVER" logcat -c 2>/dev/null || true
SPID=$(adb -s "$SENDER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
RPID=$(adb -s "$RECEIVER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
echo "==> PIDs sender=$SPID receiver=$RPID"
echo "==> Recording 90s (start cast now)..."
timeout 90 adb -s "$SENDER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_sender_106_${TS}.log" &
PID_S=$!
timeout 90 adb -s "$RECEIVER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_receiver_39_${TS}.log" &
PID_R=$!
wait "$PID_S" "$PID_R" 2>/dev/null || true
# Snapshot dumps after live capture
adb -s "$SENDER" logcat -d --pid="$SPID" -t 8000 > "$CAP/${LABEL}_sender_pid_${TS}.log" 2>&1 || true
adb -s "$RECEIVER" logcat -d --pid="$RPID" -t 8000 > "$CAP/${LABEL}_receiver_pid_${TS}.log" 2>&1 || true
adb -s "$SENDER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_sender_crash_${TS}.log" || true
adb -s "$RECEIVER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_receiver_crash_${TS}.log" || true
echo "==> Saved under $CAP/${LABEL}_*_${TS}.log"
wc -c "$CAP"/${LABEL}_*_${TS}.log 2>/dev/null | tail -6

151
ci-build-and-publish-ota.sh Executable file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# CI pipeline entry: optional git checkout, native, gradle, OTA publish.
#
# Env:
# GRADLE_TASK assembleDebug | assembleRelease | testDebugUnitTest | …
# RUN_UNIT_TESTS 1 (default) | 0
# RUN_NATIVE 1 (default) | 0
# RUN_APK 1 (default) | 0 — set 0 for tests-only
# OTA_BASE_URL public base for v0/ota (empty = skip OTA)
# OTA_CHANNEL stable | staging | dev | nightly | custom name
# AUTO_OTA 1 to emit OTA tree under OUT_DIR
# OUT_DIR artifact root (default ./out/builds/$BUILD_ID)
# BUILD_ID build record id
# GIT_REF branch | tag | commit to checkout before build
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
BUILD_ID="${BUILD_ID:-local}"
OUT_DIR="${OUT_DIR:-${ROOT}/out/builds/${BUILD_ID}}"
GRADLE_TASK="${GRADLE_TASK:-assembleDebug}"
RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}"
RUN_NATIVE="${RUN_NATIVE:-1}"
RUN_APK="${RUN_APK:-1}"
OTA_CHANNEL="${OTA_CHANNEL:-staging}"
AUTO_OTA="${AUTO_OTA:-0}"
mkdir -p "$OUT_DIR"
exec > >(tee -a "${OUT_DIR}/build.log") 2>&1
echo "==> Phase: init build_id=${BUILD_ID} channel=${OTA_CHANNEL}"
if [[ -n "${GIT_REF:-}" ]]; then
echo "==> Phase: git checkout ${GIT_REF}"
git fetch --all --tags 2>/dev/null || true
git checkout --force "$GIT_REF"
if [[ -n "${GIT_SHA:-}" ]]; then
git reset --hard "$GIT_SHA"
fi
fi
export ANDROIDCAST_ROOT="$ROOT"
export ANDROIDCAST_CCACHE="${ANDROIDCAST_CCACHE:-1}"
GRADLE_JAVA_OPT=()
if [[ -n "${JAVA_HOME:-}" ]]; then
GRADLE_JAVA_OPT=(-Dorg.gradle.java.home="${JAVA_HOME}")
fi
# shellcheck source=android-ndk.sh
source "$ROOT/scripts/android-ndk.sh"
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
LOCAL_PROPS="$ROOT/local.properties"
LOCAL_PROPS_BACKUP=""
if [[ -f "$LOCAL_PROPS" ]]; then
LOCAL_PROPS_BACKUP="$(mktemp)"
cp -f "$LOCAL_PROPS" "$LOCAL_PROPS_BACKUP"
fi
trap 'if [[ -n "${LOCAL_PROPS_BACKUP:-}" && -f "${LOCAL_PROPS_BACKUP}" ]]; then cp -f "${LOCAL_PROPS_BACKUP}" "$LOCAL_PROPS"; rm -f "${LOCAL_PROPS_BACKUP}"; else rm -f "$LOCAL_PROPS"; fi' EXIT
printf 'sdk.dir=%s\n' "${ANDROID_SDK_ROOT//\\/\\\\}" > "$LOCAL_PROPS"
fi
echo "==> Phase: third-party submodules (libvpx, opus, speex, wireguard-android)"
bash "$ROOT/scripts/init-third-party-submodules.sh"
if [[ "$RUN_NATIVE" == "1" && "$RUN_APK" == "1" ]]; then
echo "==> Phase: native codecs"
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
rm -rf "$ROOT/build/native"
mkdir -p "$ROOT/build/native"
for abi in $ABIS; do
./scripts/build-native-codecs.sh "$abi"
done
else
echo "==> Phase: native codecs (skipped)"
fi
# Avoid host-specific CMake cache paths when running in Docker.
rm -rf "$ROOT/app/.cxx" "$ROOT/app/build/.cxx" "$ROOT/app/build/intermediates/cxx"
rm -rf "$ROOT/gradle/wireguard-tunnel/.cxx" "$ROOT/gradle/wireguard-tunnel/build" 2>/dev/null || true
WG_HEAD="$(git -C "$ROOT/third-party/wireguard-android" rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "==> Phase: wireguard-android (:tunnel) third-party/wireguard-android @ ${WG_HEAD}"
if [[ "$RUN_APK" == "1" ]]; then
echo "==> Phase: gradle :tunnel + ${GRADLE_TASK}"
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}" testDebugUnitTest
else
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}"
fi
else
echo "==> Phase: gradle tests-only (:tunnel + testDebugUnitTest)"
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" :tunnel:assembleDebug testDebugUnitTest
fi
APK=""
if [[ "$RUN_APK" == "1" ]]; then
for candidate in \
"$ROOT/app/build/outputs/apk/debug/app-debug.apk" \
"$ROOT/app/build/outputs/apk/release/app-release-unsigned.apk" \
"$ROOT/app/build/outputs/apk/release/app-release.apk"; do
if [[ -f "$candidate" ]]; then
APK="$candidate"
break
fi
done
if [[ -z "$APK" ]]; then
echo "ERROR: no APK produced" >&2
exit 1
fi
cp -f "$APK" "${OUT_DIR}/android_cast-latest.apk"
echo "==> APK: ${OUT_DIR}/android_cast-latest.apk"
fi
if [[ "$AUTO_OTA" == "1" && -n "${OTA_BASE_URL:-}" && -n "$APK" ]]; then
echo "==> Phase: OTA publish channel=${OTA_CHANNEL}"
OTA_PUBLISH="${OUT_DIR}/ota-publish"
rm -rf "$OTA_PUBLISH"
./scripts/generate-ota-v0.sh "$APK" "$OTA_BASE_URL" "$OTA_PUBLISH" "$OTA_CHANNEL"
mkdir -p "${OUT_DIR}/ota"
cp -rf "${OTA_PUBLISH}/v0" "${OUT_DIR}/ota/"
for ch in staging dev nightly stable; do
if [[ "$OTA_CHANNEL" == "$ch" ]]; then
continue
fi
if [[ "$OTA_CHANNEL" == "stable" || "$OTA_CHANNEL" == "prod" || "$OTA_CHANNEL" == "production" || "$OTA_CHANNEL" == "release" ]]; then
./scripts/generate-ota-v0.sh "$APK" "$OTA_BASE_URL" "${OUT_DIR}/ota-publish-${ch}" "$ch" || true
mkdir -p "${OUT_DIR}/ota-${ch}"
cp -rf "${OUT_DIR}/ota-publish-${ch}/v0" "${OUT_DIR}/ota-${ch}/" 2>/dev/null || true
fi
done
fi
cat >"${OUT_DIR}/BUILD_INFO.json" <<EOF
{
"buildId": "${BUILD_ID}",
"gradleTask": "${GRADLE_TASK}",
"otaChannel": "${OTA_CHANNEL}",
"apk": "android_cast-latest.apk",
"gitSha": "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)",
"gitRef": "${GIT_REF:-}",
"ciVersion": "${ANDROIDCAST_CI_VERSION:-00.01.00.1000}"
}
EOF
echo "==> Phase: done"
# Mounted-repo Docker runs must not leave /opt/android-sdk paths in app/.cxx on the host.
rm -rf "$ROOT/app/.cxx"

32
ci-build.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# CI entry: native codecs (all ABIs) + Gradle debug APK + unit tests.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
bash "$ROOT/scripts/ensure-gradle-wrapper.sh"
export ANDROIDCAST_ROOT="$ROOT"
export ANDROIDCAST_CCACHE="${ANDROIDCAST_CCACHE:-1}"
export ANDROIDCAST_USE_DISTCC="${ANDROIDCAST_USE_DISTCC:-0}"
# shellcheck source=android-ndk.sh
source "$ROOT/scripts/android-ndk.sh"
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
echo "==> Initializing third-party git submodules"
bash "$ROOT/scripts/init-third-party-submodules.sh"
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
echo "==> Building libvpx for: $ABIS"
rm -rf "$ROOT/build/native"
mkdir -p "$ROOT/build/native"
for abi in $ABIS; do
./scripts/build-native-codecs.sh "$abi"
done
echo "==> Gradle :tunnel + assembleDebug + unit tests"
./gradlew --no-daemon clean :tunnel:assembleDebug assembleDebug testDebugUnitTest
echo "==> Done: $ROOT/app/build/outputs/apk/debug/app-debug.apk"

32
deploy-ota-staging.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Rsync ./out/ota/v0 to a remote web root (staging OTA tree).
#
# Usage:
# OTA_DEPLOY_TARGET=foxx@10.7.16.128:/var/www/localhost/htdocs/ \
# ./scripts/deploy-ota-staging.sh
#
# Dry run:
# OTA_DEPLOY_DRY_RUN=1 ./scripts/deploy-ota-staging.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${ROOT}/out/ota/v0"
TARGET="${OTA_DEPLOY_TARGET:?Set OTA_DEPLOY_TARGET=user@host:/path/to/web/root}"
if [[ ! -d "$SRC" ]]; then
echo "Missing ${SRC} — run ./scripts/docker-build-ota.sh first" >&2
exit 1
fi
RSYNC_OPTS=(-av --delete)
if [[ "${OTA_DEPLOY_DRY_RUN:-}" == "1" ]]; then
RSYNC_OPTS+=(--dry-run -v)
fi
echo "Sync ${SRC}/ -> ${TARGET}/v0/"
rsync "${RSYNC_OPTS[@]}" "${SRC}/" "${TARGET%/}/v0/"
echo "Channel pointer (after deploy):"
if [[ -f "${ROOT}/out/BUILD_INFO.json" ]]; then
grep channelUrl "${ROOT}/out/BUILD_INFO.json" || true
fi

39
docker-build-ota.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Host wrapper: build androidcast-ci image and run build + OTA staging publish.
#
# Usage:
# export OTA_BASE_URL=https://apps.f0xx.org
# ./scripts/docker-build-ota.sh
#
# Artifacts: ./out/android_cast-latest.apk ./out/ota/v0/ota/...
#
# Deploy to BE (example — adjust host/path):
# rsync -av ./out/ota/v0/ user@be:/var/www/localhost/htdocs/v0/
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${ANDROIDCAST_CI_IMAGE:-androidcast-ci:latest}"
docker build -t "$IMAGE" -f "${ROOT}/Dockerfile" "${ROOT}"
mkdir -p "${ROOT}/out"
docker run --rm \
-v "${ROOT}:/workspace" \
-w /workspace \
-e OTA_BASE_URL="${OTA_BASE_URL:-}" \
-e OTA_CHANNEL="${OTA_CHANNEL:-staging}" \
-e GRADLE_TASK="${GRADLE_TASK:-assembleDebug}" \
-e SKIP_NATIVE="${SKIP_NATIVE:-0}" \
-e SKIP_TESTS="${SKIP_TESTS:-0}" \
-e RELEASE_NOTES="${RELEASE_NOTES:-}" \
-e OUT_DIR=/workspace/out \
"$IMAGE" \
./scripts/ci-build-and-publish-ota.sh
echo ""
echo "Done. APK: ${ROOT}/out/android_cast-latest.apk"
if [[ -f "${ROOT}/out/BUILD_INFO.json" ]]; then
echo "OTA channel URL:"
sed -n 's/.*"channelUrl": "\([^"]*\)".*/\1/p' "${ROOT}/out/BUILD_INFO.json" | head -1
fi

245
docker-build-runner.sh Executable file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env bash
# Host wrapper: build CI image + run pipeline with log capture.
#
# Usage:
# ./scripts/docker-build-runner.sh --build-id 42 --workdir /path/to/repo
#
# Env: see ci-build-and-publish-ota.sh
# When invoked from PHP (BUILD_LOG_STDOUT_ONLY=1), log via stdout only — parent redirects to build.log.
#
# Parallel builds: set BUILD_ISOLATE_SOURCE=1 (default from builder) to shallow-clone into
# $OUT_DIR/src instead of mutating the shared deploy checkout.
# Per-build step logs: $OUT_DIR/docker-build.log, $OUT_DIR/docker-run.log (also echoed to build.log).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${ANDROIDCAST_CI_IMAGE:-androidcast-ci:latest}"
CI_VERSION="${ANDROIDCAST_CI_VERSION:-00.01.00.1000}"
BUILD_ID="${BUILD_ID:-local}"
LOG_FILE="${BUILD_LOG_FILE:-${ROOT}/out/builds/${BUILD_ID}/build.log}"
WORK_DIR="${BUILD_WORK_DIR:-${ROOT}}"
OUT_DIR="${BUILD_OUT_DIR:-${WORK_DIR}/out/builds/${BUILD_ID}}"
LOG_STDOUT_ONLY="${BUILD_LOG_STDOUT_ONLY:-0}"
ISOLATE_SOURCE="${BUILD_ISOLATE_SOURCE:-1}"
CLONE_DEPTH="${BUILD_GIT_DEPTH:-1}"
SOURCE_DIR="${OUT_DIR}/src"
DOCKER_WORKSPACE="${WORK_DIR}"
CONTAINER_NAME="androidcast-bld-${BUILD_ID}"
RESUME_FROM="${BUILD_RESUME_FROM:-}"
REUSE_SRC_DIR="${BUILD_REUSE_SRC_DIR:-}"
SSH_DEBUG="${BUILD_SSH_DEBUG:-0}"
SSH_DEBUG_SECONDS="${BUILD_SSH_DEBUG_SECONDS:-7200}"
mkdir -p "$(dirname "$LOG_FILE")" "$OUT_DIR"
log() {
if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then
echo "$@"
else
echo "$@" | tee -a "$LOG_FILE"
fi
}
run_logged() {
if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then
"$@"
else
"$@" 2>&1 | tee -a "$LOG_FILE"
fi
}
# Mirror command output to build.log (stdout) and a per-build step file for parallel debugging.
run_step() {
local step_name="$1"
shift
local step_log="${OUT_DIR}/${step_name}.log"
log "[runner] step ${step_name} -> ${step_log}"
"$@" 2>&1 | tee -a "${step_log}"
}
on_runner_err() {
local ec=$?
log "[runner] ERROR: command failed (exit ${ec}) at ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}"
if command -v docker >/dev/null 2>&1; then
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "${CONTAINER_NAME}"; then
log "[runner] docker logs ${CONTAINER_NAME} (last 100 lines):"
docker logs --tail 100 "${CONTAINER_NAME}" 2>&1 | while IFS= read -r line; do log "$line"; done
fi
fi
echo "${ec}" > "${OUT_DIR}/runner.exit"
exit "${ec}"
}
on_runner_exit() {
local ec=$?
echo "${ec}" > "${OUT_DIR}/runner.exit"
}
trap on_runner_err ERR
trap on_runner_exit EXIT
# Git 2.35+ refuses repos owned by another uid (PHP-FPM nginx vs deploy user).
git_safe() {
git -c safe.directory="${WORK_DIR}" "$@"
}
resolve_git_remote() {
if [[ -n "${GIT_REMOTE:-}" ]]; then
echo "$GIT_REMOTE"
return
fi
if [[ -d "${WORK_DIR}/.git" ]]; then
git_safe -C "${WORK_DIR}" remote get-url origin 2>/dev/null || true
fi
}
prepare_isolated_source() {
local ref="${GIT_REF:-}"
local remote sha
remote="$(resolve_git_remote)"
if [[ -z "$ref" || -z "$remote" ]]; then
log "[runner] isolate: skip (need GIT_REF and origin remote)"
return 0
fi
rm -rf "${SOURCE_DIR}"
mkdir -p "${SOURCE_DIR}"
log "[runner] isolate: shallow clone depth=${CLONE_DEPTH} ref=${ref} remote=${remote} -> ${SOURCE_DIR}"
if git clone --depth "${CLONE_DEPTH}" --recurse-submodules --shallow-submodules \
--single-branch --branch "${ref}" "${remote}" "${SOURCE_DIR}"; then
:
else
log "[runner] isolate: branch clone failed; clone default branch then checkout ${ref}"
run_step git-clone git clone --depth "${CLONE_DEPTH}" --recurse-submodules --shallow-submodules \
"${remote}" "${SOURCE_DIR}"
run_step git-fetch git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" fetch --depth "${CLONE_DEPTH}" origin "${ref}"
run_step git-checkout git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${ref}"
fi
if [[ -n "${GIT_SHA:-}" ]]; then
log "[runner] isolate: checkout pin GIT_SHA=${GIT_SHA}"
run_step git-pin git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${GIT_SHA}"
fi
run_step git-rev-parse git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" rev-parse HEAD
DOCKER_WORKSPACE="${SOURCE_DIR}"
}
sync_shared_checkout() {
if [[ -z "${GIT_REF:-}" ]] || [[ ! -d "${WORK_DIR}/.git" ]]; then
return 0
fi
log "[runner] syncing shared checkout ref=${GIT_REF}"
run_logged git_safe -C "${WORK_DIR}" fetch origin --prune || true
run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_REF}"
run_logged git_safe -C "${WORK_DIR}" pull --rebase origin "${GIT_REF}" || true
if [[ -n "${GIT_SHA:-}" ]]; then
run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_SHA}"
fi
run_logged git_safe -C "${WORK_DIR}" rev-parse HEAD
}
should_skip_git() {
[[ "$RESUME_FROM" == "docker-build" || "$RESUME_FROM" == "docker-run" ]]
}
should_skip_docker_build() {
[[ "$RESUME_FROM" == "docker-run" ]] && docker image inspect "$IMAGE" >/dev/null 2>&1
}
reuse_source_tree() {
if [[ -n "$REUSE_SRC_DIR" && -d "${REUSE_SRC_DIR}/.git" ]]; then
log "[runner] reuse: source tree from ${REUSE_SRC_DIR}"
rm -rf "${SOURCE_DIR}"
mkdir -p "${SOURCE_DIR}"
cp -a "${REUSE_SRC_DIR}/." "${SOURCE_DIR}/"
DOCKER_WORKSPACE="${SOURCE_DIR}"
return 0
fi
return 1
}
run_git_step() {
if [[ "$ISOLATE_SOURCE" == "1" ]]; then
prepare_isolated_source
else
sync_shared_checkout
DOCKER_WORKSPACE="${WORK_DIR}"
fi
}
start_ssh_debug_container() {
log "[runner] SSH debug: starting container ${CONTAINER_NAME} (${SSH_DEBUG_SECONDS}s)"
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
run_step docker-run-ssh docker run -d --name "${CONTAINER_NAME}" \
-v "${DOCKER_WORKSPACE}:/workspace" \
-v "${ROOT}/out:/workspace/out" \
-w /workspace \
-e BUILD_ID="${BUILD_ID}" \
-e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \
"$IMAGE" \
sleep "${SSH_DEBUG_SECONDS}"
printf '%s\n' 'ssh_debug' > "${OUT_DIR}/runner.mode"
local host
host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo builder)"
log "[runner] SSH debug: session open on ${host}"
log "[runner] SSH debug: ssh <you>@${host}"
log "[runner] SSH debug: docker exec -it ${CONTAINER_NAME} bash"
log "[runner] SSH debug: workspace is /workspace inside the container"
log "[runner] finished build_id=${BUILD_ID} (ssh debug — stop build or wait ${SSH_DEBUG_SECONDS}s)"
}
log "[runner] image=${IMAGE} ci_version=${CI_VERSION} build_id=${BUILD_ID} container=${CONTAINER_NAME}"
log "[runner] workdir=${WORK_DIR} out=${OUT_DIR} isolate=${ISOLATE_SOURCE} resume=${RESUME_FROM:-full}"
if should_skip_git; then
if reuse_source_tree; then
log "[runner] resume: skipped git (reused source)"
elif [[ "$ISOLATE_SOURCE" != "1" && -d "${WORK_DIR}/.git" ]]; then
DOCKER_WORKSPACE="${WORK_DIR}"
log "[runner] resume: skipped git (shared workdir ${WORK_DIR})"
else
log "[runner] resume: no reusable source; running git step"
run_git_step
fi
else
run_git_step
fi
log "[runner] docker workspace=${DOCKER_WORKSPACE}"
if should_skip_docker_build; then
log "[runner] resume: skipped docker-build (image ${IMAGE} exists)"
else
run_step docker-build docker build \
--build-arg ANDROIDCAST_CI_VERSION="${CI_VERSION}" \
-t "$IMAGE" \
-f "${ROOT}/Dockerfile" \
"${ROOT}"
fi
if [[ "$SSH_DEBUG" == "1" ]]; then
start_ssh_debug_container
exit 0
fi
run_step docker-run docker run --rm --name "${CONTAINER_NAME}" \
-v "${DOCKER_WORKSPACE}:/workspace" \
-v "${ROOT}/out:/workspace/out" \
-w /workspace \
-e BUILD_ID="${BUILD_ID}" \
-e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \
-e OTA_BASE_URL="${OTA_BASE_URL:-}" \
-e OTA_CHANNEL="${OTA_CHANNEL:-staging}" \
-e GRADLE_TASK="${GRADLE_TASK:-assembleDebug}" \
-e RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}" \
-e RUN_NATIVE="${RUN_NATIVE:-1}" \
-e AUTO_OTA="${AUTO_OTA:-0}" \
-e GIT_REF="${GIT_REF:-}" \
-e GIT_SHA="${GIT_SHA:-}" \
-e RELEASE_NOTES="${RELEASE_NOTES:-}" \
"$IMAGE" \
./scripts/ci-build-and-publish-ota.sh
log "[runner] finished build_id=${BUILD_ID}"

16
ensure-gradle-wrapper.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Ensure gradle/wrapper/* exists (often missing when .gitignore hid gradle/ or after git clean).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
JAR="$ROOT/gradle/wrapper/gradle-wrapper.jar"
PROPS="$ROOT/gradle/wrapper/gradle-wrapper.properties"
if [[ -f "$JAR" && -f "$PROPS" ]]; then
exit 0
fi
echo "ERROR: Gradle wrapper missing at gradle/wrapper/" >&2
echo " expected: gradle-wrapper.jar + gradle-wrapper.properties" >&2
echo " fix: cd \"$ROOT\" && gradle wrapper --gradle-version 8.9" >&2
echo " note: .gitignore must NOT ignore gradle/wrapper/ (see repo .gitignore)" >&2
exit 1

307
generate-docs-toc.py Normal file
View File

@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Insert/update ## Table of contents in all docs/*.md (GitHub-style anchor links)."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
TOC_START = "<!-- toc -->"
TOC_END = "<!-- /toc -->"
TOC_HEADING = "## Table of contents"
INDEX_NAME = "README.md"
# One-line blurbs for docs/README.md
DOC_BLURBS: dict[str, str] = {
"README.md": "Master index of all documentation (this file).",
"INFRA.md": "Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs.",
"GIT_FLOW.md": "Branches (`master` / `next`), release, hotfix, dual remote.",
"ROADMAP.md": "Implementation roadmap, alpha definition, deferred work.",
"ALPHA.md": "Alpha feature freeze, QA sign-off, defaults.",
"ALPHA_SOAK.md": "Short LAN soak checklist for demo validation.",
"BUILD_DEPLOY.md": "Docker CI, staging OTA, builder console deploy.",
"OTA.md": "OTA channel layout v0, manifests, MQTT, publish flow.",
"CRASH_REPORTER.md": "Anonymous crash upload (app + backend).",
"OPEN_API.md": "Crash ingest API design, triage tags, heuristics.",
"TICKETS_ROADMAP.md": "Tickets console, Gitea, attachments, agent queue.",
"COMMERCIAL.md": "Developer-gated ads / IAP toggles.",
"PRO_AAR.md": "cast-core / cast-pro AAR split and Play Billing.",
"USB_HDMI_CAST.md": "Roadmap D/E: HDMI awareness, USB-tether transport.",
"GRAFANA_vs_others_graphvis_pivot.md": "Custom graphs vs Grafana decision memo.",
"20260602_REVERSE_SSH_proposals_summary.md": "On-demand remote access (WG/SSH/file API) proposals.",
"20260607-2FA-email-mobile-auth-flow.md": "Email forwarding + registration/2FA (alpha must-have).",
"20260608_ALPHA_PRIORITIES.md": "**Master priority plan** (0.19.x renumber, owners, alpha path).",
"20260608_BE_SERVICES_and_infra.md": "BE service map, validation status, SFU preview (gray).",
"20260608_STREAM_DUMP_DEBUG.md": "FR 0.1 stream dump spec.",
"20260610_SERVICES.md": "Exposed services catalog — web UI, APIs, curl, validation (2026-06-10).",
"OPEN_TASKS_GRAPH.md": "Open tasks dependency graph (Mermaid).",
"OPUS_SPEEX_VALIDATION.md": "Opus/Speex + UDP stream protection E2E checklist.",
"REMOTE_ACCESS_IMPL.md": "WireGuard v1 impl + deploy.",
"REMOTE_ACCESS_VALIDATION.md": "Linux CLI suite + mobile parity.",
"AV_QUALITY_QA_SESSION.md": "AV enhancement Q&A: sender vs receiver pipelines.",
"specs/20100611_3_url_shortener.md": "URL shortener service — SPEC (`s.f0xx.org`, post-alpha).",
"DRs/20100611_3_url_shortener.md": "URL shortener — design review (auth, slug algo, task graph).",
}
SPEC_DIR = DOCS / "specs"
DR_DIR = DOCS / "DRs"
def github_slug(text: str) -> str:
"""Approximate GitHub/Cursor heading anchor slugs."""
text = text.strip().lower()
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"[*_]+", "", text)
text = re.sub(r"[^\w\s-]", "", text)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def collect_headings(lines: list[str]) -> list[tuple[int, str, str]]:
"""Return (level, title, slug) for ## and ### only."""
out: list[tuple[int, str, str]] = []
used: dict[str, int] = {}
for line in lines:
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if not m:
continue
level = len(m.group(1))
title = m.group(2).strip()
if title == "Table of contents":
continue
base = github_slug(title)
if not base:
continue
n = used.get(base, 0)
slug = base if n == 0 else f"{base}-{n}"
used[base] = n + 1
out.append((level, title, slug))
return out
def build_toc_block(headings: list[tuple[int, str, str]], *, doc_name: str) -> list[str]:
lines = [TOC_HEADING, "", TOC_START]
for level, title, slug in headings:
indent = " " if level == 3 else ""
# Strip markdown emphasis for link text
link_text = re.sub(r"\*\*([^*]+)\*\*", r"\1", title)
link_text = re.sub(r"`([^`]+)`", r"\1", link_text)
lines.append(f"{indent}- [{link_text}](#{slug})")
lines.append(TOC_END)
if doc_name != INDEX_NAME:
lines.append("")
lines.append(f"**Documentation index:** [{INDEX_NAME}]({INDEX_NAME})")
lines.append("")
return lines
def strip_existing_toc(lines: list[str]) -> list[str]:
out: list[str] = []
i = 0
while i < len(lines):
if lines[i].strip() == TOC_HEADING:
i += 1
while i < len(lines) and TOC_END not in lines[i]:
i += 1
if i < len(lines):
i += 1 # skip <!-- /toc -->
while i < len(lines) and lines[i].strip() == "":
i += 1
if i < len(lines) and lines[i].strip().startswith("**Documentation index:**"):
i += 1
while i < len(lines) and lines[i].strip() == "":
i += 1
continue
out.append(lines[i])
i += 1
return out
def find_insert_index(lines: list[str]) -> int:
"""Index to insert TOC block (before first ## section)."""
for i, line in enumerate(lines):
if re.match(r"^## (?!Table of contents)", line):
return i
return len(lines)
def process_file(path: Path) -> bool:
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
raw = [ln.rstrip("\n") for ln in lines]
raw = strip_existing_toc(raw)
headings = collect_headings(raw)
if not headings and path.name != INDEX_NAME:
return False
toc = build_toc_block(headings, doc_name=path.name)
idx = find_insert_index(raw)
# Avoid duplicate --- before TOC
if idx > 0 and raw[idx - 1].strip() == "---":
new_lines = raw[: idx - 1] + toc + ["---", ""] + raw[idx:]
else:
sep = ["---", ""] if idx < len(raw) and raw[idx].startswith("##") else []
new_lines = raw[:idx] + toc + sep + raw[idx:]
new_text = "\n".join(new_lines) + "\n"
if new_text != text.replace("\r\n", "\n"):
path.write_text(new_text, encoding="utf-8")
return True
return False
def build_index_md() -> str:
md_files = sorted(p for p in DOCS.glob("*.md") if p.name != INDEX_NAME)
lines = [
"# Android Cast — documentation index",
"",
"Click a link to open a guide. Section links jump within the document (GitHub, Cursor, and most Markdown previews).",
"",
TOC_HEADING,
"",
TOC_START,
"- [All documents](#all-documents)",
"- [Specifications](#specifications)",
"- [Design reviews](#design-reviews)",
"- [By topic](#by-topic)",
"- [Agent bootstrap](#agent-bootstrap)",
TOC_END,
"",
"---",
"",
"## All documents",
"",
"| Document | Summary |",
"|----------|---------|",
]
for p in md_files:
name = p.name
blurb = DOC_BLURBS.get(name, "")
lines.append(f"| [{name}]({name}) | {blurb} |")
spec_files = sorted(SPEC_DIR.glob("*.md")) if SPEC_DIR.is_dir() else []
dr_files = sorted(DR_DIR.glob("*.md")) if DR_DIR.is_dir() else []
lines += [
"",
"---",
"",
"## Specifications",
"",
"| Document | Summary |",
"|----------|---------|",
]
if spec_files:
for p in spec_files:
rel = f"specs/{p.name}"
blurb = DOC_BLURBS.get(rel, "")
lines.append(f"| [{p.name}]({rel}) | {blurb} |")
else:
lines.append("| _(none yet)_ | |")
lines += [
"",
"---",
"",
"## Design reviews",
"",
"| Document | Summary |",
"|----------|---------|",
]
if dr_files:
for p in dr_files:
rel = f"DRs/{p.name}"
blurb = DOC_BLURBS.get(rel, "")
lines.append(f"| [{p.name}]({rel}) | {blurb} |")
else:
lines.append("| _(none yet)_ | |")
lines += [
"",
"---",
"",
"## By topic",
"",
"### Infrastructure and deploy",
"",
f"- [INFRA.md](INFRA.md) — FE/BE topology, nginx, ports ([§1 Topology](INFRA.md#1-topology))",
f"- [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker CI, builder ([§Docker CI](BUILD_DEPLOY.md#docker-ci))",
f"- [OTA.md](OTA.md) — OTA v0 layout ([§URL layout](OTA.md#url-layout))",
"",
"### Development process",
"",
f"- [GIT_FLOW.md](GIT_FLOW.md) — `master` / `next` ([§2 Branches](GIT_FLOW.md#2-branches))",
f"- [ROADMAP.md](ROADMAP.md) — milestones ([§Alpha definition](ROADMAP.md#alpha-definition))",
"",
"### Alpha and QA",
"",
f"- [ALPHA.md](ALPHA.md) — freeze and sign-off ([§Alpha scope](ALPHA.md#alpha-scope-what-we-ship))",
f"- [ALPHA_SOAK.md](ALPHA_SOAK.md) — LAN soak ([§Happy path](ALPHA_SOAK.md#happy-path-15-min))",
"",
"### Backend apps",
"",
f"- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload",
f"- [OPEN_API.md](OPEN_API.md) — ingest API ([§Upload API](OPEN_API.md#upload-api-post-apiv1uploadphp))",
f"- [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) — tickets + Gitea",
f"- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs pivot",
f"- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — remote access ([§Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive))",
f"- [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md) — URL shortener SPEC ([§REST API](specs/20100611_3_url_shortener.md#6-rest-api))",
f"- [DRs/20100611_3_url_shortener.md](DRs/20100611_3_url_shortener.md) — URL shortener DR ([§Resolved open questions](DRs/20100611_3_url_shortener.md#2-resolved-open-questions))",
"",
"### Product and codecs",
"",
f"- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP",
f"- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split",
f"- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether",
f"- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A",
"",
"---",
"",
"## Agent bootstrap",
"",
"After a context reset, read in order:",
"",
"1. [INFRA.md](INFRA.md) — topology (no `:8089` for androidcast)",
"2. [GIT_FLOW.md](GIT_FLOW.md) — branch rules",
"3. Task-specific: [BUILD_DEPLOY.md](BUILD_DEPLOY.md), [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md), [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md), etc.",
"",
"Repo root [AGENTS.md](../AGENTS.md) points here.",
"",
"---",
"",
"## Maintaining TOCs",
"",
"Section links use GitHub/Cursor-style heading anchors. After adding or renaming `##` / `###` headings, run:",
"",
"```bash",
"python3 scripts/generate-docs-toc.py",
"```",
"",
]
return "\n".join(lines) + "\n"
def main() -> None:
index_path = DOCS / INDEX_NAME
index_path.write_text(build_index_md(), encoding="utf-8")
print(f"Wrote {index_path}")
changed = []
toc_paths: list[Path] = sorted(DOCS.glob("*.md"))
if SPEC_DIR.is_dir():
toc_paths.extend(sorted(SPEC_DIR.glob("*.md")))
if DR_DIR.is_dir():
toc_paths.extend(sorted(DR_DIR.glob("*.md")))
for path in toc_paths:
if process_file(path):
changed.append(str(path.relative_to(DOCS)))
print(f"Updated TOC: {path.relative_to(DOCS)}")
print(f"Done. {len(changed)} file(s) updated.")
if __name__ == "__main__":
main()

147
generate-ota-v0.sh Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Generate v0 OTA artifacts for one APK (stdout = channel stable.json).
#
# Usage:
# ./scripts/generate-ota-v0.sh path/to/app-release.apk https://host[:port] [out-dir] [channel]
#
# channel — JSON file under v0/ota/channel/ (default: stable). Use staging for QA.
# RELEASE_NOTES — optional env, embedded in manifest.
#
# channel — JSON under v0/ota/channel/ (stable, staging, dev, nightly, or custom)
# Writes under out-dir (default: ./ota-publish):
# v0/ota/channel/<channel>.json
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_manifest.json
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_sign.json
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otapkg
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otabundle.zip (STORE zip)
set -euo pipefail
apk="${1:?APK path required}"
host_base="${2:?Base URL required, e.g. https://192.168.1.1:8080}"
out_dir="${3:-ota-publish}"
channel="${4:-stable}"
release_notes="${RELEASE_NOTES:-}"
json_notes() {
if command -v jq >/dev/null 2>&1; then
jq -n --arg n "$release_notes" '$n'
else
local esc="${release_notes//\\/\\\\}"
esc="${esc//\"/\\\"}"
esc="${esc//$'\n'/\\n}"
esc="${esc//$'\r'/}"
printf '"%s"' "$esc"
fi
}
NOTES_JSON="$(json_notes)"
if [[ ! -f "$apk" ]]; then
echo "APK not found: $apk" >&2
exit 1
fi
pad2() { printf '%02d' "$1"; }
major="" minor="" build=""
if command -v aapt >/dev/null 2>&1; then
badging="$(aapt dump badging "$apk" 2>/dev/null || true)"
version_code="$(echo "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p" | head -1)"
version_name="$(echo "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
if [[ -n "${version_code:-}" ]]; then
major=$((version_code / 10000))
minor=$(((version_code / 100) % 100))
build=$((version_code % 100))
fi
fi
if [[ -z "${major:-}" ]]; then
echo "Could not read versionCode from APK (install build-tools / aapt)." >&2
exit 1
fi
maj_p="$(pad2 "$major")"
min_p="$(pad2 "$minor")"
bld_p="$(pad2 "$build")"
ver_p="${maj_p}.${min_p}.${bld_p}"
base="${host_base%/}/v0/ota/00/${maj_p}/${ver_p}"
artifact_base="${base}/android_cast_${ver_p}.${bld_p}"
apk_url="${artifact_base}.otapkg"
sign_url="${artifact_base}_sign.json"
manifest_url="${artifact_base}_manifest.json"
bundle_url="${artifact_base}.otabundle.zip"
sha256="$(sha256sum "$apk" | awk '{print $1}')"
apk_size="$(stat -c%s "$apk" 2>/dev/null || wc -c <"$apk" | tr -d ' ')"
rel_root="${out_dir}/v0/ota"
rel_dir="${rel_root}/00/${maj_p}/${ver_p}"
mkdir -p "${rel_dir}" "${rel_root}/channel"
pkg_name="android_cast_${ver_p}.${bld_p}.otapkg"
cp -f "$apk" "${rel_dir}/${pkg_name}"
sign_name="android_cast_${ver_p}.${bld_p}_sign.json"
manifest_name="android_cast_${ver_p}.${bld_p}_manifest.json"
bundle_name="android_cast_${ver_p}.${bld_p}.otabundle.zip"
cat >"${rel_dir}/${sign_name}" <<EOF
{
"schema": "v0",
"sha256": "${sha256}"
}
EOF
cat >"${rel_dir}/${manifest_name}" <<EOF
{
"schema": "v0",
"major": ${major},
"minor": ${minor},
"build": ${build},
"versionName": "${version_name:-${major}.${minor}.${build}}",
"apkUrl": "${apk_url}",
"signUrl": "${sign_url}",
"sizeBytes": ${apk_size},
"mandatory": false,
"releaseNotes": ${NOTES_JSON}
}
EOF
bundle_stage="$(mktemp -d)"
trap 'rm -rf "${bundle_stage}"' EXIT
cp -f "${rel_dir}/${manifest_name}" "${bundle_stage}/manifest.json"
cp -f "${rel_dir}/${sign_name}" "${bundle_stage}/sign.json"
cp -f "$apk" "${bundle_stage}/package.apk"
# -0 = STORED (no re-compression of the APK)
( cd "${bundle_stage}" && zip -0 -q -X "${rel_dir}/${bundle_name}" manifest.json sign.json package.apk )
bundle_sha256="$(sha256sum "${rel_dir}/${bundle_name}" | awk '{print $1}')"
bundle_size="$(stat -c%s "${rel_dir}/${bundle_name}" 2>/dev/null || wc -c <"${rel_dir}/${bundle_name}" | tr -d ' ')"
# Patch manifest with bundle fields (jq optional; use temp file + heredoc rewrite)
cat >"${rel_dir}/${manifest_name}" <<EOF
{
"schema": "v0",
"major": ${major},
"minor": ${minor},
"build": ${build},
"versionName": "${version_name:-${major}.${minor}.${build}}",
"apkUrl": "${apk_url}",
"signUrl": "${sign_url}",
"sizeBytes": ${apk_size},
"bundleUrl": "${bundle_url}",
"bundleSha256": "${bundle_sha256}",
"bundleSizeBytes": ${bundle_size},
"mandatory": false,
"releaseNotes": ${NOTES_JSON}
}
EOF
channel_file="${rel_root}/channel/${channel}.json"
cat >"${channel_file}" <<EOF
{
"schema": "v0",
"manifestUrl": "${manifest_url}"
}
EOF
echo "Published v0 OTA under ${out_dir}/v0/ota" >&2
echo "Channel: ${host_base%/}/v0/ota/channel/${channel}.json" >&2
echo "Bundle: ${bundle_url} (${bundle_size} bytes)" >&2
cat "${channel_file}"

18
grant-dev-adb-perms.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# One-time grants while device is connected (usually USB). Debug package only.
set -euo pipefail
PACKAGE="${1:-com.foxx.androidcast}"
if ! adb get-state >/dev/null 2>&1; then
echo "ERROR: no adb device in 'device' state" >&2
exit 1
fi
echo "==> Granting dev adb permissions to ${PACKAGE}"
adb shell pm grant "${PACKAGE}" android.permission.WRITE_SECURE_SETTINGS
adb shell pm grant "${PACKAGE}" android.permission.DUMP
adb shell am broadcast \
-a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
-n "${PACKAGE}/.dev.DevAdbWifiReceiver"
echo "OK. Re-check: curl -s http://DEVICE:5039/adb.json | grep secure_settings_granted"

52
header-post-commit.py Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Post-commit: set Commit: SHA on sources touched in HEAD (amends commit once if needed)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from header_lib import (
REPO_ROOT,
files_in_git_commit,
git_head_sha,
hook_skip_requested,
update_header_commit,
)
def main() -> int:
if hook_skip_requested():
return 0
sha = git_head_sha()
if not sha:
return 0
touched: list[Path] = []
for path in files_in_git_commit("HEAD"):
text = path.read_text(encoding="utf-8", errors="replace")
new_text = update_header_commit(text, path, sha)
if new_text != text:
path.write_text(new_text, encoding="utf-8", newline="\n")
touched.append(path)
if not touched:
return 0
env = os.environ.copy()
env["HEADER_HOOK_SKIP"] = "1"
for path in touched:
rel = path.relative_to(REPO_ROOT).as_posix()
subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT, env=env)
subprocess.check_call(
["git", "commit", "--amend", "--no-edit", "--no-verify"],
cwd=REPO_ROOT,
env=env,
)
return 0
if __name__ == "__main__":
sys.exit(main())

90
header-pre-commit.py Executable file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Git pre-commit: validate headers on new files; refresh Updated/Digest on staged edits."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from header_lib import (
REPO_ROOT,
SOURCE_SUFFIXES,
apply_header_to_content,
has_valid_header,
header_needs_identity_refresh,
hook_skip_requested,
is_excluded_path,
resolve_header_contributor,
update_header_timestamps,
)
def staged_files() -> list[Path]:
out = subprocess.check_output(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
text=True,
)
files = []
for line in out.splitlines():
p = REPO_ROOT / line.strip()
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
continue
if is_excluded_path(p):
continue
files.append(p)
return files
def is_new_file(path: Path) -> bool:
rel = path.relative_to(REPO_ROOT).as_posix()
try:
subprocess.check_call(
["git", "diff", "--cached", "--diff-filter=A", "--quiet", "--", rel],
cwd=REPO_ROOT,
)
return False
except subprocess.CalledProcessError:
return True
def main() -> int:
if hook_skip_requested():
return 0
errors = []
contributor = resolve_header_contributor()
for path in staged_files():
text = path.read_text(encoding="utf-8", errors="replace")
if is_new_file(path):
if not has_valid_header(text):
errors.append(
f"{path.relative_to(REPO_ROOT)}: new file must include HEADER.txt block"
)
text = apply_header_to_content(path, text)
text = update_header_timestamps(text, path, contributor)
else:
if has_valid_header(text):
if header_needs_identity_refresh(text, path):
text = apply_header_to_content(path, text)
text = update_header_timestamps(text, path, contributor)
else:
text = apply_header_to_content(path, text)
text = update_header_timestamps(text, path, contributor)
path.write_text(text, encoding="utf-8", newline="\n")
rel = path.relative_to(REPO_ROOT).as_posix()
subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT)
if errors:
for e in errors:
print(e, file=sys.stderr)
print(
"Run: python3 scripts/apply-project-headers.py",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

638
header_lib.py Executable file
View File

@@ -0,0 +1,638 @@
#!/usr/bin/env python3
"""Shared logic for project source headers (see HEADER.txt)."""
from __future__ import annotations
import hashlib
import re
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parents[1]
HEADER_TEMPLATE = REPO_ROOT / "HEADER.txt"
BANNER_START = "/*********************************************************************"
BANNER_END = "**********************************************************************/"
MARKER_CREATED = "Created at:"
MARKER_UPDATED = "Updated at:"
MARKER_COMMIT = "Commit:"
MARKER_DIGEST = "Digest:"
MARKER_CONTRIBUTORS = "Contributors:"
MARKER_CLASS = re.compile(r"^\s*\*?\s*(class|interface|enum)\s+(\w+)", re.I)
ASSISTANT_NAME = "Cursor Agent"
ASSISTANT_CONTRIBUTOR = ASSISTANT_NAME
IDENTITY_IN_ANGLE = re.compile(r"^(.+?)\s*<([^>]+)>\s*$")
IGNORED_GIT_AUTHORS = frozenset({"Not Committed Yet", "Unknown"})
SOURCE_SUFFIXES = {".java", ".aidl", ".c", ".cpp", ".h", ".php"}
SKIP_DIR_NAMES = {
".git",
".gradle",
"build",
".cxx",
"third-party",
"node_modules",
".idea",
}
EXCLUDED_PATH_MARKERS = frozenset({"third-party"})
def is_excluded_path(path: Path) -> bool:
"""Never apply headers under third-party/ or other vendor trees."""
resolved = path.resolve()
root = REPO_ROOT.resolve()
try:
rel_parts = resolved.relative_to(root).parts
except ValueError:
return True
if any(part in EXCLUDED_PATH_MARKERS for part in rel_parts):
return True
return any(part in SKIP_DIR_NAMES for part in rel_parts)
@dataclass
class FileMeta:
rel_path: str
filename: str
package_line: str
class_line: str
created_at: str
updated_at: str
last_contributor: str
contributors_block: str
language: str
commit_sha: str = ""
def iter_source_files(root: Path) -> Iterable[Path]:
for path in root.rglob("*"):
if path.suffix.lower() not in SOURCE_SUFFIXES:
continue
if is_excluded_path(path):
continue
if path.is_file():
yield path
_GLOBAL_IDENTITY_MAP: dict[str, str] | None = None
def global_git_identity_map() -> dict[str, str]:
"""Repo-wide author -> email from git shortlog."""
global _GLOBAL_IDENTITY_MAP
if _GLOBAL_IDENTITY_MAP is not None:
return _GLOBAL_IDENTITY_MAP
emails: dict[str, str] = {}
out = run_git(["shortlog", "-sne", "--all"], REPO_ROOT)
for line in out.splitlines():
line = line.strip()
if not line:
continue
# "29\tAnton Afanasyeu <a@b.c>" or "29 Anton ..."
if "\t" in line:
_, rest = line.split("\t", 1)
else:
parts = line.split(None, 1)
rest = parts[1] if len(parts) > 1 else line
name, mail = parse_identity(rest.strip())
if is_tracked_author(name) and mail:
emails.setdefault(name, mail)
_GLOBAL_IDENTITY_MAP = emails
return emails
def run_git(args: list[str], cwd: Path) -> str:
try:
out = subprocess.check_output(
["git", *args],
cwd=cwd,
stderr=subprocess.DEVNULL,
text=True,
)
return out.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
def normalize_email(raw: str) -> str:
e = raw.strip()
if e.startswith("<") and e.endswith(">"):
e = e[1:-1].strip()
if not e or e.lower() in {"unknown", "none", "n/a"}:
return ""
if "@" not in e:
return ""
return e
def parse_identity(text: str) -> tuple[str, str]:
"""Split 'Name <email>' or plain name."""
t = text.strip()
if not t:
return "", ""
m = IDENTITY_IN_ANGLE.match(t)
if m:
return m.group(1).strip(), normalize_email(m.group(2))
return t, ""
def is_tracked_author(name: str) -> bool:
n = name.strip()
return bool(n) and n not in IGNORED_GIT_AUTHORS
def format_identity(name: str, email: str = "") -> str:
name = name.strip()
email = normalize_email(email)
if not name:
return ASSISTANT_NAME
if email:
return f"{name} <{email}>"
return name
def hook_skip_requested() -> bool:
import os
return os.environ.get("HEADER_HOOK_SKIP", "").strip() in {"1", "true", "yes"}
def git_head_sha() -> str:
return run_git(["rev-parse", "HEAD"], REPO_ROOT)
def git_last_commit_sha_for_file(path: Path) -> str:
rel = repo_relative(path)
return run_git(["log", "-1", "--format=%H", "--", rel], REPO_ROOT)
def parse_commit_from_header(text: str) -> str:
m = re.search(r"\* Commit:\s*(\S+)", text)
return m.group(1).strip() if m else ""
def files_in_git_commit(commit: str = "HEAD") -> list[Path]:
out = run_git(
["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
REPO_ROOT,
)
paths: list[Path] = []
for line in out.splitlines():
rel = line.strip()
if not rel:
continue
p = REPO_ROOT / rel
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
continue
if is_excluded_path(p):
continue
paths.append(p)
return paths
def format_git_date(raw: str) -> str:
if not raw:
return datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
try:
# git %ai: 2026-05-20 15:04:12 +0300
dt = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S %z")
return dt.strftime("%a %d %b %Y %H:%M:%S %z")
except ValueError:
return raw
def git_identity_map(path: Path, body: str = "") -> dict[str, str]:
"""Map git author name -> email (best effort per file)."""
rel = repo_relative(path)
emails: dict[str, str] = {}
log = run_git(["log", "--follow", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
for line in log.splitlines():
if "\t" not in line:
continue
an, ae = line.split("\t", 1)
an, ae = an.strip(), normalize_email(ae)
if is_tracked_author(an) and ae:
emails.setdefault(an, ae)
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
author_name: str | None = None
for line in blame.splitlines():
if line.startswith("author "):
author_name = line[7:].strip()
elif line.startswith("author-mail "):
mail = normalize_email(line[12:])
if is_tracked_author(author_name or "") and mail:
emails.setdefault(author_name, mail)
author_name = None
elif line.startswith("\t"):
author_name = None
for tag in re.finditer(
r"@author\s+(.+)$|/\*\s*@author\s+(.+?)\s*\*/",
body,
re.MULTILINE | re.IGNORECASE,
):
fragment = (tag.group(1) or tag.group(2) or "").strip()
name, mail = parse_identity(fragment)
if name and mail:
emails.setdefault(name, mail)
for name, mail in global_git_identity_map().items():
emails.setdefault(name, mail)
return emails
def git_created_updated(path: Path, body: str = "") -> tuple[str, str, str]:
rel = repo_relative(path)
created_raw = run_git(
["log", "--follow", "--diff-filter=A", "--format=%ai", "-1", "--", rel],
REPO_ROOT,
)
updated_raw = run_git(["log", "-1", "--format=%ai", "--", rel], REPO_ROOT)
last = run_git(["log", "-1", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
if "\t" in last:
an, ae = last.split("\t", 1)
author = format_identity(an, ae)
elif last:
id_map = git_identity_map(path, body)
author = format_identity(last, id_map.get(last, ""))
else:
author = ASSISTANT_NAME
return (
format_git_date(created_raw),
format_git_date(updated_raw or created_raw),
author,
)
def git_config_identity() -> tuple[str, str]:
name = run_git(["config", "user.name"], REPO_ROOT)
email = run_git(["config", "user.email"], REPO_ROOT)
return name, email
def git_config_identity_formatted() -> str:
name, email = git_config_identity()
if name:
return format_identity(name, email)
return ASSISTANT_NAME
def resolve_header_contributor() -> str:
"""
Pre-commit / env contributor: HEADER_CONTRIBUTOR or HEADER_CONTRIBUTOR_EMAIL
enriched with git user.email when the name has no address.
"""
import os
raw = os.environ.get("HEADER_CONTRIBUTOR", "").strip()
email_extra = normalize_email(os.environ.get("HEADER_CONTRIBUTOR_EMAIL", ""))
cfg_name, cfg_email = git_config_identity()
if raw:
name, email = parse_identity(raw)
if not name:
name = raw
if not email:
email = email_extra or cfg_email or global_git_identity_map().get(name, "")
return format_identity(name, email)
if email_extra and cfg_name:
return format_identity(cfg_name, email_extra)
return git_config_identity_formatted()
def git_contributors(path: Path, body_line_count: int, body: str = "") -> list[tuple[str, int, int]]:
rel = repo_relative(path)
id_map = git_identity_map(path, body)
commit_counts: dict[str, int] = {}
log = run_git(["log", "--follow", "--format=%an", "--", rel], REPO_ROOT)
for line in log.splitlines():
name = line.strip()
if is_tracked_author(name):
commit_counts[name] = commit_counts.get(name, 0) + 1
line_counts: dict[str, int] = {}
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
author: str | None = None
for line in blame.splitlines():
if line.startswith("author "):
author = line[7:].strip()
elif line.startswith("\t") and author and is_tracked_author(author):
line_counts[author] = line_counts.get(author, 0) + 1
names = {n for n in (set(commit_counts) | set(line_counts)) if is_tracked_author(n)}
rows: list[tuple[str, int, int]] = []
for name in names:
label = format_identity(name, id_map.get(name, ""))
rows.append((label, commit_counts.get(name, 0), line_counts.get(name, 0)))
rows.sort(key=lambda r: (-r[1], -r[2], r[0].lower()))
if not rows:
rows = [(ASSISTANT_NAME, 0, body_line_count)]
return rows
def contributors_block(rows: list[tuple[str, int, int]], include_assistant: bool = True) -> str:
lines = []
for label, commits, nlines in rows:
name, _ = parse_identity(label)
if name == ASSISTANT_NAME:
continue
lines.append(f" * - {label} ({commits} commits, {nlines} lines)")
if include_assistant:
lines.append(f" * - {ASSISTANT_NAME} (project assistant)")
return "\n".join(lines)
def detect_class_line(body: str, suffix: str) -> str:
if suffix not in {".java", ".aidl", ".cpp", ".php"}:
return ""
patterns = [
("class", r"^\s*public\s+(?:final\s+)?class\s+(\w+)"),
("class", r"^\s*class\s+(\w+)"),
("interface", r"^\s*public\s+interface\s+(\w+)"),
("interface", r"^\s*interface\s+(\w+)"),
("enum", r"^\s*public\s+enum\s+(\w+)"),
("enum", r"^\s*enum\s+(\w+)"),
]
for line in body.splitlines():
for kind, pat in patterns:
m = re.match(pat, line)
if m:
return f" * {kind} {m.group(1)}"
return ""
def package_line_for_file(source_text: str, rel_path: str, suffix: str) -> str:
for line in source_text.splitlines():
if suffix in {".java", ".aidl"}:
m = re.match(r"^\s*package\s+([\w.]+)\s*;", line)
if m:
return f"package {m.group(1)};"
return f"/* package {rel_path} */"
def strip_existing_header_php(text: str) -> str:
"""Remove <?php, block-comment header(s), and closing ?> before real body."""
work = text.lstrip("\ufeff")
while True:
changed = False
w = work.lstrip()
if w.startswith("<?php"):
work = w[5:].lstrip("\n")
changed = True
w = work.lstrip()
if w.startswith("?>"):
work = w[2:].lstrip("\n")
changed = True
w = work.lstrip()
if w.startswith("/*") or w.startswith("/*****"):
close = w.find("*/")
if close >= 0:
work = w[close + 2 :].lstrip("\n")
changed = True
else:
break
if not changed:
break
return work.lstrip("\n")
def php_body_is_markup(body: str) -> bool:
"""True when body is HTML/templates after the commented header (needs ?>)."""
s = body.lstrip()
return bool(s) and s[0] == "<"
def strip_existing_header(text: str, suffix: str) -> tuple[str, str]:
"""Returns (body without header/package, preserved leading e.g. <?php)."""
if suffix == ".php":
return strip_existing_header_php(text), ""
leading = ""
work = text
lines = work.splitlines(keepends=True)
i = 0
if i < len(lines) and (
lines[i].startswith("package ")
or lines[i].startswith("/* package ")
):
i += 1
if i < len(lines) and lines[i].strip() == "":
i += 1
if i < len(lines) and BANNER_START in lines[i]:
while i < len(lines) and BANNER_END not in lines[i]:
i += 1
if i < len(lines):
i += 1
if i < len(lines) and lines[i].strip() == "":
i += 1
body = "".join(lines[i:]).lstrip("\n")
if body.startswith("package "):
first_nl = body.find("\n")
if first_nl >= 0:
body = body[first_nl + 1 :].lstrip("\n")
return body, leading
def repo_relative(path: Path) -> str:
root = REPO_ROOT.resolve()
try:
return path.resolve().relative_to(root).as_posix()
except ValueError:
return path.name
def build_meta(path: Path, body: str, source_text: str = "") -> FileMeta:
rel = repo_relative(path)
suffix = path.suffix.lower()
full_source = source_text if source_text else body
created, updated, author = git_created_updated(path, body)
rows = git_contributors(path, max(1, len(body.splitlines())), body)
if not any(parse_identity(r[0])[0] == ASSISTANT_NAME for r in rows):
rows.append((ASSISTANT_NAME, 0, 0))
pkg = package_line_for_file(full_source, rel, suffix)
lang = "java" if suffix in {".java", ".aidl"} else ("php" if suffix == ".php" else "c")
class_line = detect_class_line(body, suffix)
commit_sha = parse_commit_from_header(source_text) or git_last_commit_sha_for_file(path)
return FileMeta(
rel_path=rel,
filename=path.name,
package_line=pkg,
class_line=class_line,
created_at=created,
updated_at=updated,
last_contributor=author,
contributors_block=contributors_block(rows),
language=lang,
commit_sha=commit_sha,
)
def _inner_header_lines(meta: FileMeta, *, for_digest: bool) -> list[str]:
"""Banner lines shared by PHP and C-style headers."""
lines = [
f" * {meta.filename}",
f" * {MARKER_CREATED} {meta.created_at}",
f" * {MARKER_UPDATED} {meta.updated_at} by {meta.last_contributor}",
]
if not for_digest and meta.commit_sha:
lines.append(f" * {MARKER_COMMIT} {meta.commit_sha}")
if meta.class_line:
lines.append(meta.class_line)
lines.append(f" * {MARKER_CONTRIBUTORS}")
lines.extend(meta.contributors_block.splitlines())
return lines
def render_php_header(meta: FileMeta, digest_hex: str | None, *, for_digest: bool = False) -> str:
"""
PHP: header is one block comment inside <?php ... ?> so the engine ignores it.
No nested '/***** ... */' banners (inner */ would break the comment).
"""
inner = [f" * package {meta.rel_path}"]
inner.extend(_inner_header_lines(meta, for_digest=for_digest))
if digest_hex and not for_digest:
inner.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
return "<?php\n/*\n" + "\n".join(inner) + "\n */\n"
def render_header(
meta: FileMeta, digest_hex: str | None, suffix: str = "", *, for_digest: bool = False) -> str:
if suffix == ".php" or meta.language == "php":
return render_php_header(meta, digest_hex, for_digest=for_digest)
lines = [
meta.package_line,
"",
BANNER_START,
*_inner_header_lines(meta, for_digest=for_digest),
]
if digest_hex and not for_digest:
lines.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
lines.append(BANNER_END)
lines.append("")
return "\n".join(lines)
def wrap_php_header_and_body(header: str, body: str) -> str:
"""
Pure PHP: <?php /* header */ code... (declare stays first statement after <?php).
Templates: <?php /* header */ ?> then HTML.
"""
body = body.lstrip("\n")
if body.startswith("<?php"):
body = body[5:].lstrip("\n")
if php_body_is_markup(body):
return header + "?>\n" + body
return header + body
def compute_digest(header_prefix: str, body: str) -> str:
"""Hash file body + header without Digest/Commit lines."""
payload = header_prefix + body
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def finalize_header(meta: FileMeta, body: str, suffix: str, leading: str = "") -> str:
prefix_for_digest = render_header(meta, None, suffix, for_digest=True)
digest = compute_digest(prefix_for_digest, body)
prefix = render_header(meta, digest, suffix, for_digest=False)
if suffix == ".php":
return wrap_php_header_and_body(prefix, body)
return leading + prefix + body
def apply_header_to_content(path: Path, text: str, update_only: bool = False) -> str:
suffix = path.suffix.lower()
body, leading = strip_existing_header(text, suffix)
meta = build_meta(path, body, text)
if update_only and MARKER_CREATED in text:
m = re.search(r"\* Created at: (.+)", text)
if m:
meta.created_at = m.group(1).strip()
return finalize_header(meta, body, suffix, leading)
def has_valid_header(text: str) -> bool:
if MARKER_CREATED not in text:
return False
if text.lstrip().startswith("<?php") and "/*" in text[:800]:
return "*/" in text[:1200]
return BANNER_START in text and BANNER_END in text
def header_needs_identity_refresh(text: str, path: Path) -> bool:
"""True when a known author appears without email but git has one."""
if not has_valid_header(text):
return False
suffix = path.suffix.lower()
body, _ = strip_existing_header(text, suffix)
id_map = git_identity_map(path, body)
updated = re.search(r"\* Updated at: .+ by (.+)$", text, re.MULTILINE)
if updated:
name, email = parse_identity(updated.group(1).strip())
if name != ASSISTANT_NAME and not email and id_map.get(name):
return True
for line in text.splitlines():
stripped = line.strip()
if not stripped.startswith("* - "):
continue
label = stripped[7:].split(" (", 1)[0].strip()
name, email = parse_identity(label)
if name != ASSISTANT_NAME and not email and id_map.get(name):
return True
return False
def update_header_timestamps(
text: str, path: Path, contributor: str | None = None, *, touch_commit: bool = False) -> str:
suffix = path.suffix.lower()
body, leading = strip_existing_header(text, suffix)
if contributor:
who = contributor
else:
who = resolve_header_contributor()
if who == ASSISTANT_NAME:
who = git_created_updated(path, body)[2]
updated_line = datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
m_created = re.search(r"\* Created at: (.+)", text)
created = m_created.group(1).strip() if m_created else git_created_updated(path)[0]
meta = build_meta(path, body, text)
meta.created_at = created
meta.updated_at = updated_line
meta.last_contributor = who
if touch_commit:
meta.commit_sha = git_head_sha()
else:
meta.commit_sha = parse_commit_from_header(text)
return finalize_header(meta, body, suffix, leading)
def update_header_commit(text: str, path: Path, commit_sha: str) -> str:
"""Set Commit: line after post-commit; recomputes Digest (Commit excluded from hash)."""
if parse_commit_from_header(text) == commit_sha:
return text
suffix = path.suffix.lower()
body, leading = strip_existing_header(text, suffix)
meta = build_meta(path, body, text)
m_created = re.search(r"\* Created at: (.+)", text)
m_updated = re.search(r"\* Updated at: (.+?) by (.+)$", text, re.MULTILINE)
if m_created:
meta.created_at = m_created.group(1).strip()
if m_updated:
meta.updated_at = m_updated.group(1).strip()
meta.last_contributor = m_updated.group(2).strip()
meta.commit_sha = commit_sha
return finalize_header(meta, body, suffix, leading)

110
init-third-party-submodules.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Sync and initialize all third-party/ git submodules from .gitmodules (recursive nested).
# Used by rebuild.sh, ci-build.sh, and deploy paths — no network except submodule fetch/update.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ONLY_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_PATH="${2:-}"
shift 2
;;
-h|--help)
echo "Usage: $0 [--only third-party/wireguard-android]"
echo " Initializes every path in .gitmodules under third-party/ (recursive)."
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
exit 1
;;
esac
done
if [[ ! -f .gitmodules ]]; then
echo "error: .gitmodules not found in $ROOT" >&2
exit 1
fi
mapfile -t SUB_PATHS < <(git config -f .gitmodules --get-regexp '^submodule\..*\.path$' \
| awk '{ print $2 }' | grep '^third-party/' | sort -u)
if [[ ${#SUB_PATHS[@]} -eq 0 ]]; then
echo "error: no third-party paths in .gitmodules" >&2
exit 1
fi
if [[ -n "$ONLY_PATH" ]]; then
SUB_PATHS=("$ONLY_PATH")
fi
ensure_gitlink() {
local path="$1"
local name="$path"
if git cat-file -e "HEAD:${path}" 2>/dev/null \
|| git ls-files --stage "$path" | grep -q '^160000'; then
return 0
fi
local url
url="$(git config -f .gitmodules --get "submodule.${name}.url" || true)"
if [[ -z "$url" ]]; then
echo "error: $path listed in .gitmodules but no gitlink in tree and no url" >&2
return 1
fi
local sha
sha="$(git ls-remote "$url" HEAD | awk 'NR==1 {print $1}')"
if [[ -z "$sha" ]]; then
echo "error: could not resolve HEAD for $url (needed to register missing gitlink)" >&2
return 1
fi
echo "WARN: registering missing gitlink for $path at $sha (commit this gitlink)" >&2
git update-index --add --cacheinfo 160000,"$sha","$path"
}
validate_path() {
local path="$1"
case "$path" in
third-party/wireguard-android)
[[ -d "$path/tunnel/src/main/java" ]] \
&& [[ -f "$path/tunnel/tools/wireguard-tools/src/config.c" ]] \
&& [[ -f "$path/tunnel/tools/elf-cleaner/elf-cleaner.cpp" || -f "$path/tunnel/tools/elf-cleaner/main.cpp" ]]
;;
third-party/libvpx)
[[ -d "$path" ]]
;;
third-party/opus|third-party/speex)
[[ -f "$path/configure" || -f "$path/CMakeLists.txt" || -d "$path/include" ]]
;;
*)
[[ -d "$path" ]]
;;
esac
}
echo "==> third-party submodules: sync + update --init --recursive"
for path in "${SUB_PATHS[@]}"; do
if ! grep -q "$path" .gitmodules 2>/dev/null; then
echo "error: $path not in .gitmodules" >&2
exit 1
fi
ensure_gitlink "$path"
git submodule sync "$path"
git submodule update --init --recursive "$path"
if ! validate_path "$path"; then
echo "error: $path incomplete after submodule update (nested submodules missing?)" >&2
exit 1
fi
short="$(git -C "$path" rev-parse --short HEAD 2>/dev/null || echo '?')"
echo " $path @ $short"
done
if [[ -d third-party/wireguard-android/tunnel ]]; then
wg_tools="$(git -C third-party/wireguard-android/tunnel/tools/wireguard-tools rev-parse --short HEAD 2>/dev/null || echo '?')"
echo " third-party/wireguard-android/tunnel/tools/wireguard-tools @ $wg_tools"
fi
echo "==> third-party submodules ready"

5
init-wireguard-submodule.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Back-compat wrapper — wireguard-android is one of the third-party submodules.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec "$ROOT/scripts/init-third-party-submodules.sh" --only third-party/wireguard-android

11
install-git-hooks.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Install repo git hooks (header pre-commit + post-commit).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
git -C "$ROOT" config core.hooksPath .githooks
chmod +x "$ROOT/.githooks/pre-commit"
chmod +x "$ROOT/.githooks/post-commit"
chmod +x "$ROOT/scripts/header-pre-commit.py"
chmod +x "$ROOT/scripts/header-post-commit.py"
chmod +x "$ROOT/scripts/apply-project-headers.py"
echo "Installed hooks via core.hooksPath=.githooks (pre-commit + post-commit)"

124
native-build-cache.sh Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Host build accelerators for native codecs (libvpx / NDK). Source, do not execute.
#
# ccache (default: auto-detect):
# ANDROIDCAST_CCACHE=auto|1|0
# CCACHE_DIR — default: <repo>/build/.ccache
#
# distcc (off by default; experimental for libvpx configure/make only, not ndk-build):
# ANDROIDCAST_USE_DISTCC=1
# DISTCC_HOSTS=localhost/8 192.168.1.10/16
#
set -euo pipefail
[[ -n "${ANDROIDCAST_NATIVE_CACHE_SOURCED:-}" ]] && return 0
ANDROIDCAST_NATIVE_CACHE_SOURCED=1
androidcast_project_root() {
if [[ -n "${ANDROIDCAST_ROOT:-}" ]]; then
printf '%s\n' "$ANDROIDCAST_ROOT"
return 0
fi
local here
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
printf '%s\n' "$here"
}
androidcast_ccache_wanted() {
case "${ANDROIDCAST_CCACHE:-auto}" in
0 | false | FALSE | no | NO | off | OFF) return 1 ;;
1 | true | TRUE | yes | YES | on | ON) return 0 ;;
auto | AUTO | "")
command -v ccache >/dev/null 2>&1
;;
*) return 0 ;;
esac
}
androidcast_ndk_clang_basename() {
case "${1:-}" in
arm64-v8a) echo aarch64-linux-android29-clang ;;
armeabi-v7a) echo armv7a-linux-androideabi29-clang ;;
x86_64) echo x86_64-linux-android29-clang ;;
x86) echo i686-linux-android29-clang ;;
*) echo "" ;;
esac
}
androidcast_ndk_clangxx_basename() {
case "${1:-}" in
arm64-v8a) echo aarch64-linux-android29-clang++ ;;
armeabi-v7a) echo armv7a-linux-androideabi29-clang++ ;;
x86_64) echo x86_64-linux-android29-clang++ ;;
x86) echo i686-linux-android29-clang++ ;;
*) echo "" ;;
esac
}
# Configure ccache + optional distcc; set CC/CXX/NDK_CCACHE for libvpx + ndk-build.
# Args: project_root abi ndk_root ndk_host_bin
androidcast_apply_native_build_accelerators() {
local root="${1:?root}"
local abi="${2:?abi}"
local ndk_bin="${4:?ndk_host_bin}"
local clang clangxx
clang="$(androidcast_ndk_clang_basename "$abi")"
clangxx="$(androidcast_ndk_clangxx_basename "$abi")"
if [[ -z "$clang" || ! -x "$ndk_bin/$clang" ]]; then
echo "WARN: NDK clang not found for $abi under $ndk_bin" >&2
return 0
fi
local prefix=""
local ccache_on=false
local distcc_on=false
if androidcast_ccache_wanted && command -v ccache >/dev/null 2>&1; then
ccache_on=true
export CCACHE_DIR="${CCACHE_DIR:-$root/build/.ccache}"
mkdir -p "$CCACHE_DIR"
export CCACHE_BASEDIR="$root"
export CCACHE_COMPRESS="${CCACHE_COMPRESS:-1}"
export CCACHE_COMPILERCHECK="${CCACHE_COMPILERCHECK:-content}"
export CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-5G}"
export NDK_CCACHE="${NDK_CCACHE:-ccache}"
prefix="ccache "
echo "ccache: ON (dir=$CCACHE_DIR, NDK_CCACHE=$NDK_CCACHE)"
ccache -s 2>/dev/null | sed 's/^/ /' || true
elif androidcast_ccache_wanted; then
echo "ccache: requested but not installed (install ccache or ANDROIDCAST_CCACHE=0)"
else
echo "ccache: off (ANDROIDCAST_CCACHE=${ANDROIDCAST_CCACHE:-auto})"
fi
case "${ANDROIDCAST_USE_DISTCC:-0}" in
1 | true | TRUE | yes | YES | on | ON)
if command -v distcc >/dev/null 2>&1 && [[ -n "${DISTCC_HOSTS:-}" ]]; then
distcc_on=true
export DISTCC_VERBOSE="${DISTCC_VERBOSE:-0}"
prefix="distcc ${prefix}"
echo "distcc: ON (experimental, libvpx make/configure only; hosts=$DISTCC_HOSTS)"
elif command -v distcc >/dev/null 2>&1; then
echo "distcc: ANDROIDCAST_USE_DISTCC=1 but DISTCC_HOSTS empty — skipped" >&2
else
echo "distcc: requested but distcc not installed — skipped" >&2
fi
;;
*)
echo "distcc: off (set ANDROIDCAST_USE_DISTCC=1 and DISTCC_HOSTS to try)"
;;
esac
export CC="${prefix}${ndk_bin}/${clang}"
if [[ -n "$clangxx" && -x "$ndk_bin/$clangxx" ]]; then
export CXX="${prefix}${ndk_bin}/${clangxx}"
else
export CXX="${prefix}${ndk_bin}/${clang}"
fi
export AR="${ndk_bin}/llvm-ar"
export RANLIB="${ndk_bin}/llvm-ranlib"
# libvpx configure reads these when cross-compiling.
export AS="$CC"
export LD="${ndk_bin}/ld.lld"
}

196
simulate-vpn-crashes.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# VPN crash simulation lab — device (adb) and/or BE upload verification.
#
# Modes:
# MODE=device — trigger VpnCrashSimulator in :vpn via adb (debug APK)
# MODE=upload — POST synthetic vpn-process crash JSON to BE upload API
# MODE=both — device then verify BE (default when adb device present)
#
# Env:
# COUNT=70
# BASE=https://apps.f0xx.org/app/androidcast_project/crashes
# RUN_ID=YYYYMMDD_HHMMSS (batch tag for Issues search)
# PACKAGE=com.foxx.androidcast
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COUNT="${COUNT:-70}"
BASE="${BASE:-https://apps.f0xx.org/app/androidcast_project/crashes}"
UPLOAD_URL="$BASE/api/upload.php"
PACKAGE="${PACKAGE:-com.foxx.androidcast}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%d_%H%M%S)}"
MODE="${MODE:-auto}"
JAR="${JAR:-/tmp/ac_vpn_crash_cookies.txt}"
DELAY_MS="${DELAY_MS:-80}"
VERIFY_LOGIN="${VERIFY_LOGIN:-1}"
log() { printf '[vpn-crash-sim] %s\n' "$*"; }
has_device() {
adb devices 2>/dev/null | awk 'NR>1 && $2=="device" { found=1 } END { exit !found }'
}
simulate_device() {
local n="$1"
local i seq
log "device: triggering $n VPN-process crash recordings via $PACKAGE"
for ((i = 1; i <= n; i++)); do
seq="$i"
adb shell am start-service \
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
--ei sim_seq "$seq" >/dev/null 2>&1 \
|| adb shell am startservice \
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
--ei sim_seq "$seq" >/dev/null 2>&1 \
|| true
if [[ "$DELAY_MS" -gt 0 ]]; then
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
fi
if (( i % 10 == 0 )); then
log " device progress $i/$n"
fi
done
log "device: done — waiting for crash watcher upload sweep"
sleep 15
}
upload_one() {
local seq="$1"
local report_id="vpn_sim_${RUN_ID}_$(printf '%03d' "$seq")"
local ms
ms="$(date +%s)000"
local fp
fp="$(printf 'vpn_sim_%s_%03d' "$RUN_ID" "$seq" | sha256sum | awk '{print $1}')"
local body
body="$(cat <<EOF
{
"schema_version": 1,
"report_id": "$report_id",
"generated_at_epoch_ms": $ms,
"crash_type": "java",
"scenario": "vpn_service",
"process": "com.foxx.androidcast:vpn",
"fingerprint": "$fp",
"tags": ["vpn_sim", "$RUN_ID"],
"device": {
"manufacturer": "SimLab",
"brand": "AndroidCast",
"model": "VpnCrashSim",
"device": "vpn_sim",
"product": "vpn_sim",
"sdk_int": 34,
"release": "14",
"abis": ["arm64-v8a"]
},
"app": {
"package": "$PACKAGE",
"version_name": "0.1.0",
"version_code": 100
},
"build": { "debug": true, "git_commit": "vpn_sim" },
"runtime_context": { "vpn_sim_seq": $seq, "run_id": "$RUN_ID" },
"java": {
"thread": "androidcast-vpn-worker",
"exception": "java.lang.RuntimeException",
"message": "VpnCrashSimulation seq=$seq proc=com.foxx.androidcast:vpn",
"stack_frames": [
"com.foxx.androidcast.vpn.WireGuardVpnEngine.apply(WireGuardVpnEngine.java:40)",
"com.foxx.androidcast.vpn.AndroidCastVpnService\$1.applyWireGuardConfig(AndroidCastVpnService.java:60)",
"com.foxx.androidcast.vpn.AndroidCastVpnService.onStartCommand(AndroidCastVpnService.java:95)",
"android.app.ActivityThread.handleServiceArgs(ActivityThread.java:5001)"
]
}
}
EOF
)"
curl -sk -w '\n%{http_code}' -H 'Content-Type: application/json' --data-binary "$body" "$UPLOAD_URL"
}
simulate_upload() {
local n="$1"
local ok=0 fail=0 dup=0
log "upload: posting $n vpn-process crash reports to $UPLOAD_URL (run_id=$RUN_ID)"
local i resp code
for ((i = 1; i <= n; i++)); do
resp="$(upload_one "$i")"
code="$(echo "$resp" | tail -n1)"
case "$code" in
200) ((ok++)) || true ;;
409) ((dup++)) || true ;;
*) ((fail++)) || true; log " FAIL seq=$i HTTP $code" ;;
esac
if (( i % 10 == 0 )); then
log " upload progress $i/$n (ok=$ok dup=$dup fail=$fail)"
fi
if [[ "$DELAY_MS" -gt 0 ]]; then
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
fi
done
log "upload: ok=$ok duplicate=$dup failed=$fail / $n"
[[ "$fail" -eq 0 ]]
}
verify_be() {
if [[ "$VERIFY_LOGIN" != "1" ]]; then
log "verify: skipped (VERIFY_LOGIN=$VERIFY_LOGIN)"
return 0
fi
log "verify: login + search Issues for run_id=$RUN_ID"
curl -sk -c "$JAR" -b "$JAR" -L -X POST "$BASE/login" \
-d 'username=admin&password=admin' -o /dev/null || true
local json
local q="${QUERY:-$RUN_ID}"
json="$(curl -sk -b "$JAR" "$BASE/api/reports.php?q=${q}&per_page=200")"
local cnt
cnt="$(echo "$json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
items=d.get('items') or []
print(len(items))
" 2>/dev/null || echo 0)"
log "verify: BE reports matching q=$q$cnt (expected up to $COUNT)"
if [[ "$cnt" -lt 1 ]]; then
log "verify: WARN — Issues API returned 0 (login cookie may be invalid in automation)"
log "verify: open $BASE/?view=reports&q=$q in browser when logged in"
return 1
fi
echo "$json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
for it in (d.get('items') or [])[:3]:
p=it.get('payload') or {}
j=p.get('java') or {}
print(' sample:', it.get('report_id'), 'process=', p.get('process'), 'frames=', len(j.get('stack_frames') or []))
" 2>/dev/null || true
}
if [[ "$MODE" == "auto" ]]; then
if has_device; then
MODE=both
else
MODE=upload
log "no adb device — using upload-only mode"
fi
fi
case "$MODE" in
device)
simulate_device "$COUNT"
;;
upload)
simulate_upload "$COUNT"
verify_be || true
;;
both)
simulate_device "$COUNT"
QUERY="${QUERY:-VpnCrashSimulation}" verify_be || true
;;
*)
echo "unknown MODE=$MODE (device|upload|both)" >&2
exit 1
;;
esac
log "complete run_id=$RUN_ID — Issues: $BASE/?view=reports&q=$RUN_ID"

112
test_header_lib.py Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Unit tests for header_lib (run: python3 scripts/test_header_lib.py)."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from header_lib import (
apply_header_to_content,
compute_digest,
format_identity,
has_valid_header,
is_excluded_path,
parse_identity,
render_header,
resolve_header_contributor,
strip_existing_header,
build_meta,
)
from pathlib import Path
import header_lib
class HeaderLibTest(unittest.TestCase):
def test_strip_and_apply_java(self) -> None:
src = """package com.example;
public class Foo {
}
"""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "Foo.java"
path.write_text(src, encoding="utf-8")
out = apply_header_to_content(path, src)
self.assertTrue(has_valid_header(out))
self.assertIn("package com.example;", out)
self.assertIn(" * class Foo", out)
self.assertIn("Digest: SHA256", out)
self.assertIn("Cursor Agent", out)
body, _ = strip_existing_header(out, ".java")
self.assertIn("public class Foo", body)
self.assertNotIn("package com.example", body.split("public class")[0])
def test_php_header_wrapped_in_comment(self) -> None:
src = "<?php\ndeclare(strict_types=1);\n"
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "bootstrap.php"
path.write_text(src, encoding="utf-8")
out = apply_header_to_content(path, src)
self.assertTrue(out.startswith("<?php\n/*\n"))
self.assertIn("*/\ndeclare(strict_types=1)", out)
self.assertNotIn("?>\n<?php", out)
self.assertNotIn("/*********************************************************************", out)
body, _ = strip_existing_header(out, ".php")
self.assertTrue(body.startswith("declare(strict_types=1)"))
def test_php_html_template(self) -> None:
src = "<!DOCTYPE html>\n<html></html>\n"
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "page.php"
path.write_text(src, encoding="utf-8")
out = apply_header_to_content(path, src)
self.assertIn("?>\n<!DOCTYPE", out)
self.assertNotIn("<?php\n<!DOCTYPE", out)
def test_commit_excluded_from_digest(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "X.java"
path.write_text("package p;\npublic class X {}\n", encoding="utf-8")
body, _ = strip_existing_header(
apply_header_to_content(path, path.read_text()), ".java")
meta = build_meta(path, body, path.read_text())
meta.commit_sha = "aaa"
d1 = compute_digest(render_header(meta, None, ".java", for_digest=True), body)
meta.commit_sha = "bbb"
d2 = compute_digest(render_header(meta, None, ".java", for_digest=True), body)
self.assertEqual(d1, d2)
def test_format_identity(self) -> None:
self.assertEqual("Ada <a@b.c>", format_identity("Ada", "a@b.c"))
self.assertEqual("Ada", format_identity("Ada", ""))
name, mail = parse_identity("Ada <a@b.c>")
self.assertEqual(("Ada", "a@b.c"), (name, mail))
def test_excluded_third_party(self) -> None:
p = header_lib.REPO_ROOT / "third-party/libvpx/foo.c"
self.assertTrue(is_excluded_path(p))
def test_resolve_header_contributor_env(self) -> None:
import os
old = os.environ.get("HEADER_CONTRIBUTOR")
old_mail = os.environ.get("HEADER_CONTRIBUTOR_EMAIL")
try:
os.environ["HEADER_CONTRIBUTOR"] = "Test User"
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
ident = resolve_header_contributor()
self.assertTrue(ident.startswith("Test User"))
finally:
if old is None:
os.environ.pop("HEADER_CONTRIBUTOR", None)
else:
os.environ["HEADER_CONTRIBUTOR"] = old
if old_mail is None:
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
else:
os.environ["HEADER_CONTRIBUTOR_EMAIL"] = old_mail
if __name__ == "__main__":
unittest.main()

12
validate_opus_speex.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Unit-test baseline for Opus/Speex negotiation and stream protection (task 3).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "== Opus/Speex + stream protection unit tests =="
./gradlew :app:testDebugUnitTest \
--tests 'com.foxx.androidcast.network.transport.StreamProtectionNegotiatorTest' \
--tests 'com.foxx.androidcast.media.AudioNegotiatorTest' \
--tests 'com.foxx.androidcast.media.codec.*' \
"$@"
echo "== OK =="

34
verify-gitignore.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Quick check that paths required for ./rebuild.sh are not gitignored.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
required=(
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties
gradle/wireguard-tunnel/build.gradle
gradlew
third-party/README.md
)
fail=0
for path in "${required[@]}"; do
if [[ ! -e "$path" ]]; then
echo "MISSING on disk: $path" >&2
fail=1
continue
fi
if git check-ignore -q "$path" 2>/dev/null; then
echo "GITIGNORE BLOCKS: $path" >&2
fail=1
else
echo "OK: $path"
fi
done
if [[ "$fail" -ne 0 ]]; then
echo "fix .gitignore — see header comments in .gitignore" >&2
exit 1
fi
echo "verify-gitignore: all required paths visible to git"