mirror of
git://f0xx.org/ac/ac-scripts
synced 2026-07-29 04:18:19 +03:00
initial
This commit is contained in:
6
README.md
Normal file
6
README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# ac-scripts
|
||||
|
||||
CI helpers, OTA, native codec scripts, PDF driver wrappers.
|
||||
|
||||
- **Remote:** `git://f0xx.org/ac/ac-scripts`
|
||||
- **Consumed by:** `ac-deploy` (submodule), local dev
|
||||
497
adb-reconnect.sh
Executable file
497
adb-reconnect.sh
Executable file
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wireless adb discovery + reconnect helpers (sourced by rebuild.sh).
|
||||
# Priority: app HTTP :5039/adb.json → avahi → cache → mdns → :5555 hints.
|
||||
# Only IPv4:PORT is used for adb connect (no mDNS service names).
|
||||
|
||||
: "${ADB_CONNECT_RETRIES:=3}"
|
||||
: "${ADB_CONNECT_DELAY_SEC:=1}"
|
||||
: "${ADB_TCP_PROBE:=yes}"
|
||||
: "${ADB_TCP_PROBE_SEC:=1}"
|
||||
: "${DEV_ADB_HTTP_PORT:=5039}"
|
||||
: "${ADB_CONNECT_CACHE:=$ROOT/.adb_connect_cache}"
|
||||
: "${ADB_HTTP_CACHE_DIR:=$ROOT/.adb_http_cache}"
|
||||
: "${ADB_RECONNECT_POKE_DELAY_SEC:=2}"
|
||||
: "${ADB_MDNS_AUTO_CONNECT:=adb-tls-connect,adb}"
|
||||
: "${ADB_KILL_SERVER:=no}"
|
||||
: "${ADB_DISCONNECT_BEFORE_CONNECT:=no}"
|
||||
|
||||
declare -gA CONNECTIONS_LIST_HINTS
|
||||
|
||||
adb_reconnect_init_hints() {
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_153"]="[A13] Android Projector"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_39"]="[A16] Doogee Tab G6 Max / P8_EEA"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_106"]="[A11] BlackView BL6000Pro / BL6000Pro_EEA"
|
||||
CONNECTIONS_LIST_HINTS["192_168_33_173"]="[A11] BlackView BL6000Pro / BL6000Pro"
|
||||
}
|
||||
|
||||
adb_hint_key_for_ip() {
|
||||
local ip=${1}
|
||||
ip="$(echo "${ip}" | sed -e 's@\.@_@g' -e 's@:.*@@' | tr -d '\n')"
|
||||
printf '%s' "${ip}"
|
||||
}
|
||||
|
||||
adb_ip_for_hint_key() {
|
||||
local ip=${1}
|
||||
ip="$(echo "${ip}" | sed -e 's/_/./g' -e 's/:.*//' | tr -d '\n')"
|
||||
printf '%s' "${ip}"
|
||||
}
|
||||
|
||||
adb_hint_for_endpoint() {
|
||||
local endpoint=${1}
|
||||
local key
|
||||
key="$(adb_hint_key_for_ip "${endpoint}")"
|
||||
printf '%s' "${CONNECTIONS_LIST_HINTS[${key}]:-}"
|
||||
}
|
||||
|
||||
adb_endpoint_host() {
|
||||
local endpoint=${1}
|
||||
if [[ "$endpoint" =~ ^([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+): ]]; then
|
||||
printf '%s' "${BASH_REMATCH[1]}"
|
||||
fi
|
||||
}
|
||||
|
||||
adb_is_connectable_ip_port() {
|
||||
local endpoint=${1}
|
||||
[[ "$endpoint" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+$ ]]
|
||||
}
|
||||
|
||||
adb_tcp_port_open() {
|
||||
local endpoint=${1}
|
||||
local host port
|
||||
host="$(adb_endpoint_host "$endpoint")"
|
||||
port="${endpoint##*:}"
|
||||
[[ -n "$host" && "$port" =~ ^[0-9]+$ ]] || return 1
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z -w "${ADB_TCP_PROBE_SEC}" "$host" "$port" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
timeout "${ADB_TCP_PROBE_SEC}" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
|
||||
}
|
||||
|
||||
adb_collect_candidate_ips() {
|
||||
local ip endpoint key
|
||||
if [[ -f "$ADB_CONNECT_CACHE" ]]; then
|
||||
while IFS= read -r endpoint; do
|
||||
[[ -z "$endpoint" ]] && continue
|
||||
ip="${endpoint%%:*}"
|
||||
[[ -n "$ip" ]] && printf '%s\n' "$ip"
|
||||
done < "$ADB_CONNECT_CACHE"
|
||||
fi
|
||||
for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
|
||||
adb_ip_for_hint_key "$key"
|
||||
done
|
||||
adb devices -l 2>/dev/null | awk '/^[^ ]+ +device/ { print $1 }' | while read -r serial; do
|
||||
[[ "$serial" == *:* ]] || continue
|
||||
printf '%s\n' "${serial%%:*}"
|
||||
done
|
||||
}
|
||||
|
||||
adb_json_valid() {
|
||||
local json=${1}
|
||||
[[ -n "$json" ]] || return 1
|
||||
printf '%s' "$json" | python3 -c 'import json,sys; json.loads(sys.stdin.read())' 2>/dev/null
|
||||
}
|
||||
|
||||
adb_json_connect_lines() {
|
||||
local json=${1}
|
||||
[[ -n "$json" ]] || return 0
|
||||
printf '%s' "$json" | python3 -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
raise SystemExit(0)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raise SystemExit(0)
|
||||
tls = int(data.get("tls_port") or 0)
|
||||
if tls > 0:
|
||||
for ip in data.get("ips") or []:
|
||||
if ip:
|
||||
print(f"{ip}:{tls}")
|
||||
import re
|
||||
IP_PORT = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}:\d+$")
|
||||
def ok(ep):
|
||||
return bool(ep and IP_PORT.match(ep))
|
||||
for item in data.get("connect") or []:
|
||||
if ok(item):
|
||||
print(item)
|
||||
for item in data.get("commands") or []:
|
||||
if isinstance(item, str) and item.startswith("adb connect "):
|
||||
ep = item[len("adb connect "):]
|
||||
if ok(ep):
|
||||
print(ep)
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
adb_curl_adb_json() {
|
||||
local ip=${1}
|
||||
local json cached="${ADB_HTTP_CACHE_DIR}/${ip}.json"
|
||||
json="$(curl -m 5 -sS "http://${ip}:${DEV_ADB_HTTP_PORT}/adb.json" 2>/dev/null || true)"
|
||||
if adb_json_valid "$json"; then
|
||||
mkdir -p "$ADB_HTTP_CACHE_DIR"
|
||||
printf '%s' "$json" >"$cached"
|
||||
printf '%s' "$json"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$cached" ]] && adb_json_valid "$(cat "$cached")"; then
|
||||
cat "$cached"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
adb_fetch_http_endpoints() {
|
||||
local ip json
|
||||
for ip in $(adb_collect_candidate_ips | sort -u); do
|
||||
[[ -z "$ip" ]] && continue
|
||||
json="$(adb_curl_adb_json "$ip" 2>/dev/null || true)"
|
||||
[[ -z "$json" ]] && continue
|
||||
adb_json_connect_lines "$json"
|
||||
done
|
||||
}
|
||||
|
||||
adb_discover_mdns_endpoints() {
|
||||
local line hostport
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == List* ]] && continue
|
||||
[[ "$line" == error:* ]] && continue
|
||||
[[ "$line" == *"_adb"* ]] || continue
|
||||
hostport="$(awk '{print $NF}' <<<"$line")"
|
||||
if adb_is_connectable_ip_port "$hostport"; then
|
||||
printf '%s\n' "$hostport"
|
||||
fi
|
||||
done < <(adb mdns services 2>&1 || true)
|
||||
}
|
||||
|
||||
adb_discover_avahi_endpoints() {
|
||||
command -v avahi-browse >/dev/null 2>&1 || return 0
|
||||
local svc
|
||||
for svc in _adb-tls-connect._tcp _adb._tcp; do
|
||||
avahi-browse -rpt "$svc" 2>/dev/null | awk -F';' '
|
||||
$1 == "=" && $3 == "IPv4" && $8 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && $9 ~ /^[0-9]+$/ {
|
||||
print $8 ":" $9
|
||||
}'
|
||||
done
|
||||
}
|
||||
|
||||
adb_pull_device_json_endpoints() {
|
||||
local serial json tmp
|
||||
while IFS= read -r serial; do
|
||||
[[ -z "$serial" ]] && continue
|
||||
tmp="$(mktemp)"
|
||||
if adb -s "$serial" exec-out run-as com.foxx.androidcast cat files/dev/adb_connect.json \
|
||||
>"$tmp" 2>/dev/null; then
|
||||
json="$(cat "$tmp")"
|
||||
rm -f "$tmp"
|
||||
adb_json_connect_lines "$json"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||
}
|
||||
|
||||
adb_endpoint_rank() {
|
||||
local e=${1}
|
||||
if [[ "$e" == *":5555" ]]; then
|
||||
printf '9'
|
||||
else
|
||||
printf '2'
|
||||
fi
|
||||
}
|
||||
|
||||
# One endpoint per IPv4 host; keep best rank (HTTP/avahi TLS beats :5555).
|
||||
adb_discover_endpoints() {
|
||||
local endpoint host rank best_rank existing
|
||||
declare -A seen=()
|
||||
declare -A host_best=()
|
||||
declare -A host_rank=()
|
||||
local -a ordered=()
|
||||
|
||||
consider_endpoint() {
|
||||
local e=${1}
|
||||
[[ -z "$e" ]] && return 0
|
||||
if [[ "$e" == *_* && "$e" != *":"* ]]; then
|
||||
e="$(adb_ip_for_hint_key "$e")"
|
||||
fi
|
||||
[[ -z "$e" ]] && return 0
|
||||
if ! adb_is_connectable_ip_port "$e"; then
|
||||
return 0
|
||||
fi
|
||||
host="$(adb_endpoint_host "$e")"
|
||||
rank="$(adb_endpoint_rank "$e")"
|
||||
if [[ -n "$host" ]]; then
|
||||
existing="${host_best[$host]:-}"
|
||||
if [[ -n "$existing" ]]; then
|
||||
best_rank="${host_rank[$host]}"
|
||||
if [[ "$rank" -ge "$best_rank" ]]; then
|
||||
return 0
|
||||
fi
|
||||
seen["$existing"]=0
|
||||
for i in "${!ordered[@]}"; do
|
||||
if [[ "${ordered[$i]}" == "$existing" ]]; then
|
||||
unset 'ordered[i]'
|
||||
fi
|
||||
done
|
||||
ordered=("${ordered[@]}")
|
||||
fi
|
||||
host_best["$host"]="$e"
|
||||
host_rank["$host"]="$rank"
|
||||
fi
|
||||
[[ -n "${seen[$e]:-}" ]] && return 0
|
||||
seen["$e"]=1
|
||||
ordered+=("$e")
|
||||
}
|
||||
|
||||
# HTTP first — matches curl the user runs manually.
|
||||
while IFS= read -r endpoint; do
|
||||
consider_endpoint "$endpoint"
|
||||
done < <(adb_fetch_http_endpoints)
|
||||
|
||||
while IFS= read -r endpoint; do
|
||||
consider_endpoint "$endpoint"
|
||||
done < <(adb_discover_avahi_endpoints)
|
||||
|
||||
if [[ -f "$ADB_CONNECT_CACHE" ]]; then
|
||||
while IFS= read -r endpoint; do
|
||||
consider_endpoint "$endpoint"
|
||||
done < "$ADB_CONNECT_CACHE"
|
||||
fi
|
||||
|
||||
while IFS= read -r endpoint; do
|
||||
consider_endpoint "$endpoint"
|
||||
done < <(adb_pull_device_json_endpoints)
|
||||
|
||||
while IFS= read -r endpoint; do
|
||||
consider_endpoint "$endpoint"
|
||||
done < <(adb_discover_mdns_endpoints)
|
||||
|
||||
local key ip
|
||||
for key in "${!CONNECTIONS_LIST_HINTS[@]}"; do
|
||||
ip="$(adb_ip_for_hint_key "$key")"
|
||||
[[ -n "${host_best[$ip]:-}" ]] && continue
|
||||
consider_endpoint "${ip}:5555"
|
||||
done
|
||||
|
||||
adb_filter_endpoints_by_tcp "${ordered[@]}"
|
||||
}
|
||||
|
||||
# Drop closed ports (fast nc/bash probe). If none open, return candidates anyway.
|
||||
adb_filter_endpoints_by_tcp() {
|
||||
local -a all=("$@")
|
||||
local -a open=()
|
||||
local e
|
||||
if [[ "${ADB_TCP_PROBE}" =~ ^[nN] ]]; then
|
||||
printf '%s\n' "${all[@]}"
|
||||
return 0
|
||||
fi
|
||||
for e in "${all[@]}"; do
|
||||
[[ -z "$e" ]] && continue
|
||||
if adb_tcp_port_open "$e"; then
|
||||
open+=("$e")
|
||||
else
|
||||
echo " probe ${e} — closed (skip)" >&2
|
||||
fi
|
||||
done
|
||||
if [[ ${#open[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${open[@]}"
|
||||
else
|
||||
printf '%s\n' "${all[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
adb_poke_refresh_broadcast() {
|
||||
local serial
|
||||
while IFS= read -r serial; do
|
||||
[[ -z "$serial" ]] && continue
|
||||
adb -s "$serial" shell am broadcast \
|
||||
-a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
|
||||
-n com.foxx.androidcast/.dev.DevAdbWifiReceiver >/dev/null 2>&1 || true
|
||||
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||
}
|
||||
|
||||
# Wireless TLS adb often shows as adb-xxx._adb-tls-connect._tcp, not IP:port.
|
||||
adb_resolve_device_serial() {
|
||||
local endpoint=${1}
|
||||
local serial state host count=0 last=""
|
||||
if adb -s "$endpoint" get-state 2>/dev/null | grep -q '^device$'; then
|
||||
printf '%s' "$endpoint"
|
||||
return 0
|
||||
fi
|
||||
host="$(adb_endpoint_host "$endpoint")"
|
||||
while IFS= read -r serial state; do
|
||||
[[ "$state" != "device" ]] && continue
|
||||
count=$((count + 1))
|
||||
last="$serial"
|
||||
if [[ "$serial" == "$endpoint" ]]; then
|
||||
printf '%s' "$serial"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$host" && "$serial" == *"$host"* ]]; then
|
||||
printf '%s' "$serial"
|
||||
return 0
|
||||
fi
|
||||
done < <(adb devices 2>/dev/null | awk 'NR > 1 && NF >= 2 { print $1, $2 }')
|
||||
if [[ "$count" -eq 1 && -n "$last" ]]; then
|
||||
printf '%s' "$last"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
adb_parse_connect_serial() {
|
||||
local out=${1}
|
||||
if [[ "$out" =~ [Cc]onnected\ to\ ([^[:space:]]+) ]]; then
|
||||
printf '%s' "${BASH_REMATCH[1]}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
adb_host_has_device() {
|
||||
local host=${1}
|
||||
local serial
|
||||
serial="$(adb_resolve_device_serial "${host}:1" 2>/dev/null || true)"
|
||||
[[ -n "$serial" ]]
|
||||
}
|
||||
|
||||
adb_reconnect_endpoint() {
|
||||
local endpoint="$1"
|
||||
[[ -z "$endpoint" ]] && return 1
|
||||
if ! adb_is_connectable_ip_port "$endpoint"; then
|
||||
echo " skip ${endpoint} — not IPv4:PORT" >&2
|
||||
return 1
|
||||
fi
|
||||
local attempt out hint resolved
|
||||
hint="$(adb_hint_for_endpoint "${endpoint}")"
|
||||
[[ -n "${hint}" ]] && hint="(${hint})"
|
||||
if [[ "${ADB_TCP_PROBE}" =~ [yY] ]] && ! adb_tcp_port_open "$endpoint"; then
|
||||
echo " skip ${endpoint} — TCP closed ${hint}" >&2
|
||||
return 1
|
||||
fi
|
||||
resolved="$(adb_resolve_device_serial "$endpoint" 2>/dev/null || true)"
|
||||
if [[ -n "$resolved" ]]; then
|
||||
echo " ${endpoint} already connected as ${resolved} ${hint}" >&2
|
||||
printf '%s' "$resolved"
|
||||
return 0
|
||||
fi
|
||||
echo " adb connect ${endpoint} ${hint}" >&2
|
||||
if [[ "${ADB_DISCONNECT_BEFORE_CONNECT}" =~ [yY] ]]; then
|
||||
adb disconnect "$endpoint" >/dev/null 2>&1 || true
|
||||
fi
|
||||
for (( attempt = 1; attempt <= ADB_CONNECT_RETRIES; attempt++ )); do
|
||||
out="$(adb connect "$endpoint" 2>&1)" || true
|
||||
sleep 1
|
||||
resolved="$(adb_parse_connect_serial "$out" 2>/dev/null || true)"
|
||||
if [[ -z "$resolved" ]]; then
|
||||
resolved="$(adb_resolve_device_serial "$endpoint" 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -n "$resolved" ]]; then
|
||||
echo " ${resolved} — ready (${out})" >&2
|
||||
printf '%s' "$resolved"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$attempt" -lt "$ADB_CONNECT_RETRIES" ]]; then
|
||||
sleep "$ADB_CONNECT_DELAY_SEC"
|
||||
fi
|
||||
done
|
||||
echo " ${endpoint} — not reachable (${out})" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
adb_update_connect_cache() {
|
||||
local serial
|
||||
mkdir -p "$(dirname "$ADB_CONNECT_CACHE")"
|
||||
: >"$ADB_CONNECT_CACHE"
|
||||
while IFS= read -r serial; do
|
||||
adb_is_connectable_ip_port "$serial" || continue
|
||||
echo "$serial" >>"$ADB_CONNECT_CACHE"
|
||||
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||
}
|
||||
|
||||
adb_reconnect_prepare() {
|
||||
export ADB_MDNS_AUTO_CONNECT
|
||||
adb start-server >/dev/null 2>&1 || true
|
||||
if [[ "${ADB_KILL_SERVER}" =~ [yY] ]]; then
|
||||
adb kill-server >/dev/null 2>&1 || true
|
||||
adb start-server >/dev/null 2>&1 || true
|
||||
fi
|
||||
adb reconnect offline >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
adb_reconnect_all() {
|
||||
local label=${1:-post-build}
|
||||
echo "==> Reconnecting wireless adb targets (${label})"
|
||||
adb_reconnect_prepare
|
||||
adb_poke_refresh_broadcast
|
||||
if [[ "${ADB_RECONNECT_POKE_DELAY_SEC:-0}" -gt 0 ]]; then
|
||||
sleep "$ADB_RECONNECT_POKE_DELAY_SEC"
|
||||
fi
|
||||
|
||||
local endpoint connected=0 host resolved
|
||||
declare -A connected_hosts=()
|
||||
while IFS= read -r serial; do
|
||||
host="$(adb_endpoint_host "$serial")"
|
||||
[[ -n "$host" ]] && connected_hosts["$host"]="$serial"
|
||||
done < <(adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||
|
||||
while IFS= read -r endpoint; do
|
||||
[[ -z "$endpoint" ]] && continue
|
||||
host="$(adb_endpoint_host "$endpoint")"
|
||||
if [[ -n "$host" && -n "${connected_hosts[$host]:-}" ]]; then
|
||||
echo " skip ${endpoint} — ${host} already on ${connected_hosts[$host]}"
|
||||
continue
|
||||
fi
|
||||
if resolved="$(adb_reconnect_endpoint "$endpoint")"; then
|
||||
connected=$((connected + 1))
|
||||
host="$(adb_endpoint_host "$resolved")"
|
||||
[[ -z "$host" ]] && host="$(adb_endpoint_host "$endpoint")"
|
||||
[[ -n "$host" ]] && connected_hosts["$host"]="$resolved"
|
||||
fi
|
||||
done < <(adb_discover_endpoints)
|
||||
|
||||
if [[ "$connected" -eq 0 ]]; then
|
||||
echo " retry: HTTP-only pass"
|
||||
while IFS= read -r endpoint; do
|
||||
[[ -z "$endpoint" ]] && continue
|
||||
if resolved="$(adb_reconnect_endpoint "$endpoint")"; then
|
||||
connected=$((connected + 1))
|
||||
fi
|
||||
done < <(adb_fetch_http_endpoints)
|
||||
fi
|
||||
|
||||
adb_update_connect_cache
|
||||
adb devices -l
|
||||
if [[ "$connected" -gt 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
adb_count_ready_devices() {
|
||||
adb devices 2>/dev/null | awk 'NR > 1 && $2 == "device" { c++ } END { print c+0 }'
|
||||
}
|
||||
|
||||
adb_prepare_device_for_deploy() {
|
||||
local serial=${1:-}
|
||||
local -a adb_args=()
|
||||
[[ -n "$serial" ]] && adb_args=(-s "$serial")
|
||||
adb "${adb_args[@]}" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 \
|
||||
|| adb "${adb_args[@]}" shell input keyevent 224 >/dev/null 2>&1 \
|
||||
|| true
|
||||
adb "${adb_args[@]}" shell am broadcast \
|
||||
-a com.foxx.androidcast.dev.PREPARE_FOR_DEPLOY \
|
||||
-n com.foxx.androidcast/.dev.DebugDeployWakeReceiver >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
adb_http_status() {
|
||||
local ip=${1}
|
||||
local json
|
||||
json="$(adb_curl_adb_json "$ip" 2>/dev/null || true)"
|
||||
if [[ -z "$json" ]]; then
|
||||
echo "${ip}: no response (app not running or port ${DEV_ADB_HTTP_PORT} closed)"
|
||||
return 1
|
||||
fi
|
||||
echo "${ip}: ok"
|
||||
adb_json_connect_lines "$json" | sed 's/^/ connect /'
|
||||
}
|
||||
47
alpha-qa-logcat.sh
Executable file
47
alpha-qa-logcat.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture paired sender/receiver logcat for alpha QA (run from repo root).
|
||||
set -euo pipefail
|
||||
|
||||
SENDER="${1:-}"
|
||||
RECEIVER="${2:-}"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
OUT_DIR="$(dirname "$0")/../alpha-logs-${STAMP}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
TAGS='ScreenCastService:* ReceiverCastService:* AndroidCast:* SenderScreenPreview:* CastProtocol:*'
|
||||
|
||||
adb_cmd() {
|
||||
local serial="$1"
|
||||
shift
|
||||
if [[ -n "$serial" ]]; then
|
||||
adb -s "$serial" "$@"
|
||||
else
|
||||
adb "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Writing logs to $OUT_DIR"
|
||||
echo "Press Ctrl+C to stop."
|
||||
|
||||
if [[ -n "$SENDER" ]]; then
|
||||
adb_cmd "$SENDER" logcat -c
|
||||
fi
|
||||
if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then
|
||||
adb_cmd "$RECEIVER" logcat -c
|
||||
fi
|
||||
|
||||
trap 'echo; echo "Stopped."' INT
|
||||
|
||||
if [[ -n "$SENDER" ]]; then
|
||||
adb_cmd "$SENDER" logcat $TAGS | tee "$OUT_DIR/sender.log" &
|
||||
PID_S=$!
|
||||
fi
|
||||
if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then
|
||||
adb_cmd "$RECEIVER" logcat $TAGS | tee "$OUT_DIR/receiver.log" &
|
||||
PID_R=$!
|
||||
elif [[ -z "$SENDER" ]]; then
|
||||
adb logcat $TAGS | tee "$OUT_DIR/combined.log"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
wait ${PID_S:-} ${PID_R:-}
|
||||
91
android-ndk.sh
Executable file
91
android-ndk.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Android NDK discovery for AndroidCast build scripts.
|
||||
# Source this file: source "$(dirname "$0")/android-ndk.sh"
|
||||
#
|
||||
# Optional env:
|
||||
# ANDROIDCAST_NDK_VERSION — pin a version under $HOME/Android/Sdk/ndk (e.g. 29.0.14206865)
|
||||
# ANDROID_NDK_HOME / ANDROID_NDK_ROOT — used when valid
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
_androidcast_sdk_base() {
|
||||
printf '%s\n' "${ANDROIDCAST_SDK_BASE:-${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}}"
|
||||
}
|
||||
|
||||
# Print absolute NDK root (directory containing ndk-build). Errors on stderr.
|
||||
androidcast_find_ndk_root() {
|
||||
local project_root="${1:-}"
|
||||
local ndk="" ndk_root ndk_ver parent pinned
|
||||
|
||||
if [[ -n "$project_root" && -f "$project_root/local.properties" ]]; then
|
||||
ndk="$(sed -n 's/^[[:space:]]*ndk\.dir=//p' "$project_root/local.properties" | tr -d '\r' | head -1)"
|
||||
ndk="${ndk%/}"
|
||||
while [[ -n "$ndk" ]]; do
|
||||
if [[ -x "$ndk/ndk-build" ]]; then
|
||||
realpath "$ndk"
|
||||
return 0
|
||||
fi
|
||||
parent="$(dirname "$ndk")"
|
||||
[[ "$parent" == "$ndk" ]] && break
|
||||
ndk="$parent"
|
||||
done
|
||||
fi
|
||||
|
||||
for ndk in "${ANDROID_NDK_HOME:-}" "${ANDROID_NDK_ROOT:-}"; do
|
||||
ndk="${ndk%/}"
|
||||
if [[ -n "$ndk" && -x "$ndk/ndk-build" ]]; then
|
||||
realpath "$ndk"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
ndk_root="$(_androidcast_sdk_base)/ndk"
|
||||
if [[ ! -d "$ndk_root" ]]; then
|
||||
echo "ERROR: No Android NDK found. Install via SDK Manager ($ndk_root) or set ANDROID_NDK_HOME." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
pinned="${ANDROIDCAST_NDK_VERSION:-}"
|
||||
if [[ -n "$pinned" ]]; then
|
||||
if [[ -d "$ndk_root/$pinned" ]]; then
|
||||
ndk_ver="$pinned"
|
||||
else
|
||||
echo "ERROR: ANDROIDCAST_NDK_VERSION=$pinned not found under $ndk_root" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
ndk_ver="$(ls -1 "$ndk_root" 2>/dev/null | sort -V | tail -n 1 || true)"
|
||||
if [[ -z "$ndk_ver" ]]; then
|
||||
echo "ERROR: No NDK versions under $ndk_root" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
ndk="$ndk_root/$ndk_ver"
|
||||
if [[ ! -x "$ndk/ndk-build" ]]; then
|
||||
echo "ERROR: ndk-build missing under $ndk" >&2
|
||||
return 1
|
||||
fi
|
||||
realpath "$ndk"
|
||||
}
|
||||
|
||||
# Print llvm prebuilt bin directory for the current host.
|
||||
androidcast_ndk_host_bin() {
|
||||
local ndk_root="${1:?NDK root required}"
|
||||
local os arch host_tag
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=darwin ;;
|
||||
*) os="$(uname -s | tr '[:upper:]' '[:lower:]')" ;;
|
||||
esac
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
x86_64 | amd64) arch=x86_64 ;;
|
||||
aarch64 | arm64) arch=aarch64 ;;
|
||||
esac
|
||||
host_tag="${os}-${arch}"
|
||||
if [[ ! -d "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin" ]]; then
|
||||
host_tag="linux-x86_64"
|
||||
fi
|
||||
realpath "$ndk_root/toolchains/llvm/prebuilt/${host_tag}/bin"
|
||||
}
|
||||
86
apply-project-headers.py
Executable file
86
apply-project-headers.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply HEADER.txt blocks to all project sources."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
REPO_ROOT,
|
||||
apply_header_to_content,
|
||||
has_valid_header,
|
||||
header_needs_identity_refresh,
|
||||
is_excluded_path,
|
||||
iter_source_files,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply project headers to source files")
|
||||
parser.add_argument("--check", action="store_true", help="Only report files missing headers")
|
||||
parser.add_argument(
|
||||
"--fix-identity",
|
||||
action="store_true",
|
||||
help="Re-apply headers where contributors lack email but git has one",
|
||||
)
|
||||
parser.add_argument("--path", type=str, default="", help="Limit to one file or directory")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = REPO_ROOT
|
||||
targets = list(iter_source_files(root))
|
||||
if args.path:
|
||||
p = Path(args.path)
|
||||
if not p.is_absolute():
|
||||
p = REPO_ROOT / p
|
||||
if p.is_file():
|
||||
targets = [p]
|
||||
else:
|
||||
targets = [f for f in iter_source_files(root) if str(f).startswith(str(p))]
|
||||
|
||||
missing = []
|
||||
identity_fix = []
|
||||
changed = 0
|
||||
skipped_vendor = 0
|
||||
for path in sorted(targets):
|
||||
if is_excluded_path(path):
|
||||
skipped_vendor += 1
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if args.check:
|
||||
if not has_valid_header(text):
|
||||
missing.append(path.relative_to(REPO_ROOT))
|
||||
elif header_needs_identity_refresh(text, path):
|
||||
identity_fix.append(path.relative_to(REPO_ROOT))
|
||||
continue
|
||||
if args.fix_identity:
|
||||
if not header_needs_identity_refresh(text, path) and has_valid_header(text):
|
||||
continue
|
||||
new_text = apply_header_to_content(path, text)
|
||||
if new_text != text:
|
||||
path.write_text(new_text, encoding="utf-8", newline="\n")
|
||||
changed += 1
|
||||
|
||||
if args.check:
|
||||
if missing:
|
||||
print("Missing headers:", len(missing))
|
||||
for m in missing[:50]:
|
||||
print(" ", m)
|
||||
if len(missing) > 50:
|
||||
print(" ...")
|
||||
if identity_fix:
|
||||
print("Identity refresh suggested:", len(identity_fix))
|
||||
for m in identity_fix[:30]:
|
||||
print(" ", m)
|
||||
if not missing and not identity_fix:
|
||||
print("All", len(targets) - skipped_vendor, "tracked files OK (third-party skipped).")
|
||||
return 0
|
||||
return 1 if missing else 0
|
||||
|
||||
note = f" (skipped {skipped_vendor} under third-party/vendor paths)" if skipped_vendor else ""
|
||||
print(f"Updated {changed} / {len(targets) - skipped_vendor} source files{note}.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15
build-all-docs-pdf.sh
Executable file
15
build-all-docs-pdf.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render docs/**/*.md to sibling .pdf, then diagram/special PDF builders.
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PDF_BUILD="$ROOT_DIR/docs/_pdf_build"
|
||||
python3 "$PDF_BUILD/build_all_docs_pdf.py"
|
||||
for extra in \
|
||||
build_git_flow_pdf.py \
|
||||
build_graphvis_pivot_pdf.py \
|
||||
build_egress_review_pdf.py; do
|
||||
if [[ -f "$PDF_BUILD/$extra" ]]; then
|
||||
python3 "$PDF_BUILD/$extra" || echo "WARN: $extra failed (optional)" >&2
|
||||
fi
|
||||
done
|
||||
echo "Done: PDFs under $ROOT_DIR/docs/ (recursive + diagram builders)"
|
||||
7
build-graphvis-pdf.sh
Normal file
7
build-graphvis-pdf.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
python3 "$ROOT_DIR/docs/_pdf_build/build_graphvis_pivot_pdf.py"
|
||||
echo "Generated: $ROOT_DIR/docs/GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
342
build-native-codecs.sh
Executable file
342
build-native-codecs.sh
Executable file
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build libvpx, libopus, and libspeex for Android ABIs → build/native/<abi>/*.a
|
||||
# CMake in ndk/ autolinks when those archives exist (ANDROIDCAST_HAVE_* = 1).
|
||||
#
|
||||
# Prerequisites:
|
||||
# bash scripts/init-third-party-submodules.sh
|
||||
# Android NDK (auto-detected via scripts/android-ndk.sh)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-native-codecs.sh arm64-v8a
|
||||
# ANDROIDCAST_LIBVPX_LIB=$PWD/build/native/arm64-v8a/libvpx.a ./gradlew assembleDebug
|
||||
#
|
||||
# Produces (when sources present): libvpx.a, libopus.a, libspeex.a under build/native/<abi>/
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ABI="${1:-arm64-v8a}"
|
||||
VPX_SRC_REAL="$ROOT/third-party/libvpx"
|
||||
PROJECT_OUT="$ROOT/build/native/$ABI"
|
||||
OUT="$PROJECT_OUT"
|
||||
BUILD_DIR="$OUT/libvpx-build"
|
||||
|
||||
if [[ ! -d "$VPX_SRC_REAL" ]]; then
|
||||
echo "ERROR: $VPX_SRC_REAL missing. Run:"
|
||||
echo " bash scripts/init-third-party-submodules.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=android-ndk.sh
|
||||
source "$ROOT/scripts/android-ndk.sh"
|
||||
if ! NDK="$(androidcast_find_ndk_root "$ROOT")"; then
|
||||
exit 1
|
||||
fi
|
||||
NDK_HOST_BIN="$(androidcast_ndk_host_bin "$NDK")"
|
||||
if [[ ! -d "$NDK_HOST_BIN" ]]; then
|
||||
echo "ERROR: NDK host toolchain bin not found under $NDK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$ABI" in
|
||||
arm64-v8a) VPX_TARGET=arm64-android-gcc ;;
|
||||
armeabi-v7a) VPX_TARGET=armv7-android-gcc ;;
|
||||
x86_64) VPX_TARGET=x86_64-android-gcc ;;
|
||||
x86) VPX_TARGET=x86-android-gcc ;;
|
||||
*)
|
||||
echo "Unsupported ABI: $ABI"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# libvpx Makefiles break on spaces in SRC_PATH_BARE.
|
||||
path_has_space() {
|
||||
case "$1" in
|
||||
*" "*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
VPX_SRC="$VPX_SRC_REAL"
|
||||
STAGING=""
|
||||
if path_has_space "$ROOT" || path_has_space "$VPX_SRC_REAL"; then
|
||||
STAGING="/tmp/androidcast-libvpx-${USER:-build}"
|
||||
VPX_SRC="$STAGING/src"
|
||||
BUILD_DIR="$STAGING/build/$ABI"
|
||||
OUT="$STAGING/out/$ABI"
|
||||
mkdir -p "$STAGING/build" "$STAGING/out"
|
||||
ln -sfn "$VPX_SRC_REAL" "$VPX_SRC"
|
||||
echo "Repo path contains spaces; using space-free staging under $STAGING"
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR" "$OUT" "$PROJECT_OUT"
|
||||
export ANDROID_NDK_HOME="$NDK"
|
||||
export ANDROID_NDK_ROOT="$NDK"
|
||||
export PATH="$NDK_HOST_BIN:$PATH"
|
||||
|
||||
# shellcheck source=native-build-cache.sh
|
||||
source "$ROOT/scripts/native-build-cache.sh"
|
||||
androidcast_apply_native_build_accelerators "$ROOT" "$ABI" "$NDK" "$NDK_HOST_BIN"
|
||||
|
||||
VPX_CONFIGURE_EXTRA=()
|
||||
APP_CFLAGS_EXTRA=""
|
||||
case "$ABI" in
|
||||
x86)
|
||||
# NDK does not infer -msse4.1/-mavx from libvpx source names; AVX-512 is unused on Android x86.
|
||||
VPX_CONFIGURE_EXTRA+=(--disable-avx512)
|
||||
APP_CFLAGS_EXTRA="-msse4.1 -mavx"
|
||||
;;
|
||||
x86_64)
|
||||
VPX_CONFIGURE_EXTRA+=(--disable-avx512)
|
||||
APP_CFLAGS_EXTRA="-msse4.1 -mavx -mavx2"
|
||||
;;
|
||||
esac
|
||||
|
||||
## common native optimization options
|
||||
APP_CFLAGS_EXTRA+=" -O2 -fdata-sections -ffunction-sections -Wl,--gc-sections -ftree-vectorize -O2 -s -funroll-loops -fomit-frame-pointer "
|
||||
|
||||
echo "Building libvpx for $ABI (target=$VPX_TARGET) ..."
|
||||
echo " source: $VPX_SRC"
|
||||
echo " build: $BUILD_DIR"
|
||||
|
||||
cd "$BUILD_DIR"
|
||||
|
||||
# Out-of-tree build fails if someone previously ran configure inside third-party/libvpx.
|
||||
if [[ -f "$VPX_SRC/vpx_config.h" ]]; then
|
||||
src_real="$(cd "$VPX_SRC" && pwd)"
|
||||
build_real="$(pwd)"
|
||||
if [[ "$src_real" != "$build_real" ]]; then
|
||||
echo "Cleaning in-tree configure artifacts from libvpx source (needed for out-of-tree build)..."
|
||||
make -C "$VPX_SRC" distclean >/dev/null 2>&1 || true
|
||||
rm -f "$VPX_SRC"/vpx_config.h "$VPX_SRC"/vpx_config.c "$VPX_SRC"/vpx_version.h \
|
||||
"$VPX_SRC"/config.mk "$VPX_SRC"/Makefile
|
||||
rm -f "$VPX_SRC"/libs-*.mk "$VPX_SRC"/vp*_rtcd.h "$VPX_SRC"/libvpx*.a 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
LIBS_MK="$BUILD_DIR/libs-${VPX_TARGET}.mk"
|
||||
need_configure=false
|
||||
if [[ ! -f Makefile ]]; then
|
||||
need_configure=true
|
||||
elif [[ ! -f "$LIBS_MK" ]] || ! grep -q "^TOOLCHAIN := ${VPX_TARGET}\$" "$BUILD_DIR/config.mk" 2>/dev/null; then
|
||||
echo "Re-configuring (stale or wrong ABI in $BUILD_DIR)..."
|
||||
make distclean >/dev/null 2>&1 || rm -f Makefile config.mk "$LIBS_MK" vpx_config.h vpx_config.c vpx_config.asm \
|
||||
vpx_version.h vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
|
||||
need_configure=true
|
||||
elif [[ "$ABI" == x86 || "$ABI" == x86_64 ]] && [[ -f "$BUILD_DIR/vpx_config.h" ]] \
|
||||
&& grep -q '#define HAVE_AVX512 1' "$BUILD_DIR/vpx_config.h"; then
|
||||
echo "Re-configuring (AVX-512 disabled for Android NDK on $ABI)..."
|
||||
make distclean >/dev/null 2>&1 || rm -f Makefile config.mk "$LIBS_MK" vpx_config.h vpx_config.c vpx_config.asm \
|
||||
vpx_version.h vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
|
||||
need_configure=true
|
||||
fi
|
||||
|
||||
if $need_configure; then
|
||||
# CC/CXX may be ccache-wrapped NDK clang (see native-build-cache.sh).
|
||||
"$VPX_SRC/configure" \
|
||||
--target="$VPX_TARGET" \
|
||||
--disable-examples \
|
||||
--disable-tools \
|
||||
--disable-unit-tests \
|
||||
--disable-webm-io \
|
||||
--enable-vp8 \
|
||||
--enable-vp9 \
|
||||
--enable-static \
|
||||
--disable-shared \
|
||||
--extra-cflags="-fPIC -O2" \
|
||||
--enable-multi-res-encoding \
|
||||
--enable-vp9-temporal-denoising \
|
||||
--enable-onthefly-bitpacking \
|
||||
--enable-error-concealment \
|
||||
--enable-realtime-only \
|
||||
--enable-postproc \
|
||||
--enable-vp9-postproc \
|
||||
--enable-multithread \
|
||||
--enable-spatial-resampling \
|
||||
--disable-debug-libs \
|
||||
--disable-docs \
|
||||
--enable-external-build \
|
||||
"${VPX_CONFIGURE_EXTRA[@]}"
|
||||
fi
|
||||
|
||||
# armeabi-v7a ndk-build runs make in ndk-app/ and needs vpx_config.asm (not required for arm64).
|
||||
make vpx_config.asm 2>/dev/null || true
|
||||
if [[ -f "$BUILD_DIR/vpx_config.asm" ]] && [[ ! -s "$BUILD_DIR/vpx_config.asm" ]]; then
|
||||
rm -f "$BUILD_DIR/vpx_config.asm"
|
||||
make vpx_config.asm
|
||||
fi
|
||||
|
||||
# RTCD headers (configure creates them; refresh if missing).
|
||||
make -j1 vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h
|
||||
|
||||
# NDK project root must not contain libvpx's top-level Makefile (ndk-build would invoke it).
|
||||
NDK_ROOT="$BUILD_DIR/ndk-app"
|
||||
JNI_DIR="$NDK_ROOT/jni"
|
||||
rm -rf "$NDK_ROOT"
|
||||
mkdir -p "$JNI_DIR"
|
||||
ln -sfn "$VPX_SRC" "$JNI_DIR/libvpx"
|
||||
|
||||
# NDK Android.mk prepends jni/ to ASM_CONVERSION; keep the configure-generated absolute path
|
||||
# in BUILD_DIR for `make vpx_config.asm`, and use a jni-only copy for ndk-build.
|
||||
LIBS_MK_NDK="$BUILD_DIR/libs-ndk-${VPX_TARGET}.mk"
|
||||
if [[ -f "$LIBS_MK" ]]; then
|
||||
cp -f "$LIBS_MK" "$LIBS_MK_NDK"
|
||||
sed -i 's|^ASM_CONVERSION=.*|ASM_CONVERSION=libvpx/build/make/ads2gas.pl|' "$LIBS_MK_NDK"
|
||||
fi
|
||||
|
||||
for f in config.mk vpx_config.c vpx_config.h vpx_config.asm vpx_version.h \
|
||||
vp8_rtcd.h vp9_rtcd.h vpx_scale_rtcd.h vpx_dsp_rtcd.h; do
|
||||
if [[ -f "$BUILD_DIR/$f" ]]; then
|
||||
ln -sfn "$BUILD_DIR/$f" "$JNI_DIR/$f"
|
||||
# ndk-build may invoke make in ndk-app/ (not jni/) for RTCD / vpx_config.asm on armeabi-v7a.
|
||||
ln -sfn "$BUILD_DIR/$f" "$NDK_ROOT/$f"
|
||||
fi
|
||||
done
|
||||
if [[ -f "$LIBS_MK_NDK" ]]; then
|
||||
ln -sfn "$LIBS_MK_NDK" "$JNI_DIR/libs-${VPX_TARGET}.mk"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "APP_ABI := $ABI"
|
||||
echo "APP_PLATFORM := android-29"
|
||||
echo "APP_STL := c++_static"
|
||||
if [[ -n "$APP_CFLAGS_EXTRA" ]]; then
|
||||
echo "APP_CFLAGS += $APP_CFLAGS_EXTRA"
|
||||
fi
|
||||
} > "$JNI_DIR/Application.mk"
|
||||
|
||||
cat > "$JNI_DIR/Android.mk" <<'EOF'
|
||||
LOCAL_PATH := $(call my-dir)
|
||||
include $(CLEAR_VARS)
|
||||
# configure outputs (RTCD headers, vpx_config.h) live in jni/ — not inside libvpx sources
|
||||
LOCAL_CFLAGS += -I$(LOCAL_PATH)
|
||||
include $(LOCAL_PATH)/libvpx/build/make/Android.mk
|
||||
EOF
|
||||
|
||||
"$NDK/ndk-build" -C "$NDK_ROOT" -j"$(nproc)"
|
||||
|
||||
LIBVPX_A="$NDK_ROOT/obj/local/$ABI/libvpx.a"
|
||||
if [[ ! -f "$LIBVPX_A" ]]; then
|
||||
echo "ERROR: expected $LIBVPX_A after ndk-build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp -f "$LIBVPX_A" "$OUT/libvpx.a"
|
||||
if [[ "$OUT" != "$PROJECT_OUT" ]]; then
|
||||
cp -f "$OUT/libvpx.a" "$PROJECT_OUT/libvpx.a"
|
||||
fi
|
||||
|
||||
androidcast_ndk_api() { echo 29; }
|
||||
|
||||
androidcast_ndk_clang() {
|
||||
local abi="$1"
|
||||
local api
|
||||
api="$(androidcast_ndk_api)"
|
||||
case "$abi" in
|
||||
arm64-v8a) echo "$NDK_HOST_BIN/aarch64-linux-android${api}-clang" ;;
|
||||
armeabi-v7a) echo "$NDK_HOST_BIN/armv7a-linux-androideabi${api}-clang" ;;
|
||||
x86_64) echo "$NDK_HOST_BIN/x86_64-linux-android${api}-clang" ;;
|
||||
x86) echo "$NDK_HOST_BIN/i686-linux-android${api}-clang" ;;
|
||||
*) echo "ERROR: unsupported ABI $abi" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
androidcast_ndk_configure_host() {
|
||||
case "$1" in
|
||||
arm64-v8a) echo aarch64-linux-android ;;
|
||||
armeabi-v7a) echo armv7a-linux-androideabi ;;
|
||||
x86_64) echo x86_64-linux-android ;;
|
||||
x86) echo i686-linux-android ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_opus_for_abi() {
|
||||
local opus_src="$ROOT/third-party/opus"
|
||||
if [[ ! -f "$opus_src/CMakeLists.txt" ]]; then
|
||||
echo "Skip libopus: $opus_src missing (bash scripts/init-third-party-submodules.sh)"
|
||||
return 0
|
||||
fi
|
||||
local opus_build="$BUILD_DIR/opus-cmake"
|
||||
rm -rf "$opus_build"
|
||||
echo "Building libopus for $ABI (CMake) ..."
|
||||
cmake -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
|
||||
-DANDROID_ABI="$ABI" \
|
||||
-DANDROID_PLATFORM="android-$(androidcast_ndk_api)" \
|
||||
-DBUILD_SHARED_LIBS=OFF \
|
||||
-DOPUS_BUILD_SHARED_LIBRARY=OFF \
|
||||
-DOPUS_BUILD_TESTING=OFF \
|
||||
-DOPUS_BUILD_PROGRAMS=OFF \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-B "$opus_build" -S "$opus_src"
|
||||
cmake --build "$opus_build" --target opus -j"$(nproc)"
|
||||
local opus_a="$opus_build/libopus.a"
|
||||
if [[ ! -f "$opus_a" ]]; then
|
||||
opus_a="$(find "$opus_build" -name 'libopus.a' | head -1)"
|
||||
fi
|
||||
if [[ ! -f "$opus_a" ]]; then
|
||||
echo "ERROR: libopus.a not found after Opus build"
|
||||
exit 1
|
||||
fi
|
||||
cp -f "$opus_a" "$PROJECT_OUT/libopus.a"
|
||||
echo "Built: $PROJECT_OUT/libopus.a (size $(ls -lah "${PROJECT_OUT}/libopus.a" | awk '{print $5}'))"
|
||||
}
|
||||
|
||||
build_speex_for_abi() {
|
||||
local speex_src_real="$ROOT/third-party/speex"
|
||||
if [[ ! -d "$speex_src_real" ]]; then
|
||||
echo "Skip libspeex: $speex_src_real missing (bash scripts/init-third-party-submodules.sh)"
|
||||
return 0
|
||||
fi
|
||||
local speex_src="$speex_src_real"
|
||||
local speex_build="$BUILD_DIR/speex-autotools"
|
||||
if path_has_space "$speex_src_real"; then
|
||||
speex_src="$STAGING/speex-src"
|
||||
ln -sfn "$speex_src_real" "$speex_src"
|
||||
fi
|
||||
if [[ ! -f "$speex_src/configure" ]]; then
|
||||
echo "Running speex autogen.sh ..."
|
||||
(cd "$speex_src" && ./autogen.sh)
|
||||
fi
|
||||
rm -rf "$speex_build"
|
||||
mkdir -p "$speex_build"
|
||||
local cc host
|
||||
cc="$(androidcast_ndk_clang "$ABI")"
|
||||
host="$(androidcast_ndk_configure_host "$ABI")"
|
||||
echo "Building libspeex for $ABI (autotools) ..."
|
||||
(
|
||||
cd "$speex_build"
|
||||
"$speex_src/configure" \
|
||||
--host="$host" \
|
||||
--prefix="$speex_build/install" \
|
||||
--disable-shared \
|
||||
--enable-static \
|
||||
--disable-binaries \
|
||||
--with-pic \
|
||||
CC="$cc" \
|
||||
CFLAGS="-fPIC -O2"
|
||||
make -j"$(nproc)"
|
||||
)
|
||||
local speex_a="$speex_build/libspeex/.libs/libspeex.a"
|
||||
if [[ ! -f "$speex_a" ]]; then
|
||||
speex_a="$(find "$speex_build" -name 'libspeex.a' | head -1)"
|
||||
fi
|
||||
if [[ ! -f "$speex_a" ]]; then
|
||||
echo "ERROR: libspeex.a not found after Speex build"
|
||||
exit 1
|
||||
fi
|
||||
cp -f "$speex_a" "$PROJECT_OUT/libspeex.a"
|
||||
echo "Built: $PROJECT_OUT/libspeex.a (size $(ls -lah "${PROJECT_OUT}/libspeex.a" | awk '{print $5}'))"
|
||||
}
|
||||
|
||||
build_opus_for_abi
|
||||
build_speex_for_abi
|
||||
|
||||
echo ""
|
||||
echo "Built: $PROJECT_OUT/libvpx.a (size $(ls -lah "${PROJECT_OUT}/libvpx.a" | awk '{print $5}'))"
|
||||
if [[ -n "$STAGING" ]]; then
|
||||
echo "(staged under $STAGING because of spaces in repo path)"
|
||||
fi
|
||||
echo "Build other ABIs as needed, e.g.:"
|
||||
echo " ./scripts/build-native-codecs.sh armeabi-v7a"
|
||||
echo "Then rebuild the APK (CMake autolinks build/native/<abi>/*.a per ABI):"
|
||||
echo " ./gradlew assembleDebug"
|
||||
7
build-reverse-ssh-proposals-pdf.sh
Executable file
7
build-reverse-ssh-proposals-pdf.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
python3 "$ROOT_DIR/docs/_pdf_build/build_reverse_ssh_proposals_pdf.py"
|
||||
echo "Generated: $ROOT_DIR/docs/20260602_REVERSE_SSH_proposals_summary.pdf"
|
||||
41
capture-vp9-fec-logs.sh
Executable file
41
capture-vp9-fec-logs.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture VP9+FEC cast logs from sender (.106) and receiver (.39).
|
||||
# Usage: ./scripts/capture-vp9-fec-logs.sh [label]
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CAP="$ROOT/.log_capture"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
LABEL="${1:-fec}"
|
||||
mkdir -p "$CAP"
|
||||
|
||||
SENDER="192.168.33.106:5555"
|
||||
RECEIVER="192.168.33.39:5555"
|
||||
|
||||
adb connect "$SENDER" >/dev/null 2>&1 || true
|
||||
adb connect "$RECEIVER" >/dev/null 2>&1 || true
|
||||
|
||||
FILTER='FEC|Fec|fec|UdpCast|StreamProtection|Protection|reasm|ReedSolomon|shard|ScreenCast|Libvpx|ReceiverCast|ReceiverSession|VideoDecoder|NativeCodec|CodecPriority|CastFanout|CastSendPump|NetworkFeedback|transportLoss|decode|keyframe|awaiting|AudioDecoder|drop'
|
||||
|
||||
echo "==> Clearing logcat buffers"
|
||||
adb -s "$SENDER" logcat -c 2>/dev/null || true
|
||||
adb -s "$RECEIVER" logcat -c 2>/dev/null || true
|
||||
|
||||
SPID=$(adb -s "$SENDER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
|
||||
RPID=$(adb -s "$RECEIVER" shell pidof com.foxx.androidcast 2>/dev/null | tr -d '\r' || true)
|
||||
echo "==> PIDs sender=$SPID receiver=$RPID"
|
||||
|
||||
echo "==> Recording 90s (start cast now)..."
|
||||
timeout 90 adb -s "$SENDER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_sender_106_${TS}.log" &
|
||||
PID_S=$!
|
||||
timeout 90 adb -s "$RECEIVER" logcat -v threadtime 2>&1 | grep -iE "$FILTER" > "$CAP/${LABEL}_receiver_39_${TS}.log" &
|
||||
PID_R=$!
|
||||
wait "$PID_S" "$PID_R" 2>/dev/null || true
|
||||
|
||||
# Snapshot dumps after live capture
|
||||
adb -s "$SENDER" logcat -d --pid="$SPID" -t 8000 > "$CAP/${LABEL}_sender_pid_${TS}.log" 2>&1 || true
|
||||
adb -s "$RECEIVER" logcat -d --pid="$RPID" -t 8000 > "$CAP/${LABEL}_receiver_pid_${TS}.log" 2>&1 || true
|
||||
adb -s "$SENDER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_sender_crash_${TS}.log" || true
|
||||
adb -s "$RECEIVER" logcat -d -b crash -t 50 2>&1 > "$CAP/${LABEL}_receiver_crash_${TS}.log" || true
|
||||
|
||||
echo "==> Saved under $CAP/${LABEL}_*_${TS}.log"
|
||||
wc -c "$CAP"/${LABEL}_*_${TS}.log 2>/dev/null | tail -6
|
||||
151
ci-build-and-publish-ota.sh
Executable file
151
ci-build-and-publish-ota.sh
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI pipeline entry: optional git checkout, native, gradle, OTA publish.
|
||||
#
|
||||
# Env:
|
||||
# GRADLE_TASK assembleDebug | assembleRelease | testDebugUnitTest | …
|
||||
# RUN_UNIT_TESTS 1 (default) | 0
|
||||
# RUN_NATIVE 1 (default) | 0
|
||||
# RUN_APK 1 (default) | 0 — set 0 for tests-only
|
||||
# OTA_BASE_URL public base for v0/ota (empty = skip OTA)
|
||||
# OTA_CHANNEL stable | staging | dev | nightly | custom name
|
||||
# AUTO_OTA 1 to emit OTA tree under OUT_DIR
|
||||
# OUT_DIR artifact root (default ./out/builds/$BUILD_ID)
|
||||
# BUILD_ID build record id
|
||||
# GIT_REF branch | tag | commit to checkout before build
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
BUILD_ID="${BUILD_ID:-local}"
|
||||
OUT_DIR="${OUT_DIR:-${ROOT}/out/builds/${BUILD_ID}}"
|
||||
GRADLE_TASK="${GRADLE_TASK:-assembleDebug}"
|
||||
RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}"
|
||||
RUN_NATIVE="${RUN_NATIVE:-1}"
|
||||
RUN_APK="${RUN_APK:-1}"
|
||||
OTA_CHANNEL="${OTA_CHANNEL:-staging}"
|
||||
AUTO_OTA="${AUTO_OTA:-0}"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
exec > >(tee -a "${OUT_DIR}/build.log") 2>&1
|
||||
|
||||
echo "==> Phase: init build_id=${BUILD_ID} channel=${OTA_CHANNEL}"
|
||||
|
||||
if [[ -n "${GIT_REF:-}" ]]; then
|
||||
echo "==> Phase: git checkout ${GIT_REF}"
|
||||
git fetch --all --tags 2>/dev/null || true
|
||||
git checkout --force "$GIT_REF"
|
||||
if [[ -n "${GIT_SHA:-}" ]]; then
|
||||
git reset --hard "$GIT_SHA"
|
||||
fi
|
||||
fi
|
||||
|
||||
export ANDROIDCAST_ROOT="$ROOT"
|
||||
export ANDROIDCAST_CCACHE="${ANDROIDCAST_CCACHE:-1}"
|
||||
GRADLE_JAVA_OPT=()
|
||||
if [[ -n "${JAVA_HOME:-}" ]]; then
|
||||
GRADLE_JAVA_OPT=(-Dorg.gradle.java.home="${JAVA_HOME}")
|
||||
fi
|
||||
# shellcheck source=android-ndk.sh
|
||||
source "$ROOT/scripts/android-ndk.sh"
|
||||
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
|
||||
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
|
||||
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
|
||||
LOCAL_PROPS="$ROOT/local.properties"
|
||||
LOCAL_PROPS_BACKUP=""
|
||||
if [[ -f "$LOCAL_PROPS" ]]; then
|
||||
LOCAL_PROPS_BACKUP="$(mktemp)"
|
||||
cp -f "$LOCAL_PROPS" "$LOCAL_PROPS_BACKUP"
|
||||
fi
|
||||
trap 'if [[ -n "${LOCAL_PROPS_BACKUP:-}" && -f "${LOCAL_PROPS_BACKUP}" ]]; then cp -f "${LOCAL_PROPS_BACKUP}" "$LOCAL_PROPS"; rm -f "${LOCAL_PROPS_BACKUP}"; else rm -f "$LOCAL_PROPS"; fi' EXIT
|
||||
printf 'sdk.dir=%s\n' "${ANDROID_SDK_ROOT//\\/\\\\}" > "$LOCAL_PROPS"
|
||||
fi
|
||||
|
||||
echo "==> Phase: third-party submodules (libvpx, opus, speex, wireguard-android)"
|
||||
bash "$ROOT/scripts/init-third-party-submodules.sh"
|
||||
|
||||
if [[ "$RUN_NATIVE" == "1" && "$RUN_APK" == "1" ]]; then
|
||||
echo "==> Phase: native codecs"
|
||||
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
|
||||
rm -rf "$ROOT/build/native"
|
||||
mkdir -p "$ROOT/build/native"
|
||||
for abi in $ABIS; do
|
||||
./scripts/build-native-codecs.sh "$abi"
|
||||
done
|
||||
else
|
||||
echo "==> Phase: native codecs (skipped)"
|
||||
fi
|
||||
|
||||
# Avoid host-specific CMake cache paths when running in Docker.
|
||||
rm -rf "$ROOT/app/.cxx" "$ROOT/app/build/.cxx" "$ROOT/app/build/intermediates/cxx"
|
||||
rm -rf "$ROOT/gradle/wireguard-tunnel/.cxx" "$ROOT/gradle/wireguard-tunnel/build" 2>/dev/null || true
|
||||
|
||||
WG_HEAD="$(git -C "$ROOT/third-party/wireguard-android" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
echo "==> Phase: wireguard-android (:tunnel) third-party/wireguard-android @ ${WG_HEAD}"
|
||||
|
||||
if [[ "$RUN_APK" == "1" ]]; then
|
||||
echo "==> Phase: gradle :tunnel + ${GRADLE_TASK}"
|
||||
if [[ "$RUN_UNIT_TESTS" == "1" ]]; then
|
||||
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}" testDebugUnitTest
|
||||
else
|
||||
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" clean :tunnel:assembleDebug "${GRADLE_TASK}"
|
||||
fi
|
||||
else
|
||||
echo "==> Phase: gradle tests-only (:tunnel + testDebugUnitTest)"
|
||||
./gradlew --no-daemon "${GRADLE_JAVA_OPT[@]}" :tunnel:assembleDebug testDebugUnitTest
|
||||
fi
|
||||
|
||||
APK=""
|
||||
if [[ "$RUN_APK" == "1" ]]; then
|
||||
for candidate in \
|
||||
"$ROOT/app/build/outputs/apk/debug/app-debug.apk" \
|
||||
"$ROOT/app/build/outputs/apk/release/app-release-unsigned.apk" \
|
||||
"$ROOT/app/build/outputs/apk/release/app-release.apk"; do
|
||||
if [[ -f "$candidate" ]]; then
|
||||
APK="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$APK" ]]; then
|
||||
echo "ERROR: no APK produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -f "$APK" "${OUT_DIR}/android_cast-latest.apk"
|
||||
echo "==> APK: ${OUT_DIR}/android_cast-latest.apk"
|
||||
fi
|
||||
|
||||
if [[ "$AUTO_OTA" == "1" && -n "${OTA_BASE_URL:-}" && -n "$APK" ]]; then
|
||||
echo "==> Phase: OTA publish channel=${OTA_CHANNEL}"
|
||||
OTA_PUBLISH="${OUT_DIR}/ota-publish"
|
||||
rm -rf "$OTA_PUBLISH"
|
||||
./scripts/generate-ota-v0.sh "$APK" "$OTA_BASE_URL" "$OTA_PUBLISH" "$OTA_CHANNEL"
|
||||
mkdir -p "${OUT_DIR}/ota"
|
||||
cp -rf "${OTA_PUBLISH}/v0" "${OUT_DIR}/ota/"
|
||||
for ch in staging dev nightly stable; do
|
||||
if [[ "$OTA_CHANNEL" == "$ch" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$OTA_CHANNEL" == "stable" || "$OTA_CHANNEL" == "prod" || "$OTA_CHANNEL" == "production" || "$OTA_CHANNEL" == "release" ]]; then
|
||||
./scripts/generate-ota-v0.sh "$APK" "$OTA_BASE_URL" "${OUT_DIR}/ota-publish-${ch}" "$ch" || true
|
||||
mkdir -p "${OUT_DIR}/ota-${ch}"
|
||||
cp -rf "${OUT_DIR}/ota-publish-${ch}/v0" "${OUT_DIR}/ota-${ch}/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
cat >"${OUT_DIR}/BUILD_INFO.json" <<EOF
|
||||
{
|
||||
"buildId": "${BUILD_ID}",
|
||||
"gradleTask": "${GRADLE_TASK}",
|
||||
"otaChannel": "${OTA_CHANNEL}",
|
||||
"apk": "android_cast-latest.apk",
|
||||
"gitSha": "$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)",
|
||||
"gitRef": "${GIT_REF:-}",
|
||||
"ciVersion": "${ANDROIDCAST_CI_VERSION:-00.01.00.1000}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "==> Phase: done"
|
||||
|
||||
# Mounted-repo Docker runs must not leave /opt/android-sdk paths in app/.cxx on the host.
|
||||
rm -rf "$ROOT/app/.cxx"
|
||||
32
ci-build.sh
Executable file
32
ci-build.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI entry: native codecs (all ABIs) + Gradle debug APK + unit tests.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
bash "$ROOT/scripts/ensure-gradle-wrapper.sh"
|
||||
export ANDROIDCAST_ROOT="$ROOT"
|
||||
export ANDROIDCAST_CCACHE="${ANDROIDCAST_CCACHE:-1}"
|
||||
export ANDROIDCAST_USE_DISTCC="${ANDROIDCAST_USE_DISTCC:-0}"
|
||||
|
||||
# shellcheck source=android-ndk.sh
|
||||
source "$ROOT/scripts/android-ndk.sh"
|
||||
export ANDROID_NDK_HOME="$(androidcast_find_ndk_root "$ROOT")"
|
||||
export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
|
||||
|
||||
echo "==> Initializing third-party git submodules"
|
||||
bash "$ROOT/scripts/init-third-party-submodules.sh"
|
||||
|
||||
ABIS="${ANDROIDCAST_CI_ABIS:-armeabi-v7a arm64-v8a x86_64}"
|
||||
echo "==> Building libvpx for: $ABIS"
|
||||
rm -rf "$ROOT/build/native"
|
||||
mkdir -p "$ROOT/build/native"
|
||||
for abi in $ABIS; do
|
||||
./scripts/build-native-codecs.sh "$abi"
|
||||
done
|
||||
|
||||
echo "==> Gradle :tunnel + assembleDebug + unit tests"
|
||||
./gradlew --no-daemon clean :tunnel:assembleDebug assembleDebug testDebugUnitTest
|
||||
|
||||
echo "==> Done: $ROOT/app/build/outputs/apk/debug/app-debug.apk"
|
||||
32
deploy-ota-staging.sh
Executable file
32
deploy-ota-staging.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rsync ./out/ota/v0 to a remote web root (staging OTA tree).
|
||||
#
|
||||
# Usage:
|
||||
# OTA_DEPLOY_TARGET=foxx@10.7.16.128:/var/www/localhost/htdocs/ \
|
||||
# ./scripts/deploy-ota-staging.sh
|
||||
#
|
||||
# Dry run:
|
||||
# OTA_DEPLOY_DRY_RUN=1 ./scripts/deploy-ota-staging.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SRC="${ROOT}/out/ota/v0"
|
||||
TARGET="${OTA_DEPLOY_TARGET:?Set OTA_DEPLOY_TARGET=user@host:/path/to/web/root}"
|
||||
|
||||
if [[ ! -d "$SRC" ]]; then
|
||||
echo "Missing ${SRC} — run ./scripts/docker-build-ota.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RSYNC_OPTS=(-av --delete)
|
||||
if [[ "${OTA_DEPLOY_DRY_RUN:-}" == "1" ]]; then
|
||||
RSYNC_OPTS+=(--dry-run -v)
|
||||
fi
|
||||
|
||||
echo "Sync ${SRC}/ -> ${TARGET}/v0/"
|
||||
rsync "${RSYNC_OPTS[@]}" "${SRC}/" "${TARGET%/}/v0/"
|
||||
|
||||
echo "Channel pointer (after deploy):"
|
||||
if [[ -f "${ROOT}/out/BUILD_INFO.json" ]]; then
|
||||
grep channelUrl "${ROOT}/out/BUILD_INFO.json" || true
|
||||
fi
|
||||
39
docker-build-ota.sh
Executable file
39
docker-build-ota.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host wrapper: build androidcast-ci image and run build + OTA staging publish.
|
||||
#
|
||||
# Usage:
|
||||
# export OTA_BASE_URL=https://apps.f0xx.org
|
||||
# ./scripts/docker-build-ota.sh
|
||||
#
|
||||
# Artifacts: ./out/android_cast-latest.apk ./out/ota/v0/ota/...
|
||||
#
|
||||
# Deploy to BE (example — adjust host/path):
|
||||
# rsync -av ./out/ota/v0/ user@be:/var/www/localhost/htdocs/v0/
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="${ANDROIDCAST_CI_IMAGE:-androidcast-ci:latest}"
|
||||
|
||||
docker build -t "$IMAGE" -f "${ROOT}/Dockerfile" "${ROOT}"
|
||||
|
||||
mkdir -p "${ROOT}/out"
|
||||
|
||||
docker run --rm \
|
||||
-v "${ROOT}:/workspace" \
|
||||
-w /workspace \
|
||||
-e OTA_BASE_URL="${OTA_BASE_URL:-}" \
|
||||
-e OTA_CHANNEL="${OTA_CHANNEL:-staging}" \
|
||||
-e GRADLE_TASK="${GRADLE_TASK:-assembleDebug}" \
|
||||
-e SKIP_NATIVE="${SKIP_NATIVE:-0}" \
|
||||
-e SKIP_TESTS="${SKIP_TESTS:-0}" \
|
||||
-e RELEASE_NOTES="${RELEASE_NOTES:-}" \
|
||||
-e OUT_DIR=/workspace/out \
|
||||
"$IMAGE" \
|
||||
./scripts/ci-build-and-publish-ota.sh
|
||||
|
||||
echo ""
|
||||
echo "Done. APK: ${ROOT}/out/android_cast-latest.apk"
|
||||
if [[ -f "${ROOT}/out/BUILD_INFO.json" ]]; then
|
||||
echo "OTA channel URL:"
|
||||
sed -n 's/.*"channelUrl": "\([^"]*\)".*/\1/p' "${ROOT}/out/BUILD_INFO.json" | head -1
|
||||
fi
|
||||
245
docker-build-runner.sh
Executable file
245
docker-build-runner.sh
Executable file
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host wrapper: build CI image + run pipeline with log capture.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/docker-build-runner.sh --build-id 42 --workdir /path/to/repo
|
||||
#
|
||||
# Env: see ci-build-and-publish-ota.sh
|
||||
# When invoked from PHP (BUILD_LOG_STDOUT_ONLY=1), log via stdout only — parent redirects to build.log.
|
||||
#
|
||||
# Parallel builds: set BUILD_ISOLATE_SOURCE=1 (default from builder) to shallow-clone into
|
||||
# $OUT_DIR/src instead of mutating the shared deploy checkout.
|
||||
# Per-build step logs: $OUT_DIR/docker-build.log, $OUT_DIR/docker-run.log (also echoed to build.log).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IMAGE="${ANDROIDCAST_CI_IMAGE:-androidcast-ci:latest}"
|
||||
CI_VERSION="${ANDROIDCAST_CI_VERSION:-00.01.00.1000}"
|
||||
BUILD_ID="${BUILD_ID:-local}"
|
||||
LOG_FILE="${BUILD_LOG_FILE:-${ROOT}/out/builds/${BUILD_ID}/build.log}"
|
||||
WORK_DIR="${BUILD_WORK_DIR:-${ROOT}}"
|
||||
OUT_DIR="${BUILD_OUT_DIR:-${WORK_DIR}/out/builds/${BUILD_ID}}"
|
||||
LOG_STDOUT_ONLY="${BUILD_LOG_STDOUT_ONLY:-0}"
|
||||
ISOLATE_SOURCE="${BUILD_ISOLATE_SOURCE:-1}"
|
||||
CLONE_DEPTH="${BUILD_GIT_DEPTH:-1}"
|
||||
SOURCE_DIR="${OUT_DIR}/src"
|
||||
DOCKER_WORKSPACE="${WORK_DIR}"
|
||||
CONTAINER_NAME="androidcast-bld-${BUILD_ID}"
|
||||
RESUME_FROM="${BUILD_RESUME_FROM:-}"
|
||||
REUSE_SRC_DIR="${BUILD_REUSE_SRC_DIR:-}"
|
||||
SSH_DEBUG="${BUILD_SSH_DEBUG:-0}"
|
||||
SSH_DEBUG_SECONDS="${BUILD_SSH_DEBUG_SECONDS:-7200}"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")" "$OUT_DIR"
|
||||
|
||||
log() {
|
||||
if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then
|
||||
echo "$@"
|
||||
else
|
||||
echo "$@" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
run_logged() {
|
||||
if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then
|
||||
"$@"
|
||||
else
|
||||
"$@" 2>&1 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Mirror command output to build.log (stdout) and a per-build step file for parallel debugging.
|
||||
run_step() {
|
||||
local step_name="$1"
|
||||
shift
|
||||
local step_log="${OUT_DIR}/${step_name}.log"
|
||||
log "[runner] step ${step_name} -> ${step_log}"
|
||||
"$@" 2>&1 | tee -a "${step_log}"
|
||||
}
|
||||
|
||||
on_runner_err() {
|
||||
local ec=$?
|
||||
log "[runner] ERROR: command failed (exit ${ec}) at ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "${CONTAINER_NAME}"; then
|
||||
log "[runner] docker logs ${CONTAINER_NAME} (last 100 lines):"
|
||||
docker logs --tail 100 "${CONTAINER_NAME}" 2>&1 | while IFS= read -r line; do log "$line"; done
|
||||
fi
|
||||
fi
|
||||
echo "${ec}" > "${OUT_DIR}/runner.exit"
|
||||
exit "${ec}"
|
||||
}
|
||||
|
||||
on_runner_exit() {
|
||||
local ec=$?
|
||||
echo "${ec}" > "${OUT_DIR}/runner.exit"
|
||||
}
|
||||
|
||||
trap on_runner_err ERR
|
||||
trap on_runner_exit EXIT
|
||||
|
||||
# Git 2.35+ refuses repos owned by another uid (PHP-FPM nginx vs deploy user).
|
||||
git_safe() {
|
||||
git -c safe.directory="${WORK_DIR}" "$@"
|
||||
}
|
||||
|
||||
resolve_git_remote() {
|
||||
if [[ -n "${GIT_REMOTE:-}" ]]; then
|
||||
echo "$GIT_REMOTE"
|
||||
return
|
||||
fi
|
||||
if [[ -d "${WORK_DIR}/.git" ]]; then
|
||||
git_safe -C "${WORK_DIR}" remote get-url origin 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_isolated_source() {
|
||||
local ref="${GIT_REF:-}"
|
||||
local remote sha
|
||||
remote="$(resolve_git_remote)"
|
||||
if [[ -z "$ref" || -z "$remote" ]]; then
|
||||
log "[runner] isolate: skip (need GIT_REF and origin remote)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "${SOURCE_DIR}"
|
||||
mkdir -p "${SOURCE_DIR}"
|
||||
|
||||
log "[runner] isolate: shallow clone depth=${CLONE_DEPTH} ref=${ref} remote=${remote} -> ${SOURCE_DIR}"
|
||||
if git clone --depth "${CLONE_DEPTH}" --recurse-submodules --shallow-submodules \
|
||||
--single-branch --branch "${ref}" "${remote}" "${SOURCE_DIR}"; then
|
||||
:
|
||||
else
|
||||
log "[runner] isolate: branch clone failed; clone default branch then checkout ${ref}"
|
||||
run_step git-clone git clone --depth "${CLONE_DEPTH}" --recurse-submodules --shallow-submodules \
|
||||
"${remote}" "${SOURCE_DIR}"
|
||||
run_step git-fetch git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" fetch --depth "${CLONE_DEPTH}" origin "${ref}"
|
||||
run_step git-checkout git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${ref}"
|
||||
fi
|
||||
|
||||
if [[ -n "${GIT_SHA:-}" ]]; then
|
||||
log "[runner] isolate: checkout pin GIT_SHA=${GIT_SHA}"
|
||||
run_step git-pin git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${GIT_SHA}"
|
||||
fi
|
||||
|
||||
run_step git-rev-parse git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" rev-parse HEAD
|
||||
DOCKER_WORKSPACE="${SOURCE_DIR}"
|
||||
}
|
||||
|
||||
sync_shared_checkout() {
|
||||
if [[ -z "${GIT_REF:-}" ]] || [[ ! -d "${WORK_DIR}/.git" ]]; then
|
||||
return 0
|
||||
fi
|
||||
log "[runner] syncing shared checkout ref=${GIT_REF}"
|
||||
run_logged git_safe -C "${WORK_DIR}" fetch origin --prune || true
|
||||
run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_REF}"
|
||||
run_logged git_safe -C "${WORK_DIR}" pull --rebase origin "${GIT_REF}" || true
|
||||
if [[ -n "${GIT_SHA:-}" ]]; then
|
||||
run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_SHA}"
|
||||
fi
|
||||
run_logged git_safe -C "${WORK_DIR}" rev-parse HEAD
|
||||
}
|
||||
|
||||
should_skip_git() {
|
||||
[[ "$RESUME_FROM" == "docker-build" || "$RESUME_FROM" == "docker-run" ]]
|
||||
}
|
||||
|
||||
should_skip_docker_build() {
|
||||
[[ "$RESUME_FROM" == "docker-run" ]] && docker image inspect "$IMAGE" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
reuse_source_tree() {
|
||||
if [[ -n "$REUSE_SRC_DIR" && -d "${REUSE_SRC_DIR}/.git" ]]; then
|
||||
log "[runner] reuse: source tree from ${REUSE_SRC_DIR}"
|
||||
rm -rf "${SOURCE_DIR}"
|
||||
mkdir -p "${SOURCE_DIR}"
|
||||
cp -a "${REUSE_SRC_DIR}/." "${SOURCE_DIR}/"
|
||||
DOCKER_WORKSPACE="${SOURCE_DIR}"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_git_step() {
|
||||
if [[ "$ISOLATE_SOURCE" == "1" ]]; then
|
||||
prepare_isolated_source
|
||||
else
|
||||
sync_shared_checkout
|
||||
DOCKER_WORKSPACE="${WORK_DIR}"
|
||||
fi
|
||||
}
|
||||
|
||||
start_ssh_debug_container() {
|
||||
log "[runner] SSH debug: starting container ${CONTAINER_NAME} (${SSH_DEBUG_SECONDS}s)"
|
||||
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
|
||||
run_step docker-run-ssh docker run -d --name "${CONTAINER_NAME}" \
|
||||
-v "${DOCKER_WORKSPACE}:/workspace" \
|
||||
-v "${ROOT}/out:/workspace/out" \
|
||||
-w /workspace \
|
||||
-e BUILD_ID="${BUILD_ID}" \
|
||||
-e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \
|
||||
"$IMAGE" \
|
||||
sleep "${SSH_DEBUG_SECONDS}"
|
||||
printf '%s\n' 'ssh_debug' > "${OUT_DIR}/runner.mode"
|
||||
local host
|
||||
host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo builder)"
|
||||
log "[runner] SSH debug: session open on ${host}"
|
||||
log "[runner] SSH debug: ssh <you>@${host}"
|
||||
log "[runner] SSH debug: docker exec -it ${CONTAINER_NAME} bash"
|
||||
log "[runner] SSH debug: workspace is /workspace inside the container"
|
||||
log "[runner] finished build_id=${BUILD_ID} (ssh debug — stop build or wait ${SSH_DEBUG_SECONDS}s)"
|
||||
}
|
||||
|
||||
log "[runner] image=${IMAGE} ci_version=${CI_VERSION} build_id=${BUILD_ID} container=${CONTAINER_NAME}"
|
||||
log "[runner] workdir=${WORK_DIR} out=${OUT_DIR} isolate=${ISOLATE_SOURCE} resume=${RESUME_FROM:-full}"
|
||||
|
||||
if should_skip_git; then
|
||||
if reuse_source_tree; then
|
||||
log "[runner] resume: skipped git (reused source)"
|
||||
elif [[ "$ISOLATE_SOURCE" != "1" && -d "${WORK_DIR}/.git" ]]; then
|
||||
DOCKER_WORKSPACE="${WORK_DIR}"
|
||||
log "[runner] resume: skipped git (shared workdir ${WORK_DIR})"
|
||||
else
|
||||
log "[runner] resume: no reusable source; running git step"
|
||||
run_git_step
|
||||
fi
|
||||
else
|
||||
run_git_step
|
||||
fi
|
||||
|
||||
log "[runner] docker workspace=${DOCKER_WORKSPACE}"
|
||||
|
||||
if should_skip_docker_build; then
|
||||
log "[runner] resume: skipped docker-build (image ${IMAGE} exists)"
|
||||
else
|
||||
run_step docker-build docker build \
|
||||
--build-arg ANDROIDCAST_CI_VERSION="${CI_VERSION}" \
|
||||
-t "$IMAGE" \
|
||||
-f "${ROOT}/Dockerfile" \
|
||||
"${ROOT}"
|
||||
fi
|
||||
|
||||
if [[ "$SSH_DEBUG" == "1" ]]; then
|
||||
start_ssh_debug_container
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_step docker-run docker run --rm --name "${CONTAINER_NAME}" \
|
||||
-v "${DOCKER_WORKSPACE}:/workspace" \
|
||||
-v "${ROOT}/out:/workspace/out" \
|
||||
-w /workspace \
|
||||
-e BUILD_ID="${BUILD_ID}" \
|
||||
-e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \
|
||||
-e OTA_BASE_URL="${OTA_BASE_URL:-}" \
|
||||
-e OTA_CHANNEL="${OTA_CHANNEL:-staging}" \
|
||||
-e GRADLE_TASK="${GRADLE_TASK:-assembleDebug}" \
|
||||
-e RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}" \
|
||||
-e RUN_NATIVE="${RUN_NATIVE:-1}" \
|
||||
-e AUTO_OTA="${AUTO_OTA:-0}" \
|
||||
-e GIT_REF="${GIT_REF:-}" \
|
||||
-e GIT_SHA="${GIT_SHA:-}" \
|
||||
-e RELEASE_NOTES="${RELEASE_NOTES:-}" \
|
||||
"$IMAGE" \
|
||||
./scripts/ci-build-and-publish-ota.sh
|
||||
|
||||
log "[runner] finished build_id=${BUILD_ID}"
|
||||
16
ensure-gradle-wrapper.sh
Executable file
16
ensure-gradle-wrapper.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ensure gradle/wrapper/* exists (often missing when .gitignore hid gradle/ or after git clean).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
JAR="$ROOT/gradle/wrapper/gradle-wrapper.jar"
|
||||
PROPS="$ROOT/gradle/wrapper/gradle-wrapper.properties"
|
||||
|
||||
if [[ -f "$JAR" && -f "$PROPS" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ERROR: Gradle wrapper missing at gradle/wrapper/" >&2
|
||||
echo " expected: gradle-wrapper.jar + gradle-wrapper.properties" >&2
|
||||
echo " fix: cd \"$ROOT\" && gradle wrapper --gradle-version 8.9" >&2
|
||||
echo " note: .gitignore must NOT ignore gradle/wrapper/ (see repo .gitignore)" >&2
|
||||
exit 1
|
||||
307
generate-docs-toc.py
Normal file
307
generate-docs-toc.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Insert/update ## Table of contents in all docs/*.md (GitHub-style anchor links)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCS = ROOT / "docs"
|
||||
|
||||
TOC_START = "<!-- toc -->"
|
||||
TOC_END = "<!-- /toc -->"
|
||||
TOC_HEADING = "## Table of contents"
|
||||
INDEX_NAME = "README.md"
|
||||
|
||||
# One-line blurbs for docs/README.md
|
||||
DOC_BLURBS: dict[str, str] = {
|
||||
"README.md": "Master index of all documentation (this file).",
|
||||
"INFRA.md": "Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs.",
|
||||
"GIT_FLOW.md": "Branches (`master` / `next`), release, hotfix, dual remote.",
|
||||
"ROADMAP.md": "Implementation roadmap, alpha definition, deferred work.",
|
||||
"ALPHA.md": "Alpha feature freeze, QA sign-off, defaults.",
|
||||
"ALPHA_SOAK.md": "Short LAN soak checklist for demo validation.",
|
||||
"BUILD_DEPLOY.md": "Docker CI, staging OTA, builder console deploy.",
|
||||
"OTA.md": "OTA channel layout v0, manifests, MQTT, publish flow.",
|
||||
"CRASH_REPORTER.md": "Anonymous crash upload (app + backend).",
|
||||
"OPEN_API.md": "Crash ingest API design, triage tags, heuristics.",
|
||||
"TICKETS_ROADMAP.md": "Tickets console, Gitea, attachments, agent queue.",
|
||||
"COMMERCIAL.md": "Developer-gated ads / IAP toggles.",
|
||||
"PRO_AAR.md": "cast-core / cast-pro AAR split and Play Billing.",
|
||||
"USB_HDMI_CAST.md": "Roadmap D/E: HDMI awareness, USB-tether transport.",
|
||||
"GRAFANA_vs_others_graphvis_pivot.md": "Custom graphs vs Grafana decision memo.",
|
||||
"20260602_REVERSE_SSH_proposals_summary.md": "On-demand remote access (WG/SSH/file API) proposals.",
|
||||
"20260607-2FA-email-mobile-auth-flow.md": "Email forwarding + registration/2FA (alpha must-have).",
|
||||
"20260608_ALPHA_PRIORITIES.md": "**Master priority plan** (0.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 <!-- /toc -->
|
||||
while i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip().startswith("**Documentation index:**"):
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
continue
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def find_insert_index(lines: list[str]) -> int:
|
||||
"""Index to insert TOC block (before first ## section)."""
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"^## (?!Table of contents)", line):
|
||||
return i
|
||||
return len(lines)
|
||||
|
||||
|
||||
def process_file(path: Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines(keepends=True)
|
||||
raw = [ln.rstrip("\n") for ln in lines]
|
||||
raw = strip_existing_toc(raw)
|
||||
headings = collect_headings(raw)
|
||||
if not headings and path.name != INDEX_NAME:
|
||||
return False
|
||||
toc = build_toc_block(headings, doc_name=path.name)
|
||||
idx = find_insert_index(raw)
|
||||
# Avoid duplicate --- before TOC
|
||||
if idx > 0 and raw[idx - 1].strip() == "---":
|
||||
new_lines = raw[: idx - 1] + toc + ["---", ""] + raw[idx:]
|
||||
else:
|
||||
sep = ["---", ""] if idx < len(raw) and raw[idx].startswith("##") else []
|
||||
new_lines = raw[:idx] + toc + sep + raw[idx:]
|
||||
new_text = "\n".join(new_lines) + "\n"
|
||||
if new_text != text.replace("\r\n", "\n"):
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_index_md() -> str:
|
||||
md_files = sorted(p for p in DOCS.glob("*.md") if p.name != INDEX_NAME)
|
||||
lines = [
|
||||
"# Android Cast — documentation index",
|
||||
"",
|
||||
"Click a link to open a guide. Section links jump within the document (GitHub, Cursor, and most Markdown previews).",
|
||||
"",
|
||||
TOC_HEADING,
|
||||
"",
|
||||
TOC_START,
|
||||
"- [All documents](#all-documents)",
|
||||
"- [Specifications](#specifications)",
|
||||
"- [Design reviews](#design-reviews)",
|
||||
"- [By topic](#by-topic)",
|
||||
"- [Agent bootstrap](#agent-bootstrap)",
|
||||
TOC_END,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## All documents",
|
||||
"",
|
||||
"| Document | Summary |",
|
||||
"|----------|---------|",
|
||||
]
|
||||
for p in md_files:
|
||||
name = p.name
|
||||
blurb = DOC_BLURBS.get(name, "")
|
||||
lines.append(f"| [{name}]({name}) | {blurb} |")
|
||||
|
||||
spec_files = sorted(SPEC_DIR.glob("*.md")) if SPEC_DIR.is_dir() else []
|
||||
dr_files = sorted(DR_DIR.glob("*.md")) if DR_DIR.is_dir() else []
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Specifications",
|
||||
"",
|
||||
"| Document | Summary |",
|
||||
"|----------|---------|",
|
||||
]
|
||||
if spec_files:
|
||||
for p in spec_files:
|
||||
rel = f"specs/{p.name}"
|
||||
blurb = DOC_BLURBS.get(rel, "")
|
||||
lines.append(f"| [{p.name}]({rel}) | {blurb} |")
|
||||
else:
|
||||
lines.append("| _(none yet)_ | |")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Design reviews",
|
||||
"",
|
||||
"| Document | Summary |",
|
||||
"|----------|---------|",
|
||||
]
|
||||
if dr_files:
|
||||
for p in dr_files:
|
||||
rel = f"DRs/{p.name}"
|
||||
blurb = DOC_BLURBS.get(rel, "")
|
||||
lines.append(f"| [{p.name}]({rel}) | {blurb} |")
|
||||
else:
|
||||
lines.append("| _(none yet)_ | |")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## By topic",
|
||||
"",
|
||||
"### Infrastructure and deploy",
|
||||
"",
|
||||
f"- [INFRA.md](INFRA.md) — FE/BE topology, nginx, ports ([§1 Topology](INFRA.md#1-topology))",
|
||||
f"- [BUILD_DEPLOY.md](BUILD_DEPLOY.md) — Docker CI, builder ([§Docker CI](BUILD_DEPLOY.md#docker-ci))",
|
||||
f"- [OTA.md](OTA.md) — OTA v0 layout ([§URL layout](OTA.md#url-layout))",
|
||||
"",
|
||||
"### Development process",
|
||||
"",
|
||||
f"- [GIT_FLOW.md](GIT_FLOW.md) — `master` / `next` ([§2 Branches](GIT_FLOW.md#2-branches))",
|
||||
f"- [ROADMAP.md](ROADMAP.md) — milestones ([§Alpha definition](ROADMAP.md#alpha-definition))",
|
||||
"",
|
||||
"### Alpha and QA",
|
||||
"",
|
||||
f"- [ALPHA.md](ALPHA.md) — freeze and sign-off ([§Alpha scope](ALPHA.md#alpha-scope-what-we-ship))",
|
||||
f"- [ALPHA_SOAK.md](ALPHA_SOAK.md) — LAN soak ([§Happy path](ALPHA_SOAK.md#happy-path-15-min))",
|
||||
"",
|
||||
"### Backend apps",
|
||||
"",
|
||||
f"- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload",
|
||||
f"- [OPEN_API.md](OPEN_API.md) — ingest API ([§Upload API](OPEN_API.md#upload-api-post-apiv1uploadphp))",
|
||||
f"- [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md) — tickets + Gitea",
|
||||
f"- [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) — graphs pivot",
|
||||
f"- [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md) — remote access ([§Rev. 3 Q&A](20260602_REVERSE_SSH_proposals_summary.md#rev-3--wg-vs-ssh-qa--bastion-deep-dive))",
|
||||
f"- [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md) — URL shortener SPEC ([§REST API](specs/20100611_3_url_shortener.md#6-rest-api))",
|
||||
f"- [DRs/20100611_3_url_shortener.md](DRs/20100611_3_url_shortener.md) — URL shortener DR ([§Resolved open questions](DRs/20100611_3_url_shortener.md#2-resolved-open-questions))",
|
||||
"",
|
||||
"### Product and codecs",
|
||||
"",
|
||||
f"- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP",
|
||||
f"- [PRO_AAR.md](PRO_AAR.md) — Pro AAR split",
|
||||
f"- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether",
|
||||
f"- [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Agent bootstrap",
|
||||
"",
|
||||
"After a context reset, read in order:",
|
||||
"",
|
||||
"1. [INFRA.md](INFRA.md) — topology (no `:8089` for androidcast)",
|
||||
"2. [GIT_FLOW.md](GIT_FLOW.md) — branch rules",
|
||||
"3. Task-specific: [BUILD_DEPLOY.md](BUILD_DEPLOY.md), [TICKETS_ROADMAP.md](TICKETS_ROADMAP.md), [20260602_REVERSE_SSH_proposals_summary.md](20260602_REVERSE_SSH_proposals_summary.md), etc.",
|
||||
"",
|
||||
"Repo root [AGENTS.md](../AGENTS.md) points here.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## Maintaining TOCs",
|
||||
"",
|
||||
"Section links use GitHub/Cursor-style heading anchors. After adding or renaming `##` / `###` headings, run:",
|
||||
"",
|
||||
"```bash",
|
||||
"python3 scripts/generate-docs-toc.py",
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
index_path = DOCS / INDEX_NAME
|
||||
index_path.write_text(build_index_md(), encoding="utf-8")
|
||||
print(f"Wrote {index_path}")
|
||||
|
||||
changed = []
|
||||
toc_paths: list[Path] = sorted(DOCS.glob("*.md"))
|
||||
if SPEC_DIR.is_dir():
|
||||
toc_paths.extend(sorted(SPEC_DIR.glob("*.md")))
|
||||
if DR_DIR.is_dir():
|
||||
toc_paths.extend(sorted(DR_DIR.glob("*.md")))
|
||||
for path in toc_paths:
|
||||
if process_file(path):
|
||||
changed.append(str(path.relative_to(DOCS)))
|
||||
print(f"Updated TOC: {path.relative_to(DOCS)}")
|
||||
|
||||
print(f"Done. {len(changed)} file(s) updated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
generate-ota-v0.sh
Executable file
147
generate-ota-v0.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate v0 OTA artifacts for one APK (stdout = channel stable.json).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/generate-ota-v0.sh path/to/app-release.apk https://host[:port] [out-dir] [channel]
|
||||
#
|
||||
# channel — JSON file under v0/ota/channel/ (default: stable). Use staging for QA.
|
||||
# RELEASE_NOTES — optional env, embedded in manifest.
|
||||
#
|
||||
# channel — JSON under v0/ota/channel/ (stable, staging, dev, nightly, or custom)
|
||||
# Writes under out-dir (default: ./ota-publish):
|
||||
# v0/ota/channel/<channel>.json
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_manifest.json
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB_sign.json
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otapkg
|
||||
# v0/ota/00/00.MM/00.MM.mm/android_cast_00.MM.mm.BB.otabundle.zip (STORE zip)
|
||||
set -euo pipefail
|
||||
|
||||
apk="${1:?APK path required}"
|
||||
host_base="${2:?Base URL required, e.g. https://192.168.1.1:8080}"
|
||||
out_dir="${3:-ota-publish}"
|
||||
channel="${4:-stable}"
|
||||
release_notes="${RELEASE_NOTES:-}"
|
||||
json_notes() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -n --arg n "$release_notes" '$n'
|
||||
else
|
||||
local esc="${release_notes//\\/\\\\}"
|
||||
esc="${esc//\"/\\\"}"
|
||||
esc="${esc//$'\n'/\\n}"
|
||||
esc="${esc//$'\r'/}"
|
||||
printf '"%s"' "$esc"
|
||||
fi
|
||||
}
|
||||
NOTES_JSON="$(json_notes)"
|
||||
|
||||
if [[ ! -f "$apk" ]]; then
|
||||
echo "APK not found: $apk" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pad2() { printf '%02d' "$1"; }
|
||||
|
||||
major="" minor="" build=""
|
||||
if command -v aapt >/dev/null 2>&1; then
|
||||
badging="$(aapt dump badging "$apk" 2>/dev/null || true)"
|
||||
version_code="$(echo "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p" | head -1)"
|
||||
version_name="$(echo "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p" | head -1)"
|
||||
if [[ -n "${version_code:-}" ]]; then
|
||||
major=$((version_code / 10000))
|
||||
minor=$(((version_code / 100) % 100))
|
||||
build=$((version_code % 100))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${major:-}" ]]; then
|
||||
echo "Could not read versionCode from APK (install build-tools / aapt)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
maj_p="$(pad2 "$major")"
|
||||
min_p="$(pad2 "$minor")"
|
||||
bld_p="$(pad2 "$build")"
|
||||
ver_p="${maj_p}.${min_p}.${bld_p}"
|
||||
base="${host_base%/}/v0/ota/00/${maj_p}/${ver_p}"
|
||||
artifact_base="${base}/android_cast_${ver_p}.${bld_p}"
|
||||
apk_url="${artifact_base}.otapkg"
|
||||
sign_url="${artifact_base}_sign.json"
|
||||
manifest_url="${artifact_base}_manifest.json"
|
||||
bundle_url="${artifact_base}.otabundle.zip"
|
||||
sha256="$(sha256sum "$apk" | awk '{print $1}')"
|
||||
apk_size="$(stat -c%s "$apk" 2>/dev/null || wc -c <"$apk" | tr -d ' ')"
|
||||
|
||||
rel_root="${out_dir}/v0/ota"
|
||||
rel_dir="${rel_root}/00/${maj_p}/${ver_p}"
|
||||
mkdir -p "${rel_dir}" "${rel_root}/channel"
|
||||
|
||||
pkg_name="android_cast_${ver_p}.${bld_p}.otapkg"
|
||||
cp -f "$apk" "${rel_dir}/${pkg_name}"
|
||||
|
||||
sign_name="android_cast_${ver_p}.${bld_p}_sign.json"
|
||||
manifest_name="android_cast_${ver_p}.${bld_p}_manifest.json"
|
||||
bundle_name="android_cast_${ver_p}.${bld_p}.otabundle.zip"
|
||||
|
||||
cat >"${rel_dir}/${sign_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"sha256": "${sha256}"
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"${rel_dir}/${manifest_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": ${major},
|
||||
"minor": ${minor},
|
||||
"build": ${build},
|
||||
"versionName": "${version_name:-${major}.${minor}.${build}}",
|
||||
"apkUrl": "${apk_url}",
|
||||
"signUrl": "${sign_url}",
|
||||
"sizeBytes": ${apk_size},
|
||||
"mandatory": false,
|
||||
"releaseNotes": ${NOTES_JSON}
|
||||
}
|
||||
EOF
|
||||
|
||||
bundle_stage="$(mktemp -d)"
|
||||
trap 'rm -rf "${bundle_stage}"' EXIT
|
||||
cp -f "${rel_dir}/${manifest_name}" "${bundle_stage}/manifest.json"
|
||||
cp -f "${rel_dir}/${sign_name}" "${bundle_stage}/sign.json"
|
||||
cp -f "$apk" "${bundle_stage}/package.apk"
|
||||
# -0 = STORED (no re-compression of the APK)
|
||||
( cd "${bundle_stage}" && zip -0 -q -X "${rel_dir}/${bundle_name}" manifest.json sign.json package.apk )
|
||||
bundle_sha256="$(sha256sum "${rel_dir}/${bundle_name}" | awk '{print $1}')"
|
||||
bundle_size="$(stat -c%s "${rel_dir}/${bundle_name}" 2>/dev/null || wc -c <"${rel_dir}/${bundle_name}" | tr -d ' ')"
|
||||
|
||||
# Patch manifest with bundle fields (jq optional; use temp file + heredoc rewrite)
|
||||
cat >"${rel_dir}/${manifest_name}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"major": ${major},
|
||||
"minor": ${minor},
|
||||
"build": ${build},
|
||||
"versionName": "${version_name:-${major}.${minor}.${build}}",
|
||||
"apkUrl": "${apk_url}",
|
||||
"signUrl": "${sign_url}",
|
||||
"sizeBytes": ${apk_size},
|
||||
"bundleUrl": "${bundle_url}",
|
||||
"bundleSha256": "${bundle_sha256}",
|
||||
"bundleSizeBytes": ${bundle_size},
|
||||
"mandatory": false,
|
||||
"releaseNotes": ${NOTES_JSON}
|
||||
}
|
||||
EOF
|
||||
|
||||
channel_file="${rel_root}/channel/${channel}.json"
|
||||
cat >"${channel_file}" <<EOF
|
||||
{
|
||||
"schema": "v0",
|
||||
"manifestUrl": "${manifest_url}"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Published v0 OTA under ${out_dir}/v0/ota" >&2
|
||||
echo "Channel: ${host_base%/}/v0/ota/channel/${channel}.json" >&2
|
||||
echo "Bundle: ${bundle_url} (${bundle_size} bytes)" >&2
|
||||
cat "${channel_file}"
|
||||
18
grant-dev-adb-perms.sh
Executable file
18
grant-dev-adb-perms.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time grants while device is connected (usually USB). Debug package only.
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE="${1:-com.foxx.androidcast}"
|
||||
|
||||
if ! adb get-state >/dev/null 2>&1; then
|
||||
echo "ERROR: no adb device in 'device' state" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Granting dev adb permissions to ${PACKAGE}"
|
||||
adb shell pm grant "${PACKAGE}" android.permission.WRITE_SECURE_SETTINGS
|
||||
adb shell pm grant "${PACKAGE}" android.permission.DUMP
|
||||
adb shell am broadcast \
|
||||
-a com.foxx.androidcast.dev.REFRESH_ADB_WIFI \
|
||||
-n "${PACKAGE}/.dev.DevAdbWifiReceiver"
|
||||
echo "OK. Re-check: curl -s http://DEVICE:5039/adb.json | grep secure_settings_granted"
|
||||
52
header-post-commit.py
Executable file
52
header-post-commit.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-commit: set Commit: SHA on sources touched in HEAD (amends commit once if needed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
REPO_ROOT,
|
||||
files_in_git_commit,
|
||||
git_head_sha,
|
||||
hook_skip_requested,
|
||||
update_header_commit,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hook_skip_requested():
|
||||
return 0
|
||||
|
||||
sha = git_head_sha()
|
||||
if not sha:
|
||||
return 0
|
||||
|
||||
touched: list[Path] = []
|
||||
for path in files_in_git_commit("HEAD"):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
new_text = update_header_commit(text, path, sha)
|
||||
if new_text != text:
|
||||
path.write_text(new_text, encoding="utf-8", newline="\n")
|
||||
touched.append(path)
|
||||
|
||||
if not touched:
|
||||
return 0
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HEADER_HOOK_SKIP"] = "1"
|
||||
for path in touched:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT, env=env)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "--amend", "--no-edit", "--no-verify"],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
90
header-pre-commit.py
Executable file
90
header-pre-commit.py
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Git pre-commit: validate headers on new files; refresh Updated/Digest on staged edits."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
REPO_ROOT,
|
||||
SOURCE_SUFFIXES,
|
||||
apply_header_to_content,
|
||||
has_valid_header,
|
||||
header_needs_identity_refresh,
|
||||
hook_skip_requested,
|
||||
is_excluded_path,
|
||||
resolve_header_contributor,
|
||||
update_header_timestamps,
|
||||
)
|
||||
|
||||
|
||||
def staged_files() -> list[Path]:
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
)
|
||||
files = []
|
||||
for line in out.splitlines():
|
||||
p = REPO_ROOT / line.strip()
|
||||
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if is_excluded_path(p):
|
||||
continue
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
def is_new_file(path: Path) -> bool:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
try:
|
||||
subprocess.check_call(
|
||||
["git", "diff", "--cached", "--diff-filter=A", "--quiet", "--", rel],
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
return False
|
||||
except subprocess.CalledProcessError:
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hook_skip_requested():
|
||||
return 0
|
||||
|
||||
errors = []
|
||||
contributor = resolve_header_contributor()
|
||||
for path in staged_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if is_new_file(path):
|
||||
if not has_valid_header(text):
|
||||
errors.append(
|
||||
f"{path.relative_to(REPO_ROOT)}: new file must include HEADER.txt block"
|
||||
)
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
else:
|
||||
if has_valid_header(text):
|
||||
if header_needs_identity_refresh(text, path):
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
else:
|
||||
text = apply_header_to_content(path, text)
|
||||
text = update_header_timestamps(text, path, contributor)
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
subprocess.check_call(["git", "add", "--", rel], cwd=REPO_ROOT)
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(e, file=sys.stderr)
|
||||
print(
|
||||
"Run: python3 scripts/apply-project-headers.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
638
header_lib.py
Executable file
638
header_lib.py
Executable file
@@ -0,0 +1,638 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared logic for project source headers (see HEADER.txt)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HEADER_TEMPLATE = REPO_ROOT / "HEADER.txt"
|
||||
BANNER_START = "/*********************************************************************"
|
||||
BANNER_END = "**********************************************************************/"
|
||||
MARKER_CREATED = "Created at:"
|
||||
MARKER_UPDATED = "Updated at:"
|
||||
MARKER_COMMIT = "Commit:"
|
||||
MARKER_DIGEST = "Digest:"
|
||||
MARKER_CONTRIBUTORS = "Contributors:"
|
||||
MARKER_CLASS = re.compile(r"^\s*\*?\s*(class|interface|enum)\s+(\w+)", re.I)
|
||||
|
||||
ASSISTANT_NAME = "Cursor Agent"
|
||||
ASSISTANT_CONTRIBUTOR = ASSISTANT_NAME
|
||||
IDENTITY_IN_ANGLE = re.compile(r"^(.+?)\s*<([^>]+)>\s*$")
|
||||
IGNORED_GIT_AUTHORS = frozenset({"Not Committed Yet", "Unknown"})
|
||||
SOURCE_SUFFIXES = {".java", ".aidl", ".c", ".cpp", ".h", ".php"}
|
||||
|
||||
SKIP_DIR_NAMES = {
|
||||
".git",
|
||||
".gradle",
|
||||
"build",
|
||||
".cxx",
|
||||
"third-party",
|
||||
"node_modules",
|
||||
".idea",
|
||||
}
|
||||
EXCLUDED_PATH_MARKERS = frozenset({"third-party"})
|
||||
|
||||
|
||||
def is_excluded_path(path: Path) -> bool:
|
||||
"""Never apply headers under third-party/ or other vendor trees."""
|
||||
resolved = path.resolve()
|
||||
root = REPO_ROOT.resolve()
|
||||
try:
|
||||
rel_parts = resolved.relative_to(root).parts
|
||||
except ValueError:
|
||||
return True
|
||||
if any(part in EXCLUDED_PATH_MARKERS for part in rel_parts):
|
||||
return True
|
||||
return any(part in SKIP_DIR_NAMES for part in rel_parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMeta:
|
||||
rel_path: str
|
||||
filename: str
|
||||
package_line: str
|
||||
class_line: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_contributor: str
|
||||
contributors_block: str
|
||||
language: str
|
||||
commit_sha: str = ""
|
||||
|
||||
|
||||
def iter_source_files(root: Path) -> Iterable[Path]:
|
||||
for path in root.rglob("*"):
|
||||
if path.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if is_excluded_path(path):
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
_GLOBAL_IDENTITY_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
def global_git_identity_map() -> dict[str, str]:
|
||||
"""Repo-wide author -> email from git shortlog."""
|
||||
global _GLOBAL_IDENTITY_MAP
|
||||
if _GLOBAL_IDENTITY_MAP is not None:
|
||||
return _GLOBAL_IDENTITY_MAP
|
||||
emails: dict[str, str] = {}
|
||||
out = run_git(["shortlog", "-sne", "--all"], REPO_ROOT)
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# "29\tAnton Afanasyeu <a@b.c>" or "29 Anton ..."
|
||||
if "\t" in line:
|
||||
_, rest = line.split("\t", 1)
|
||||
else:
|
||||
parts = line.split(None, 1)
|
||||
rest = parts[1] if len(parts) > 1 else line
|
||||
name, mail = parse_identity(rest.strip())
|
||||
if is_tracked_author(name) and mail:
|
||||
emails.setdefault(name, mail)
|
||||
_GLOBAL_IDENTITY_MAP = emails
|
||||
return emails
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path) -> str:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
return out.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_email(raw: str) -> str:
|
||||
e = raw.strip()
|
||||
if e.startswith("<") and e.endswith(">"):
|
||||
e = e[1:-1].strip()
|
||||
if not e or e.lower() in {"unknown", "none", "n/a"}:
|
||||
return ""
|
||||
if "@" not in e:
|
||||
return ""
|
||||
return e
|
||||
|
||||
|
||||
def parse_identity(text: str) -> tuple[str, str]:
|
||||
"""Split 'Name <email>' or plain name."""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return "", ""
|
||||
m = IDENTITY_IN_ANGLE.match(t)
|
||||
if m:
|
||||
return m.group(1).strip(), normalize_email(m.group(2))
|
||||
return t, ""
|
||||
|
||||
|
||||
def is_tracked_author(name: str) -> bool:
|
||||
n = name.strip()
|
||||
return bool(n) and n not in IGNORED_GIT_AUTHORS
|
||||
|
||||
|
||||
def format_identity(name: str, email: str = "") -> str:
|
||||
name = name.strip()
|
||||
email = normalize_email(email)
|
||||
if not name:
|
||||
return ASSISTANT_NAME
|
||||
if email:
|
||||
return f"{name} <{email}>"
|
||||
return name
|
||||
|
||||
|
||||
def hook_skip_requested() -> bool:
|
||||
import os
|
||||
|
||||
return os.environ.get("HEADER_HOOK_SKIP", "").strip() in {"1", "true", "yes"}
|
||||
|
||||
|
||||
def git_head_sha() -> str:
|
||||
return run_git(["rev-parse", "HEAD"], REPO_ROOT)
|
||||
|
||||
|
||||
def git_last_commit_sha_for_file(path: Path) -> str:
|
||||
rel = repo_relative(path)
|
||||
return run_git(["log", "-1", "--format=%H", "--", rel], REPO_ROOT)
|
||||
|
||||
|
||||
def parse_commit_from_header(text: str) -> str:
|
||||
m = re.search(r"\* Commit:\s*(\S+)", text)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def files_in_git_commit(commit: str = "HEAD") -> list[Path]:
|
||||
out = run_git(
|
||||
["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
|
||||
REPO_ROOT,
|
||||
)
|
||||
paths: list[Path] = []
|
||||
for line in out.splitlines():
|
||||
rel = line.strip()
|
||||
if not rel:
|
||||
continue
|
||||
p = REPO_ROOT / rel
|
||||
if not p.is_file() or p.suffix.lower() not in SOURCE_SUFFIXES:
|
||||
continue
|
||||
if is_excluded_path(p):
|
||||
continue
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
def format_git_date(raw: str) -> str:
|
||||
if not raw:
|
||||
return datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
try:
|
||||
# git %ai: 2026-05-20 15:04:12 +0300
|
||||
dt = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S %z")
|
||||
return dt.strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
def git_identity_map(path: Path, body: str = "") -> dict[str, str]:
|
||||
"""Map git author name -> email (best effort per file)."""
|
||||
rel = repo_relative(path)
|
||||
emails: dict[str, str] = {}
|
||||
log = run_git(["log", "--follow", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
|
||||
for line in log.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
an, ae = line.split("\t", 1)
|
||||
an, ae = an.strip(), normalize_email(ae)
|
||||
if is_tracked_author(an) and ae:
|
||||
emails.setdefault(an, ae)
|
||||
|
||||
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
|
||||
author_name: str | None = None
|
||||
for line in blame.splitlines():
|
||||
if line.startswith("author "):
|
||||
author_name = line[7:].strip()
|
||||
elif line.startswith("author-mail "):
|
||||
mail = normalize_email(line[12:])
|
||||
if is_tracked_author(author_name or "") and mail:
|
||||
emails.setdefault(author_name, mail)
|
||||
author_name = None
|
||||
elif line.startswith("\t"):
|
||||
author_name = None
|
||||
|
||||
for tag in re.finditer(
|
||||
r"@author\s+(.+)$|/\*\s*@author\s+(.+?)\s*\*/",
|
||||
body,
|
||||
re.MULTILINE | re.IGNORECASE,
|
||||
):
|
||||
fragment = (tag.group(1) or tag.group(2) or "").strip()
|
||||
name, mail = parse_identity(fragment)
|
||||
if name and mail:
|
||||
emails.setdefault(name, mail)
|
||||
for name, mail in global_git_identity_map().items():
|
||||
emails.setdefault(name, mail)
|
||||
return emails
|
||||
|
||||
|
||||
def git_created_updated(path: Path, body: str = "") -> tuple[str, str, str]:
|
||||
rel = repo_relative(path)
|
||||
created_raw = run_git(
|
||||
["log", "--follow", "--diff-filter=A", "--format=%ai", "-1", "--", rel],
|
||||
REPO_ROOT,
|
||||
)
|
||||
updated_raw = run_git(["log", "-1", "--format=%ai", "--", rel], REPO_ROOT)
|
||||
last = run_git(["log", "-1", "--format=%an%x09%ae", "--", rel], REPO_ROOT)
|
||||
if "\t" in last:
|
||||
an, ae = last.split("\t", 1)
|
||||
author = format_identity(an, ae)
|
||||
elif last:
|
||||
id_map = git_identity_map(path, body)
|
||||
author = format_identity(last, id_map.get(last, ""))
|
||||
else:
|
||||
author = ASSISTANT_NAME
|
||||
return (
|
||||
format_git_date(created_raw),
|
||||
format_git_date(updated_raw or created_raw),
|
||||
author,
|
||||
)
|
||||
|
||||
|
||||
def git_config_identity() -> tuple[str, str]:
|
||||
name = run_git(["config", "user.name"], REPO_ROOT)
|
||||
email = run_git(["config", "user.email"], REPO_ROOT)
|
||||
return name, email
|
||||
|
||||
|
||||
def git_config_identity_formatted() -> str:
|
||||
name, email = git_config_identity()
|
||||
if name:
|
||||
return format_identity(name, email)
|
||||
return ASSISTANT_NAME
|
||||
|
||||
|
||||
def resolve_header_contributor() -> str:
|
||||
"""
|
||||
Pre-commit / env contributor: HEADER_CONTRIBUTOR or HEADER_CONTRIBUTOR_EMAIL
|
||||
enriched with git user.email when the name has no address.
|
||||
"""
|
||||
import os
|
||||
|
||||
raw = os.environ.get("HEADER_CONTRIBUTOR", "").strip()
|
||||
email_extra = normalize_email(os.environ.get("HEADER_CONTRIBUTOR_EMAIL", ""))
|
||||
cfg_name, cfg_email = git_config_identity()
|
||||
|
||||
if raw:
|
||||
name, email = parse_identity(raw)
|
||||
if not name:
|
||||
name = raw
|
||||
if not email:
|
||||
email = email_extra or cfg_email or global_git_identity_map().get(name, "")
|
||||
return format_identity(name, email)
|
||||
|
||||
if email_extra and cfg_name:
|
||||
return format_identity(cfg_name, email_extra)
|
||||
return git_config_identity_formatted()
|
||||
|
||||
|
||||
def git_contributors(path: Path, body_line_count: int, body: str = "") -> list[tuple[str, int, int]]:
|
||||
rel = repo_relative(path)
|
||||
id_map = git_identity_map(path, body)
|
||||
commit_counts: dict[str, int] = {}
|
||||
log = run_git(["log", "--follow", "--format=%an", "--", rel], REPO_ROOT)
|
||||
for line in log.splitlines():
|
||||
name = line.strip()
|
||||
if is_tracked_author(name):
|
||||
commit_counts[name] = commit_counts.get(name, 0) + 1
|
||||
|
||||
line_counts: dict[str, int] = {}
|
||||
blame = run_git(["blame", "--line-porcelain", "--", rel], REPO_ROOT)
|
||||
author: str | None = None
|
||||
for line in blame.splitlines():
|
||||
if line.startswith("author "):
|
||||
author = line[7:].strip()
|
||||
elif line.startswith("\t") and author and is_tracked_author(author):
|
||||
line_counts[author] = line_counts.get(author, 0) + 1
|
||||
|
||||
names = {n for n in (set(commit_counts) | set(line_counts)) if is_tracked_author(n)}
|
||||
rows: list[tuple[str, int, int]] = []
|
||||
for name in names:
|
||||
label = format_identity(name, id_map.get(name, ""))
|
||||
rows.append((label, commit_counts.get(name, 0), line_counts.get(name, 0)))
|
||||
rows.sort(key=lambda r: (-r[1], -r[2], r[0].lower()))
|
||||
if not rows:
|
||||
rows = [(ASSISTANT_NAME, 0, body_line_count)]
|
||||
return rows
|
||||
|
||||
|
||||
def contributors_block(rows: list[tuple[str, int, int]], include_assistant: bool = True) -> str:
|
||||
lines = []
|
||||
for label, commits, nlines in rows:
|
||||
name, _ = parse_identity(label)
|
||||
if name == ASSISTANT_NAME:
|
||||
continue
|
||||
lines.append(f" * - {label} ({commits} commits, {nlines} lines)")
|
||||
if include_assistant:
|
||||
lines.append(f" * - {ASSISTANT_NAME} (project assistant)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def detect_class_line(body: str, suffix: str) -> str:
|
||||
if suffix not in {".java", ".aidl", ".cpp", ".php"}:
|
||||
return ""
|
||||
patterns = [
|
||||
("class", r"^\s*public\s+(?:final\s+)?class\s+(\w+)"),
|
||||
("class", r"^\s*class\s+(\w+)"),
|
||||
("interface", r"^\s*public\s+interface\s+(\w+)"),
|
||||
("interface", r"^\s*interface\s+(\w+)"),
|
||||
("enum", r"^\s*public\s+enum\s+(\w+)"),
|
||||
("enum", r"^\s*enum\s+(\w+)"),
|
||||
]
|
||||
for line in body.splitlines():
|
||||
for kind, pat in patterns:
|
||||
m = re.match(pat, line)
|
||||
if m:
|
||||
return f" * {kind} {m.group(1)}"
|
||||
return ""
|
||||
|
||||
|
||||
def package_line_for_file(source_text: str, rel_path: str, suffix: str) -> str:
|
||||
for line in source_text.splitlines():
|
||||
if suffix in {".java", ".aidl"}:
|
||||
m = re.match(r"^\s*package\s+([\w.]+)\s*;", line)
|
||||
if m:
|
||||
return f"package {m.group(1)};"
|
||||
return f"/* package {rel_path} */"
|
||||
|
||||
|
||||
def strip_existing_header_php(text: str) -> str:
|
||||
"""Remove <?php, block-comment header(s), and closing ?> before real body."""
|
||||
work = text.lstrip("\ufeff")
|
||||
while True:
|
||||
changed = False
|
||||
w = work.lstrip()
|
||||
if w.startswith("<?php"):
|
||||
work = w[5:].lstrip("\n")
|
||||
changed = True
|
||||
w = work.lstrip()
|
||||
if w.startswith("?>"):
|
||||
work = w[2:].lstrip("\n")
|
||||
changed = True
|
||||
w = work.lstrip()
|
||||
if w.startswith("/*") or w.startswith("/*****"):
|
||||
close = w.find("*/")
|
||||
if close >= 0:
|
||||
work = w[close + 2 :].lstrip("\n")
|
||||
changed = True
|
||||
else:
|
||||
break
|
||||
if not changed:
|
||||
break
|
||||
return work.lstrip("\n")
|
||||
|
||||
|
||||
def php_body_is_markup(body: str) -> bool:
|
||||
"""True when body is HTML/templates after the commented header (needs ?>)."""
|
||||
s = body.lstrip()
|
||||
return bool(s) and s[0] == "<"
|
||||
|
||||
|
||||
def strip_existing_header(text: str, suffix: str) -> tuple[str, str]:
|
||||
"""Returns (body without header/package, preserved leading e.g. <?php)."""
|
||||
if suffix == ".php":
|
||||
return strip_existing_header_php(text), ""
|
||||
|
||||
leading = ""
|
||||
work = text
|
||||
|
||||
lines = work.splitlines(keepends=True)
|
||||
i = 0
|
||||
if i < len(lines) and (
|
||||
lines[i].startswith("package ")
|
||||
or lines[i].startswith("/* package ")
|
||||
):
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
|
||||
if i < len(lines) and BANNER_START in lines[i]:
|
||||
while i < len(lines) and BANNER_END not in lines[i]:
|
||||
i += 1
|
||||
if i < len(lines):
|
||||
i += 1
|
||||
if i < len(lines) and lines[i].strip() == "":
|
||||
i += 1
|
||||
|
||||
body = "".join(lines[i:]).lstrip("\n")
|
||||
if body.startswith("package "):
|
||||
first_nl = body.find("\n")
|
||||
if first_nl >= 0:
|
||||
body = body[first_nl + 1 :].lstrip("\n")
|
||||
return body, leading
|
||||
|
||||
|
||||
def repo_relative(path: Path) -> str:
|
||||
root = REPO_ROOT.resolve()
|
||||
try:
|
||||
return path.resolve().relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def build_meta(path: Path, body: str, source_text: str = "") -> FileMeta:
|
||||
rel = repo_relative(path)
|
||||
suffix = path.suffix.lower()
|
||||
full_source = source_text if source_text else body
|
||||
created, updated, author = git_created_updated(path, body)
|
||||
rows = git_contributors(path, max(1, len(body.splitlines())), body)
|
||||
if not any(parse_identity(r[0])[0] == ASSISTANT_NAME for r in rows):
|
||||
rows.append((ASSISTANT_NAME, 0, 0))
|
||||
|
||||
pkg = package_line_for_file(full_source, rel, suffix)
|
||||
lang = "java" if suffix in {".java", ".aidl"} else ("php" if suffix == ".php" else "c")
|
||||
|
||||
class_line = detect_class_line(body, suffix)
|
||||
commit_sha = parse_commit_from_header(source_text) or git_last_commit_sha_for_file(path)
|
||||
return FileMeta(
|
||||
rel_path=rel,
|
||||
filename=path.name,
|
||||
package_line=pkg,
|
||||
class_line=class_line,
|
||||
created_at=created,
|
||||
updated_at=updated,
|
||||
last_contributor=author,
|
||||
contributors_block=contributors_block(rows),
|
||||
language=lang,
|
||||
commit_sha=commit_sha,
|
||||
)
|
||||
|
||||
|
||||
def _inner_header_lines(meta: FileMeta, *, for_digest: bool) -> list[str]:
|
||||
"""Banner lines shared by PHP and C-style headers."""
|
||||
lines = [
|
||||
f" * {meta.filename}",
|
||||
f" * {MARKER_CREATED} {meta.created_at}",
|
||||
f" * {MARKER_UPDATED} {meta.updated_at} by {meta.last_contributor}",
|
||||
]
|
||||
if not for_digest and meta.commit_sha:
|
||||
lines.append(f" * {MARKER_COMMIT} {meta.commit_sha}")
|
||||
if meta.class_line:
|
||||
lines.append(meta.class_line)
|
||||
lines.append(f" * {MARKER_CONTRIBUTORS}")
|
||||
lines.extend(meta.contributors_block.splitlines())
|
||||
return lines
|
||||
|
||||
|
||||
def render_php_header(meta: FileMeta, digest_hex: str | None, *, for_digest: bool = False) -> str:
|
||||
"""
|
||||
PHP: header is one block comment inside <?php ... ?> so the engine ignores it.
|
||||
No nested '/***** ... */' banners (inner */ would break the comment).
|
||||
"""
|
||||
inner = [f" * package {meta.rel_path}"]
|
||||
inner.extend(_inner_header_lines(meta, for_digest=for_digest))
|
||||
if digest_hex and not for_digest:
|
||||
inner.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
|
||||
return "<?php\n/*\n" + "\n".join(inner) + "\n */\n"
|
||||
|
||||
|
||||
def render_header(
|
||||
meta: FileMeta, digest_hex: str | None, suffix: str = "", *, for_digest: bool = False) -> str:
|
||||
if suffix == ".php" or meta.language == "php":
|
||||
return render_php_header(meta, digest_hex, for_digest=for_digest)
|
||||
lines = [
|
||||
meta.package_line,
|
||||
"",
|
||||
BANNER_START,
|
||||
*_inner_header_lines(meta, for_digest=for_digest),
|
||||
]
|
||||
if digest_hex and not for_digest:
|
||||
lines.append(f" * {MARKER_DIGEST} SHA256\t{digest_hex}")
|
||||
lines.append(BANNER_END)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def wrap_php_header_and_body(header: str, body: str) -> str:
|
||||
"""
|
||||
Pure PHP: <?php /* header */ code... (declare stays first statement after <?php).
|
||||
Templates: <?php /* header */ ?> then HTML.
|
||||
"""
|
||||
body = body.lstrip("\n")
|
||||
if body.startswith("<?php"):
|
||||
body = body[5:].lstrip("\n")
|
||||
if php_body_is_markup(body):
|
||||
return header + "?>\n" + body
|
||||
return header + body
|
||||
|
||||
|
||||
def compute_digest(header_prefix: str, body: str) -> str:
|
||||
"""Hash file body + header without Digest/Commit lines."""
|
||||
payload = header_prefix + body
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def finalize_header(meta: FileMeta, body: str, suffix: str, leading: str = "") -> str:
|
||||
prefix_for_digest = render_header(meta, None, suffix, for_digest=True)
|
||||
digest = compute_digest(prefix_for_digest, body)
|
||||
prefix = render_header(meta, digest, suffix, for_digest=False)
|
||||
if suffix == ".php":
|
||||
return wrap_php_header_and_body(prefix, body)
|
||||
return leading + prefix + body
|
||||
|
||||
|
||||
def apply_header_to_content(path: Path, text: str, update_only: bool = False) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
body, leading = strip_existing_header(text, suffix)
|
||||
meta = build_meta(path, body, text)
|
||||
|
||||
if update_only and MARKER_CREATED in text:
|
||||
m = re.search(r"\* Created at: (.+)", text)
|
||||
if m:
|
||||
meta.created_at = m.group(1).strip()
|
||||
|
||||
return finalize_header(meta, body, suffix, leading)
|
||||
|
||||
|
||||
def has_valid_header(text: str) -> bool:
|
||||
if MARKER_CREATED not in text:
|
||||
return False
|
||||
if text.lstrip().startswith("<?php") and "/*" in text[:800]:
|
||||
return "*/" in text[:1200]
|
||||
return BANNER_START in text and BANNER_END in text
|
||||
|
||||
|
||||
def header_needs_identity_refresh(text: str, path: Path) -> bool:
|
||||
"""True when a known author appears without email but git has one."""
|
||||
if not has_valid_header(text):
|
||||
return False
|
||||
suffix = path.suffix.lower()
|
||||
body, _ = strip_existing_header(text, suffix)
|
||||
id_map = git_identity_map(path, body)
|
||||
|
||||
updated = re.search(r"\* Updated at: .+ by (.+)$", text, re.MULTILINE)
|
||||
if updated:
|
||||
name, email = parse_identity(updated.group(1).strip())
|
||||
if name != ASSISTANT_NAME and not email and id_map.get(name):
|
||||
return True
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("* - "):
|
||||
continue
|
||||
label = stripped[7:].split(" (", 1)[0].strip()
|
||||
name, email = parse_identity(label)
|
||||
if name != ASSISTANT_NAME and not email and id_map.get(name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_header_timestamps(
|
||||
text: str, path: Path, contributor: str | None = None, *, touch_commit: bool = False) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
body, leading = strip_existing_header(text, suffix)
|
||||
if contributor:
|
||||
who = contributor
|
||||
else:
|
||||
who = resolve_header_contributor()
|
||||
if who == ASSISTANT_NAME:
|
||||
who = git_created_updated(path, body)[2]
|
||||
updated_line = datetime.now().astimezone().strftime("%a %d %b %Y %H:%M:%S %z")
|
||||
m_created = re.search(r"\* Created at: (.+)", text)
|
||||
created = m_created.group(1).strip() if m_created else git_created_updated(path)[0]
|
||||
|
||||
meta = build_meta(path, body, text)
|
||||
meta.created_at = created
|
||||
meta.updated_at = updated_line
|
||||
meta.last_contributor = who
|
||||
if touch_commit:
|
||||
meta.commit_sha = git_head_sha()
|
||||
else:
|
||||
meta.commit_sha = parse_commit_from_header(text)
|
||||
|
||||
return finalize_header(meta, body, suffix, leading)
|
||||
|
||||
|
||||
def update_header_commit(text: str, path: Path, commit_sha: str) -> str:
|
||||
"""Set Commit: line after post-commit; recomputes Digest (Commit excluded from hash)."""
|
||||
if parse_commit_from_header(text) == commit_sha:
|
||||
return text
|
||||
suffix = path.suffix.lower()
|
||||
body, leading = strip_existing_header(text, suffix)
|
||||
meta = build_meta(path, body, text)
|
||||
m_created = re.search(r"\* Created at: (.+)", text)
|
||||
m_updated = re.search(r"\* Updated at: (.+?) by (.+)$", text, re.MULTILINE)
|
||||
if m_created:
|
||||
meta.created_at = m_created.group(1).strip()
|
||||
if m_updated:
|
||||
meta.updated_at = m_updated.group(1).strip()
|
||||
meta.last_contributor = m_updated.group(2).strip()
|
||||
meta.commit_sha = commit_sha
|
||||
return finalize_header(meta, body, suffix, leading)
|
||||
110
init-third-party-submodules.sh
Executable file
110
init-third-party-submodules.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync and initialize all third-party/ git submodules from .gitmodules (recursive nested).
|
||||
# Used by rebuild.sh, ci-build.sh, and deploy paths — no network except submodule fetch/update.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
ONLY_PATH=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only)
|
||||
ONLY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--only third-party/wireguard-android]"
|
||||
echo " Initializes every path in .gitmodules under third-party/ (recursive)."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f .gitmodules ]]; then
|
||||
echo "error: .gitmodules not found in $ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t SUB_PATHS < <(git config -f .gitmodules --get-regexp '^submodule\..*\.path$' \
|
||||
| awk '{ print $2 }' | grep '^third-party/' | sort -u)
|
||||
|
||||
if [[ ${#SUB_PATHS[@]} -eq 0 ]]; then
|
||||
echo "error: no third-party paths in .gitmodules" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY_PATH" ]]; then
|
||||
SUB_PATHS=("$ONLY_PATH")
|
||||
fi
|
||||
|
||||
ensure_gitlink() {
|
||||
local path="$1"
|
||||
local name="$path"
|
||||
if git cat-file -e "HEAD:${path}" 2>/dev/null \
|
||||
|| git ls-files --stage "$path" | grep -q '^160000'; then
|
||||
return 0
|
||||
fi
|
||||
local url
|
||||
url="$(git config -f .gitmodules --get "submodule.${name}.url" || true)"
|
||||
if [[ -z "$url" ]]; then
|
||||
echo "error: $path listed in .gitmodules but no gitlink in tree and no url" >&2
|
||||
return 1
|
||||
fi
|
||||
local sha
|
||||
sha="$(git ls-remote "$url" HEAD | awk 'NR==1 {print $1}')"
|
||||
if [[ -z "$sha" ]]; then
|
||||
echo "error: could not resolve HEAD for $url (needed to register missing gitlink)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "WARN: registering missing gitlink for $path at $sha (commit this gitlink)" >&2
|
||||
git update-index --add --cacheinfo 160000,"$sha","$path"
|
||||
}
|
||||
|
||||
validate_path() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
third-party/wireguard-android)
|
||||
[[ -d "$path/tunnel/src/main/java" ]] \
|
||||
&& [[ -f "$path/tunnel/tools/wireguard-tools/src/config.c" ]] \
|
||||
&& [[ -f "$path/tunnel/tools/elf-cleaner/elf-cleaner.cpp" || -f "$path/tunnel/tools/elf-cleaner/main.cpp" ]]
|
||||
;;
|
||||
third-party/libvpx)
|
||||
[[ -d "$path" ]]
|
||||
;;
|
||||
third-party/opus|third-party/speex)
|
||||
[[ -f "$path/configure" || -f "$path/CMakeLists.txt" || -d "$path/include" ]]
|
||||
;;
|
||||
*)
|
||||
[[ -d "$path" ]]
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "==> third-party submodules: sync + update --init --recursive"
|
||||
for path in "${SUB_PATHS[@]}"; do
|
||||
if ! grep -q "$path" .gitmodules 2>/dev/null; then
|
||||
echo "error: $path not in .gitmodules" >&2
|
||||
exit 1
|
||||
fi
|
||||
ensure_gitlink "$path"
|
||||
git submodule sync "$path"
|
||||
git submodule update --init --recursive "$path"
|
||||
if ! validate_path "$path"; then
|
||||
echo "error: $path incomplete after submodule update (nested submodules missing?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
short="$(git -C "$path" rev-parse --short HEAD 2>/dev/null || echo '?')"
|
||||
echo " $path @ $short"
|
||||
done
|
||||
|
||||
if [[ -d third-party/wireguard-android/tunnel ]]; then
|
||||
wg_tools="$(git -C third-party/wireguard-android/tunnel/tools/wireguard-tools rev-parse --short HEAD 2>/dev/null || echo '?')"
|
||||
echo " third-party/wireguard-android/tunnel/tools/wireguard-tools @ $wg_tools"
|
||||
fi
|
||||
|
||||
echo "==> third-party submodules ready"
|
||||
5
init-wireguard-submodule.sh
Executable file
5
init-wireguard-submodule.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Back-compat wrapper — wireguard-android is one of the third-party submodules.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
exec "$ROOT/scripts/init-third-party-submodules.sh" --only third-party/wireguard-android
|
||||
11
install-git-hooks.sh
Executable file
11
install-git-hooks.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install repo git hooks (header pre-commit + post-commit).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
git -C "$ROOT" config core.hooksPath .githooks
|
||||
chmod +x "$ROOT/.githooks/pre-commit"
|
||||
chmod +x "$ROOT/.githooks/post-commit"
|
||||
chmod +x "$ROOT/scripts/header-pre-commit.py"
|
||||
chmod +x "$ROOT/scripts/header-post-commit.py"
|
||||
chmod +x "$ROOT/scripts/apply-project-headers.py"
|
||||
echo "Installed hooks via core.hooksPath=.githooks (pre-commit + post-commit)"
|
||||
124
native-build-cache.sh
Normal file
124
native-build-cache.sh
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Host build accelerators for native codecs (libvpx / NDK). Source, do not execute.
|
||||
#
|
||||
# ccache (default: auto-detect):
|
||||
# ANDROIDCAST_CCACHE=auto|1|0
|
||||
# CCACHE_DIR — default: <repo>/build/.ccache
|
||||
#
|
||||
# distcc (off by default; experimental for libvpx configure/make only, not ndk-build):
|
||||
# ANDROIDCAST_USE_DISTCC=1
|
||||
# DISTCC_HOSTS=localhost/8 192.168.1.10/16
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
[[ -n "${ANDROIDCAST_NATIVE_CACHE_SOURCED:-}" ]] && return 0
|
||||
ANDROIDCAST_NATIVE_CACHE_SOURCED=1
|
||||
|
||||
androidcast_project_root() {
|
||||
if [[ -n "${ANDROIDCAST_ROOT:-}" ]]; then
|
||||
printf '%s\n' "$ANDROIDCAST_ROOT"
|
||||
return 0
|
||||
fi
|
||||
local here
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
printf '%s\n' "$here"
|
||||
}
|
||||
|
||||
androidcast_ccache_wanted() {
|
||||
case "${ANDROIDCAST_CCACHE:-auto}" in
|
||||
0 | false | FALSE | no | NO | off | OFF) return 1 ;;
|
||||
1 | true | TRUE | yes | YES | on | ON) return 0 ;;
|
||||
auto | AUTO | "")
|
||||
command -v ccache >/dev/null 2>&1
|
||||
;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
androidcast_ndk_clang_basename() {
|
||||
case "${1:-}" in
|
||||
arm64-v8a) echo aarch64-linux-android29-clang ;;
|
||||
armeabi-v7a) echo armv7a-linux-androideabi29-clang ;;
|
||||
x86_64) echo x86_64-linux-android29-clang ;;
|
||||
x86) echo i686-linux-android29-clang ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
androidcast_ndk_clangxx_basename() {
|
||||
case "${1:-}" in
|
||||
arm64-v8a) echo aarch64-linux-android29-clang++ ;;
|
||||
armeabi-v7a) echo armv7a-linux-androideabi29-clang++ ;;
|
||||
x86_64) echo x86_64-linux-android29-clang++ ;;
|
||||
x86) echo i686-linux-android29-clang++ ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Configure ccache + optional distcc; set CC/CXX/NDK_CCACHE for libvpx + ndk-build.
|
||||
# Args: project_root abi ndk_root ndk_host_bin
|
||||
androidcast_apply_native_build_accelerators() {
|
||||
local root="${1:?root}"
|
||||
local abi="${2:?abi}"
|
||||
local ndk_bin="${4:?ndk_host_bin}"
|
||||
local clang clangxx
|
||||
clang="$(androidcast_ndk_clang_basename "$abi")"
|
||||
clangxx="$(androidcast_ndk_clangxx_basename "$abi")"
|
||||
if [[ -z "$clang" || ! -x "$ndk_bin/$clang" ]]; then
|
||||
echo "WARN: NDK clang not found for $abi under $ndk_bin" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local prefix=""
|
||||
local ccache_on=false
|
||||
local distcc_on=false
|
||||
|
||||
if androidcast_ccache_wanted && command -v ccache >/dev/null 2>&1; then
|
||||
ccache_on=true
|
||||
export CCACHE_DIR="${CCACHE_DIR:-$root/build/.ccache}"
|
||||
mkdir -p "$CCACHE_DIR"
|
||||
export CCACHE_BASEDIR="$root"
|
||||
export CCACHE_COMPRESS="${CCACHE_COMPRESS:-1}"
|
||||
export CCACHE_COMPILERCHECK="${CCACHE_COMPILERCHECK:-content}"
|
||||
export CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-5G}"
|
||||
export NDK_CCACHE="${NDK_CCACHE:-ccache}"
|
||||
prefix="ccache "
|
||||
echo "ccache: ON (dir=$CCACHE_DIR, NDK_CCACHE=$NDK_CCACHE)"
|
||||
ccache -s 2>/dev/null | sed 's/^/ /' || true
|
||||
elif androidcast_ccache_wanted; then
|
||||
echo "ccache: requested but not installed (install ccache or ANDROIDCAST_CCACHE=0)"
|
||||
else
|
||||
echo "ccache: off (ANDROIDCAST_CCACHE=${ANDROIDCAST_CCACHE:-auto})"
|
||||
fi
|
||||
|
||||
case "${ANDROIDCAST_USE_DISTCC:-0}" in
|
||||
1 | true | TRUE | yes | YES | on | ON)
|
||||
if command -v distcc >/dev/null 2>&1 && [[ -n "${DISTCC_HOSTS:-}" ]]; then
|
||||
distcc_on=true
|
||||
export DISTCC_VERBOSE="${DISTCC_VERBOSE:-0}"
|
||||
prefix="distcc ${prefix}"
|
||||
echo "distcc: ON (experimental, libvpx make/configure only; hosts=$DISTCC_HOSTS)"
|
||||
elif command -v distcc >/dev/null 2>&1; then
|
||||
echo "distcc: ANDROIDCAST_USE_DISTCC=1 but DISTCC_HOSTS empty — skipped" >&2
|
||||
else
|
||||
echo "distcc: requested but distcc not installed — skipped" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "distcc: off (set ANDROIDCAST_USE_DISTCC=1 and DISTCC_HOSTS to try)"
|
||||
;;
|
||||
esac
|
||||
|
||||
export CC="${prefix}${ndk_bin}/${clang}"
|
||||
if [[ -n "$clangxx" && -x "$ndk_bin/$clangxx" ]]; then
|
||||
export CXX="${prefix}${ndk_bin}/${clangxx}"
|
||||
else
|
||||
export CXX="${prefix}${ndk_bin}/${clang}"
|
||||
fi
|
||||
export AR="${ndk_bin}/llvm-ar"
|
||||
export RANLIB="${ndk_bin}/llvm-ranlib"
|
||||
|
||||
# libvpx configure reads these when cross-compiling.
|
||||
export AS="$CC"
|
||||
export LD="${ndk_bin}/ld.lld"
|
||||
}
|
||||
196
simulate-vpn-crashes.sh
Executable file
196
simulate-vpn-crashes.sh
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
# VPN crash simulation lab — device (adb) and/or BE upload verification.
|
||||
#
|
||||
# Modes:
|
||||
# MODE=device — trigger VpnCrashSimulator in :vpn via adb (debug APK)
|
||||
# MODE=upload — POST synthetic vpn-process crash JSON to BE upload API
|
||||
# MODE=both — device then verify BE (default when adb device present)
|
||||
#
|
||||
# Env:
|
||||
# COUNT=70
|
||||
# BASE=https://apps.f0xx.org/app/androidcast_project/crashes
|
||||
# RUN_ID=YYYYMMDD_HHMMSS (batch tag for Issues search)
|
||||
# PACKAGE=com.foxx.androidcast
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COUNT="${COUNT:-70}"
|
||||
BASE="${BASE:-https://apps.f0xx.org/app/androidcast_project/crashes}"
|
||||
UPLOAD_URL="$BASE/api/upload.php"
|
||||
PACKAGE="${PACKAGE:-com.foxx.androidcast}"
|
||||
RUN_ID="${RUN_ID:-$(date -u +%Y%m%d_%H%M%S)}"
|
||||
MODE="${MODE:-auto}"
|
||||
JAR="${JAR:-/tmp/ac_vpn_crash_cookies.txt}"
|
||||
DELAY_MS="${DELAY_MS:-80}"
|
||||
VERIFY_LOGIN="${VERIFY_LOGIN:-1}"
|
||||
|
||||
log() { printf '[vpn-crash-sim] %s\n' "$*"; }
|
||||
|
||||
has_device() {
|
||||
adb devices 2>/dev/null | awk 'NR>1 && $2=="device" { found=1 } END { exit !found }'
|
||||
}
|
||||
|
||||
simulate_device() {
|
||||
local n="$1"
|
||||
local i seq
|
||||
log "device: triggering $n VPN-process crash recordings via $PACKAGE"
|
||||
for ((i = 1; i <= n; i++)); do
|
||||
seq="$i"
|
||||
adb shell am start-service \
|
||||
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
|
||||
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
|
||||
--ei sim_seq "$seq" >/dev/null 2>&1 \
|
||||
|| adb shell am startservice \
|
||||
-n "$PACKAGE/com.foxx.androidcast.vpn.AndroidCastVpnService" \
|
||||
-a com.foxx.androidcast.vpn.SIMULATE_CRASH \
|
||||
--ei sim_seq "$seq" >/dev/null 2>&1 \
|
||||
|| true
|
||||
if [[ "$DELAY_MS" -gt 0 ]]; then
|
||||
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
|
||||
fi
|
||||
if (( i % 10 == 0 )); then
|
||||
log " device progress $i/$n"
|
||||
fi
|
||||
done
|
||||
log "device: done — waiting for crash watcher upload sweep"
|
||||
sleep 15
|
||||
}
|
||||
|
||||
upload_one() {
|
||||
local seq="$1"
|
||||
local report_id="vpn_sim_${RUN_ID}_$(printf '%03d' "$seq")"
|
||||
local ms
|
||||
ms="$(date +%s)000"
|
||||
local fp
|
||||
fp="$(printf 'vpn_sim_%s_%03d' "$RUN_ID" "$seq" | sha256sum | awk '{print $1}')"
|
||||
local body
|
||||
body="$(cat <<EOF
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report_id": "$report_id",
|
||||
"generated_at_epoch_ms": $ms,
|
||||
"crash_type": "java",
|
||||
"scenario": "vpn_service",
|
||||
"process": "com.foxx.androidcast:vpn",
|
||||
"fingerprint": "$fp",
|
||||
"tags": ["vpn_sim", "$RUN_ID"],
|
||||
"device": {
|
||||
"manufacturer": "SimLab",
|
||||
"brand": "AndroidCast",
|
||||
"model": "VpnCrashSim",
|
||||
"device": "vpn_sim",
|
||||
"product": "vpn_sim",
|
||||
"sdk_int": 34,
|
||||
"release": "14",
|
||||
"abis": ["arm64-v8a"]
|
||||
},
|
||||
"app": {
|
||||
"package": "$PACKAGE",
|
||||
"version_name": "0.1.0",
|
||||
"version_code": 100
|
||||
},
|
||||
"build": { "debug": true, "git_commit": "vpn_sim" },
|
||||
"runtime_context": { "vpn_sim_seq": $seq, "run_id": "$RUN_ID" },
|
||||
"java": {
|
||||
"thread": "androidcast-vpn-worker",
|
||||
"exception": "java.lang.RuntimeException",
|
||||
"message": "VpnCrashSimulation seq=$seq proc=com.foxx.androidcast:vpn",
|
||||
"stack_frames": [
|
||||
"com.foxx.androidcast.vpn.WireGuardVpnEngine.apply(WireGuardVpnEngine.java:40)",
|
||||
"com.foxx.androidcast.vpn.AndroidCastVpnService\$1.applyWireGuardConfig(AndroidCastVpnService.java:60)",
|
||||
"com.foxx.androidcast.vpn.AndroidCastVpnService.onStartCommand(AndroidCastVpnService.java:95)",
|
||||
"android.app.ActivityThread.handleServiceArgs(ActivityThread.java:5001)"
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
curl -sk -w '\n%{http_code}' -H 'Content-Type: application/json' --data-binary "$body" "$UPLOAD_URL"
|
||||
}
|
||||
|
||||
simulate_upload() {
|
||||
local n="$1"
|
||||
local ok=0 fail=0 dup=0
|
||||
log "upload: posting $n vpn-process crash reports to $UPLOAD_URL (run_id=$RUN_ID)"
|
||||
local i resp code
|
||||
for ((i = 1; i <= n; i++)); do
|
||||
resp="$(upload_one "$i")"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
case "$code" in
|
||||
200) ((ok++)) || true ;;
|
||||
409) ((dup++)) || true ;;
|
||||
*) ((fail++)) || true; log " FAIL seq=$i HTTP $code" ;;
|
||||
esac
|
||||
if (( i % 10 == 0 )); then
|
||||
log " upload progress $i/$n (ok=$ok dup=$dup fail=$fail)"
|
||||
fi
|
||||
if [[ "$DELAY_MS" -gt 0 ]]; then
|
||||
sleep "$(awk "BEGIN { print $DELAY_MS / 1000 }")"
|
||||
fi
|
||||
done
|
||||
log "upload: ok=$ok duplicate=$dup failed=$fail / $n"
|
||||
[[ "$fail" -eq 0 ]]
|
||||
}
|
||||
|
||||
verify_be() {
|
||||
if [[ "$VERIFY_LOGIN" != "1" ]]; then
|
||||
log "verify: skipped (VERIFY_LOGIN=$VERIFY_LOGIN)"
|
||||
return 0
|
||||
fi
|
||||
log "verify: login + search Issues for run_id=$RUN_ID"
|
||||
curl -sk -c "$JAR" -b "$JAR" -L -X POST "$BASE/login" \
|
||||
-d 'username=admin&password=admin' -o /dev/null || true
|
||||
local json
|
||||
local q="${QUERY:-$RUN_ID}"
|
||||
json="$(curl -sk -b "$JAR" "$BASE/api/reports.php?q=${q}&per_page=200")"
|
||||
local cnt
|
||||
cnt="$(echo "$json" | python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
items=d.get('items') or []
|
||||
print(len(items))
|
||||
" 2>/dev/null || echo 0)"
|
||||
log "verify: BE reports matching q=$q → $cnt (expected up to $COUNT)"
|
||||
if [[ "$cnt" -lt 1 ]]; then
|
||||
log "verify: WARN — Issues API returned 0 (login cookie may be invalid in automation)"
|
||||
log "verify: open $BASE/?view=reports&q=$q in browser when logged in"
|
||||
return 1
|
||||
fi
|
||||
echo "$json" | python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
for it in (d.get('items') or [])[:3]:
|
||||
p=it.get('payload') or {}
|
||||
j=p.get('java') or {}
|
||||
print(' sample:', it.get('report_id'), 'process=', p.get('process'), 'frames=', len(j.get('stack_frames') or []))
|
||||
" 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "auto" ]]; then
|
||||
if has_device; then
|
||||
MODE=both
|
||||
else
|
||||
MODE=upload
|
||||
log "no adb device — using upload-only mode"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
device)
|
||||
simulate_device "$COUNT"
|
||||
;;
|
||||
upload)
|
||||
simulate_upload "$COUNT"
|
||||
verify_be || true
|
||||
;;
|
||||
both)
|
||||
simulate_device "$COUNT"
|
||||
QUERY="${QUERY:-VpnCrashSimulation}" verify_be || true
|
||||
;;
|
||||
*)
|
||||
echo "unknown MODE=$MODE (device|upload|both)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log "complete run_id=$RUN_ID — Issues: $BASE/?view=reports&q=$RUN_ID"
|
||||
112
test_header_lib.py
Executable file
112
test_header_lib.py
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for header_lib (run: python3 scripts/test_header_lib.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from header_lib import (
|
||||
apply_header_to_content,
|
||||
compute_digest,
|
||||
format_identity,
|
||||
has_valid_header,
|
||||
is_excluded_path,
|
||||
parse_identity,
|
||||
render_header,
|
||||
resolve_header_contributor,
|
||||
strip_existing_header,
|
||||
build_meta,
|
||||
)
|
||||
from pathlib import Path
|
||||
import header_lib
|
||||
|
||||
|
||||
class HeaderLibTest(unittest.TestCase):
|
||||
def test_strip_and_apply_java(self) -> None:
|
||||
src = """package com.example;
|
||||
|
||||
public class Foo {
|
||||
}
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "Foo.java"
|
||||
path.write_text(src, encoding="utf-8")
|
||||
out = apply_header_to_content(path, src)
|
||||
self.assertTrue(has_valid_header(out))
|
||||
self.assertIn("package com.example;", out)
|
||||
self.assertIn(" * class Foo", out)
|
||||
self.assertIn("Digest: SHA256", out)
|
||||
self.assertIn("Cursor Agent", out)
|
||||
body, _ = strip_existing_header(out, ".java")
|
||||
self.assertIn("public class Foo", body)
|
||||
self.assertNotIn("package com.example", body.split("public class")[0])
|
||||
|
||||
def test_php_header_wrapped_in_comment(self) -> None:
|
||||
src = "<?php\ndeclare(strict_types=1);\n"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "bootstrap.php"
|
||||
path.write_text(src, encoding="utf-8")
|
||||
out = apply_header_to_content(path, src)
|
||||
self.assertTrue(out.startswith("<?php\n/*\n"))
|
||||
self.assertIn("*/\ndeclare(strict_types=1)", out)
|
||||
self.assertNotIn("?>\n<?php", out)
|
||||
self.assertNotIn("/*********************************************************************", out)
|
||||
body, _ = strip_existing_header(out, ".php")
|
||||
self.assertTrue(body.startswith("declare(strict_types=1)"))
|
||||
|
||||
def test_php_html_template(self) -> None:
|
||||
src = "<!DOCTYPE html>\n<html></html>\n"
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "page.php"
|
||||
path.write_text(src, encoding="utf-8")
|
||||
out = apply_header_to_content(path, src)
|
||||
self.assertIn("?>\n<!DOCTYPE", out)
|
||||
self.assertNotIn("<?php\n<!DOCTYPE", out)
|
||||
|
||||
|
||||
def test_commit_excluded_from_digest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "X.java"
|
||||
path.write_text("package p;\npublic class X {}\n", encoding="utf-8")
|
||||
body, _ = strip_existing_header(
|
||||
apply_header_to_content(path, path.read_text()), ".java")
|
||||
meta = build_meta(path, body, path.read_text())
|
||||
meta.commit_sha = "aaa"
|
||||
d1 = compute_digest(render_header(meta, None, ".java", for_digest=True), body)
|
||||
meta.commit_sha = "bbb"
|
||||
d2 = compute_digest(render_header(meta, None, ".java", for_digest=True), body)
|
||||
self.assertEqual(d1, d2)
|
||||
|
||||
def test_format_identity(self) -> None:
|
||||
self.assertEqual("Ada <a@b.c>", format_identity("Ada", "a@b.c"))
|
||||
self.assertEqual("Ada", format_identity("Ada", ""))
|
||||
name, mail = parse_identity("Ada <a@b.c>")
|
||||
self.assertEqual(("Ada", "a@b.c"), (name, mail))
|
||||
|
||||
def test_excluded_third_party(self) -> None:
|
||||
p = header_lib.REPO_ROOT / "third-party/libvpx/foo.c"
|
||||
self.assertTrue(is_excluded_path(p))
|
||||
|
||||
def test_resolve_header_contributor_env(self) -> None:
|
||||
import os
|
||||
old = os.environ.get("HEADER_CONTRIBUTOR")
|
||||
old_mail = os.environ.get("HEADER_CONTRIBUTOR_EMAIL")
|
||||
try:
|
||||
os.environ["HEADER_CONTRIBUTOR"] = "Test User"
|
||||
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
|
||||
ident = resolve_header_contributor()
|
||||
self.assertTrue(ident.startswith("Test User"))
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop("HEADER_CONTRIBUTOR", None)
|
||||
else:
|
||||
os.environ["HEADER_CONTRIBUTOR"] = old
|
||||
if old_mail is None:
|
||||
os.environ.pop("HEADER_CONTRIBUTOR_EMAIL", None)
|
||||
else:
|
||||
os.environ["HEADER_CONTRIBUTOR_EMAIL"] = old_mail
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
12
validate_opus_speex.sh
Executable file
12
validate_opus_speex.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit-test baseline for Opus/Speex negotiation and stream protection (task 3).
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
echo "== Opus/Speex + stream protection unit tests =="
|
||||
./gradlew :app:testDebugUnitTest \
|
||||
--tests 'com.foxx.androidcast.network.transport.StreamProtectionNegotiatorTest' \
|
||||
--tests 'com.foxx.androidcast.media.AudioNegotiatorTest' \
|
||||
--tests 'com.foxx.androidcast.media.codec.*' \
|
||||
"$@"
|
||||
echo "== OK =="
|
||||
34
verify-gitignore.sh
Executable file
34
verify-gitignore.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Quick check that paths required for ./rebuild.sh are not gitignored.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
required=(
|
||||
gradle/wrapper/gradle-wrapper.jar
|
||||
gradle/wrapper/gradle-wrapper.properties
|
||||
gradle/wireguard-tunnel/build.gradle
|
||||
gradlew
|
||||
third-party/README.md
|
||||
)
|
||||
|
||||
fail=0
|
||||
for path in "${required[@]}"; do
|
||||
if [[ ! -e "$path" ]]; then
|
||||
echo "MISSING on disk: $path" >&2
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
if git check-ignore -q "$path" 2>/dev/null; then
|
||||
echo "GITIGNORE BLOCKS: $path" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "OK: $path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "fix .gitignore — see header comments in .gitignore" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "verify-gitignore: all required paths visible to git"
|
||||
Reference in New Issue
Block a user