commit 3c197ed9dd0863c237ac7010fecacc4c7b2c4dd4 Author: Anton Afanasyeu Date: Tue Jun 23 12:20:53 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..39db0f7 --- /dev/null +++ b/README.md @@ -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 diff --git a/adb-reconnect.sh b/adb-reconnect.sh new file mode 100755 index 0000000..3cbc195 --- /dev/null +++ b/adb-reconnect.sh @@ -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 /' +} diff --git a/alpha-qa-logcat.sh b/alpha-qa-logcat.sh new file mode 100755 index 0000000..ca86cbf --- /dev/null +++ b/alpha-qa-logcat.sh @@ -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:-} diff --git a/android-ndk.sh b/android-ndk.sh new file mode 100755 index 0000000..770fd9e --- /dev/null +++ b/android-ndk.sh @@ -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" +} diff --git a/apply-project-headers.py b/apply-project-headers.py new file mode 100755 index 0000000..e1784d0 --- /dev/null +++ b/apply-project-headers.py @@ -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()) diff --git a/build-all-docs-pdf.sh b/build-all-docs-pdf.sh new file mode 100755 index 0000000..a4867fd --- /dev/null +++ b/build-all-docs-pdf.sh @@ -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)" diff --git a/build-graphvis-pdf.sh b/build-graphvis-pdf.sh new file mode 100644 index 0000000..f8b3ef6 --- /dev/null +++ b/build-graphvis-pdf.sh @@ -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" diff --git a/build-native-codecs.sh b/build-native-codecs.sh new file mode 100755 index 0000000..3c099bb --- /dev/null +++ b/build-native-codecs.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# Build libvpx, libopus, and libspeex for Android ABIs → build/native//*.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// +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//*.a per ABI):" +echo " ./gradlew assembleDebug" diff --git a/build-reverse-ssh-proposals-pdf.sh b/build-reverse-ssh-proposals-pdf.sh new file mode 100755 index 0000000..9263005 --- /dev/null +++ b/build-reverse-ssh-proposals-pdf.sh @@ -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" diff --git a/capture-vp9-fec-logs.sh b/capture-vp9-fec-logs.sh new file mode 100755 index 0000000..f528c0e --- /dev/null +++ b/capture-vp9-fec-logs.sh @@ -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 diff --git a/ci-build-and-publish-ota.sh b/ci-build-and-publish-ota.sh new file mode 100755 index 0000000..9d764e6 --- /dev/null +++ b/ci-build-and-publish-ota.sh @@ -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" </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" diff --git a/ci-build.sh b/ci-build.sh new file mode 100755 index 0000000..6bc1d84 --- /dev/null +++ b/ci-build.sh @@ -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" diff --git a/deploy-ota-staging.sh b/deploy-ota-staging.sh new file mode 100755 index 0000000..ea01636 --- /dev/null +++ b/deploy-ota-staging.sh @@ -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 diff --git a/docker-build-ota.sh b/docker-build-ota.sh new file mode 100755 index 0000000..6a9dc13 --- /dev/null +++ b/docker-build-ota.sh @@ -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 diff --git a/docker-build-runner.sh b/docker-build-runner.sh new file mode 100755 index 0000000..b12ad92 --- /dev/null +++ b/docker-build-runner.sh @@ -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 @${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}" diff --git a/ensure-gradle-wrapper.sh b/ensure-gradle-wrapper.sh new file mode 100755 index 0000000..a83e459 --- /dev/null +++ b/ensure-gradle-wrapper.sh @@ -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 diff --git a/generate-docs-toc.py b/generate-docs-toc.py new file mode 100644 index 0000000..3f2d619 --- /dev/null +++ b/generate-docs-toc.py @@ -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_END = "" +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.1–9.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 + 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() diff --git a/generate-ota-v0.sh b/generate-ota-v0.sh new file mode 100755 index 0000000..95b14a3 --- /dev/null +++ b/generate-ota-v0.sh @@ -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/.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}" <"${rel_dir}/${manifest_name}" </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}" <"${channel_file}" <&2 +echo "Channel: ${host_base%/}/v0/ota/channel/${channel}.json" >&2 +echo "Bundle: ${bundle_url} (${bundle_size} bytes)" >&2 +cat "${channel_file}" diff --git a/grant-dev-adb-perms.sh b/grant-dev-adb-perms.sh new file mode 100755 index 0000000..24a1406 --- /dev/null +++ b/grant-dev-adb-perms.sh @@ -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" diff --git a/header-post-commit.py b/header-post-commit.py new file mode 100755 index 0000000..fcfafd5 --- /dev/null +++ b/header-post-commit.py @@ -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()) diff --git a/header-pre-commit.py b/header-pre-commit.py new file mode 100755 index 0000000..39883fb --- /dev/null +++ b/header-pre-commit.py @@ -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()) diff --git a/header_lib.py b/header_lib.py new file mode 100755 index 0000000..c46be41 --- /dev/null +++ b/header_lib.py @@ -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 " 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 ' 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 before real body.""" + work = text.lstrip("\ufeff") + while True: + changed = False + 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. = 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 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 " 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: then HTML. + """ + body = body.lstrip("\n") + if body.startswith("\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(" 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) diff --git a/init-third-party-submodules.sh b/init-third-party-submodules.sh new file mode 100755 index 0000000..54647f6 --- /dev/null +++ b/init-third-party-submodules.sh @@ -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" diff --git a/init-wireguard-submodule.sh b/init-wireguard-submodule.sh new file mode 100755 index 0000000..6acabbb --- /dev/null +++ b/init-wireguard-submodule.sh @@ -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 diff --git a/install-git-hooks.sh b/install-git-hooks.sh new file mode 100755 index 0000000..9949ae3 --- /dev/null +++ b/install-git-hooks.sh @@ -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)" diff --git a/native-build-cache.sh b/native-build-cache.sh new file mode 100644 index 0000000..d4534b9 --- /dev/null +++ b/native-build-cache.sh @@ -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: /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" +} diff --git a/simulate-vpn-crashes.sh b/simulate-vpn-crashes.sh new file mode 100755 index 0000000..742e29c --- /dev/null +++ b/simulate-vpn-crashes.sh @@ -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 </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" diff --git a/test_header_lib.py b/test_header_lib.py new file mode 100755 index 0000000..9d0d43f --- /dev/null +++ b/test_header_lib.py @@ -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 = "\n None: + src = "\n\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 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 ", format_identity("Ada", "a@b.c")) + self.assertEqual("Ada", format_identity("Ada", "")) + name, mail = parse_identity("Ada ") + 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() diff --git a/validate_opus_speex.sh b/validate_opus_speex.sh new file mode 100755 index 0000000..46a10ae --- /dev/null +++ b/validate_opus_speex.sh @@ -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 ==" diff --git a/verify-gitignore.sh b/verify-gitignore.sh new file mode 100755 index 0000000..eb49e43 --- /dev/null +++ b/verify-gitignore.sh @@ -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"