Some checks failed
Android CI / build (push) Has been cancelled
- app/src/main/baseline-prof.txt: 29 641 правило (4 344 — код приложения), записано на эмуляторе по обходу Главной, Серверов, Маршрутов и Настроек; AGP сам кладёт его в assets/dexopt/baseline.prof и переносит через R8. - scripts/generate-baseline-profile.sh: сборка build type profileGen (release без R8, подпись одноразовым ключом), обход UI через uiautomator/input, сброс профиля SIGUSR1, profman -> отсортированный HRF. - scripts/measure-startup-frames.sh: холодный старт и gfxinfo до/после, docs/PERFORMANCE.md с методом и честными цифрами (эмулятор, не телефон). - verify-project.sh проверяет файл профиля, verify-release-candidate.sh требует baseline.prof(m) в релизном APK; release build type не менялся. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
28 KiB
Shell
Executable file
588 lines
28 KiB
Shell
Executable file
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
MANIFEST="$PROJECT_ROOT/app/src/main/AndroidManifest.xml"
|
||
|
||
[[ -f "$PROJECT_ROOT/settings.gradle.kts" ]]
|
||
[[ -f "$PROJECT_ROOT/app/build.gradle.kts" ]]
|
||
[[ -f "$MANIFEST" ]]
|
||
[[ -f "$PROJECT_ROOT/docs/NOTICE" ]]
|
||
[[ -f "$PROJECT_ROOT/docs/THIRD_PARTY_NOTICES.md" ]]
|
||
[[ -f "$PROJECT_ROOT/gradle/verification-metadata.xml" ]]
|
||
[[ -f "$PROJECT_ROOT/app/src/main/res/raw/sing_box_extended_license.txt" ]]
|
||
[[ -f "$PROJECT_ROOT/app/src/main/res/raw/sing_geoip_license.txt" ]]
|
||
[[ -f "$PROJECT_ROOT/audit/rule_set_performance_test.go" ]]
|
||
[[ -f "$PROJECT_ROOT/scripts/core-patchset.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/publish-local-stable.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/publish-forgejo-stable.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/test-publish-forgejo-stable.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/verify-release-bundle.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/derive-test-version.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/build-test-bundle.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/create-test-bundle.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/verify-test-bundle.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/publish-forgejo-test.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/check-libbox-fingerprint.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/ensure-libbox.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/test-libbox-fingerprint.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/generate-baseline-profile.sh" ]]
|
||
[[ -x "$PROJECT_ROOT/scripts/measure-startup-frames.sh" ]]
|
||
BASELINE_PROFILE="$PROJECT_ROOT/app/src/main/baseline-prof.txt"
|
||
[[ -s "$BASELINE_PROFILE" ]]
|
||
# Файл детерминирован: отсортирован, без повторов, только правила HRF (флаги HSP + дескриптор).
|
||
LC_ALL=C sort -c -u "$BASELINE_PROFILE"
|
||
if grep -Evq '^[HSP]*L[A-Za-z0-9_$/-]+;(->.+)?$' "$BASELINE_PROFILE"; then
|
||
echo "app/src/main/baseline-prof.txt contains a line that is not an HRF rule" >&2
|
||
exit 1
|
||
fi
|
||
(( $(grep -c -E '^[HSP]*Lio/github/zapretkvn/' "$BASELINE_PROFILE") >= 500 ))
|
||
# profileGen нужен только для записи профиля и не должен попадать в выпуск.
|
||
grep -Fq 'create("profileGen")' "$PROJECT_ROOT/app/build.gradle.kts"
|
||
if grep -Fq 'profileGen' "$PROJECT_ROOT/scripts/build-apk-matrix.sh" \
|
||
"$PROJECT_ROOT/scripts/create-release-bundle.sh" \
|
||
"$PROJECT_ROOT/scripts/publish-local-stable.sh"; then
|
||
echo "profileGen must not take part in release builds" >&2
|
||
exit 1
|
||
fi
|
||
grep -Fq 'scripts/ensure-libbox.sh' "$PROJECT_ROOT/app/build.gradle.kts"
|
||
[[ -f "$PROJECT_ROOT/release.properties" ]]
|
||
source "$PROJECT_ROOT/core.properties"
|
||
source "$PROJECT_ROOT/scripts/core-patchset.sh"
|
||
verify_core_patchset "$PROJECT_ROOT"
|
||
"$PROJECT_ROOT/scripts/test-libbox-fingerprint.sh"
|
||
[[ -f "$PROJECT_ROOT/scripts/gate8-performance-summary.jq" ]]
|
||
|
||
RELEASE_WORKFLOW="$PROJECT_ROOT/.forgejo/workflows/release-verify.yml"
|
||
grep -Fq 'workflow_dispatch:' "$RELEASE_WORKFLOW"
|
||
grep -Fq 'scripts/ci-build.sh' "$RELEASE_WORKFLOW"
|
||
grep -Fq 'scripts/verify-release-bundle.sh' "$RELEASE_WORKFLOW"
|
||
grep -Fq 'git.zapret.moe/api/v1/repos/' "$RELEASE_WORKFLOW"
|
||
if grep -Fq 'release create' "$RELEASE_WORKFLOW" ||
|
||
grep -Fq 'ANDROID_SIGNING_KEYSTORE_BASE64' "$RELEASE_WORKFLOW" ||
|
||
grep -Fq 'secrets.ANDROID_SIGNING' "$RELEASE_WORKFLOW"; then
|
||
echo "Background release verification must not publish or access the production key" >&2
|
||
exit 1
|
||
fi
|
||
if grep -Eq '^[[:space:]]+push:' "$RELEASE_WORKFLOW"; then
|
||
echo "Stable verification must be dispatched by the local publisher" >&2
|
||
exit 1
|
||
fi
|
||
LOCAL_PUBLISHER="$PROJECT_ROOT/scripts/publish-local-stable.sh"
|
||
grep -Fq -- '--final-gate-approved' "$LOCAL_PUBLISHER"
|
||
grep -Fq 'scripts/publish-forgejo-stable.sh' "$LOCAL_PUBLISHER"
|
||
grep -Fq 'actions/workflows/release-verify.yml/dispatches' "$LOCAL_PUBLISHER"
|
||
grep -Fq 'scripts/ci-build.sh' "$LOCAL_PUBLISHER"
|
||
grep -Fq 'scripts/verify-release-bundle.sh' "$LOCAL_PUBLISHER"
|
||
FORGEJO_PUBLISHER="$PROJECT_ROOT/scripts/publish-forgejo-stable.sh"
|
||
grep -Fq '/api/v1' "$FORGEJO_PUBLISHER"
|
||
grep -Fq 'draft:true,prerelease:false' "$FORGEJO_PUBLISHER"
|
||
grep -Fq 'draft:false,prerelease:false' "$FORGEJO_PUBLISHER"
|
||
grep -Fq 'sha256sum "$downloaded"' "$FORGEJO_PUBLISHER"
|
||
TEST_RELEASE_WORKFLOW="$PROJECT_ROOT/.forgejo/workflows/test-release-verify.yml"
|
||
grep -Fq 'workflow_dispatch:' "$TEST_RELEASE_WORKFLOW"
|
||
grep -Fq 'scripts/verify-test-bundle.sh' "$TEST_RELEASE_WORKFLOW"
|
||
grep -Eq '(^|[[:space:]])testDebugUnitTest' "$TEST_RELEASE_WORKFLOW"
|
||
TEST_PUBLISHER="$PROJECT_ROOT/scripts/publish-forgejo-test.sh"
|
||
grep -Fq 'draft:true,prerelease:true' "$TEST_PUBLISHER"
|
||
grep -Fq 'draft:false,prerelease:true' "$TEST_PUBLISHER"
|
||
grep -Fq 'sha256sum "$downloaded"' "$TEST_PUBLISHER"
|
||
grep -Fq 'actions/workflows/test-release-verify.yml/dispatches' "$TEST_PUBLISHER"
|
||
"$PROJECT_ROOT/scripts/test-publish-forgejo-stable.sh"
|
||
source "$PROJECT_ROOT/release.properties"
|
||
if [[ ! "$RELEASE_SIGNER_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
|
||
echo "Invalid public production signing fingerprint" >&2
|
||
exit 1
|
||
fi
|
||
|
||
cmp -s "$PROJECT_ROOT/docs/LICENSE" "$PROJECT_ROOT/app/src/main/res/raw/license_gpl_3.txt"
|
||
cmp -s "$PROJECT_ROOT/docs/NOTICE" "$PROJECT_ROOT/app/src/main/res/raw/notice.txt"
|
||
cmp -s "$PROJECT_ROOT/docs/THIRD_PARTY_NOTICES.md" "$PROJECT_ROOT/app/src/main/res/raw/third_party_notices.txt"
|
||
|
||
WRAPPER_PROPERTIES="$PROJECT_ROOT/gradle/wrapper/gradle-wrapper.properties"
|
||
grep -Fqx 'distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip' "$WRAPPER_PROPERTIES"
|
||
grep -Fqx 'distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb' "$WRAPPER_PROPERTIES"
|
||
printf '%s %s\n' \
|
||
'55243ef57851f12b070ad14f7f5bb8302daceeebc5bce5ece5fa6edb23e1145c' \
|
||
"$PROJECT_ROOT/gradle/wrapper/gradle-wrapper.jar" \
|
||
| sha256sum -c - >/dev/null
|
||
|
||
INVALID_ACTIONS="$(
|
||
sed -n 's/^[[:space:]]*uses:[[:space:]]*//p' "$PROJECT_ROOT"/.forgejo/workflows/*.yml \
|
||
| grep -Ev '^https://data\.forgejo\.org/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^[:space:]]+$' || true
|
||
)"
|
||
if [[ -n "$INVALID_ACTIONS" ]]; then
|
||
echo "Forgejo Actions must use explicit trusted action URLs:" >&2
|
||
printf '%s\n' "$INVALID_ACTIONS" >&2
|
||
exit 1
|
||
fi
|
||
|
||
mapfile -t ANDROID_MODULES < <(
|
||
find "$PROJECT_ROOT" -mindepth 2 -maxdepth 2 -name build.gradle.kts -printf '%h\n' \
|
||
| sed "s|$PROJECT_ROOT/||" \
|
||
| sort
|
||
)
|
||
EXPECTED_ANDROID_MODULES=(app app-updater network-bootstrap wireguard-import)
|
||
if [[ "${ANDROID_MODULES[*]}" != "${EXPECTED_ANDROID_MODULES[*]}" ]]; then
|
||
echo "Unexpected Android module set: ${ANDROID_MODULES[*]:-none}" >&2
|
||
exit 1
|
||
fi
|
||
if [[ "$(grep -l 'com.android.application' "$PROJECT_ROOT"/*/build.gradle.kts | wc -l)" -ne 1 ]] ||
|
||
! grep -Fq 'com.android.application' "$PROJECT_ROOT/app/build.gradle.kts"; then
|
||
echo "Exactly app must be the Android application module" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if grep -Eq 'WAKE_LOCK|REQUEST_IGNORE_BATTERY_OPTIMIZATIONS|android:process=' "$MANIFEST"; then
|
||
echo "Manifest contains a forbidden permission/process" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Every production Kotlin source root: :app, :app-updater, the :core:* libraries and :feature:* modules.
|
||
SCAN_ROOTS=("$PROJECT_ROOT/app/src/main/java" "$PROJECT_ROOT/app-updater/src/main/java")
|
||
for module_dir in "$PROJECT_ROOT"/core/*/ "$PROJECT_ROOT"/feature/*/; do
|
||
[[ -d "${module_dir}src/main/java" ]] && SCAN_ROOTS+=("${module_dir}src/main/java")
|
||
done
|
||
|
||
# Module boundary: :feature:* hold screens and presenters only. They may use :core:* and androidx,
|
||
# never :app, the VPN runtime, ViewModels or the dependency containers (Route files in :app bind them).
|
||
for build_file in "$PROJECT_ROOT"/feature/*/build.gradle.kts "$PROJECT_ROOT"/core/*/build.gradle.kts; do
|
||
if grep -Eq 'project\(":(app|app-updater|network-bootstrap|wireguard-import|feature:[a-z]+)"\)' "$build_file"; then
|
||
echo "$build_file: :core:* and :feature:* modules must not depend on :app or on each other's features" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
for build_file in "$PROJECT_ROOT"/core/*/build.gradle.kts; do
|
||
if grep -Eq 'project\(":feature:' "$build_file"; then
|
||
echo "$build_file: :core:* modules must not depend on :feature:*" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
if [[ -n "$(find "$PROJECT_ROOT/feature" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)" ]]; then
|
||
if grep -R -n -E '^import io\.github\.zapretkvn\.android\.(vpn\.runtime|engines|platform|importer\.(ProfileImporter|SubscriptionClient|ImportParser)\b|profiles\.(ProfilesViewModel|ProfileStore)|[A-Za-z.]*ViewModel|ui\.(AppContainer|AppDependencies|AppNavigation|AppRoot|UiSettingsStore)|AppContainer|BuildConfig|R$)' \
|
||
"$PROJECT_ROOT"/feature/*/src; then
|
||
echo "Feature modules must not import the VPN runtime, ViewModels, AppContainer or AppDependencies" >&2
|
||
exit 1
|
||
fi
|
||
if grep -R -n -E '\b(AppDependencies|AppContainer|VpnController|[A-Za-z]+ViewModel)\b' --include=*.kt \
|
||
"$PROJECT_ROOT"/feature/*/src/main; then
|
||
echo "Feature modules must not reference ViewModels, VpnController or the dependency containers" >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if grep -R -E 'import android\.util\.Log|\bLog\.[vdiewtf]\(|printStackTrace\(|System\.(out|err)' \
|
||
"${SCAN_ROOTS[@]}"; then
|
||
echo "Production source must not write credentials or runtime details to Logcat/stdout" >&2
|
||
exit 1
|
||
fi
|
||
if grep -R -E 'addPrimaryClipChangedListener|setPrimaryClip\(' \
|
||
"${SCAN_ROOTS[@]}"; then
|
||
echo "Application clipboard history/listeners are forbidden; clipboard import is read-on-action only" >&2
|
||
exit 1
|
||
fi
|
||
if grep -R -E 'android\.os\.PowerManager|android\.app\.AlarmManager|androidx\.work|ScheduledExecutorService|scheduleAtFixedRate|scheduleWithFixedDelay' \
|
||
"${SCAN_ROOTS[@]}"; then
|
||
echo "Production source contains an app-owned wake/alarm/work/periodic scheduler" >&2
|
||
exit 1
|
||
fi
|
||
# The only app-owned scheduler is the platform JobScheduler daily update check (no wakelock, no alarm,
|
||
# no WorkManager, not persisted across reboot because RECEIVE_BOOT_COMPLETED is not requested).
|
||
JOB_SCHEDULER_USERS="$(
|
||
grep -R -l 'android\.app\.job\.JobScheduler' "${SCAN_ROOTS[@]}" || true
|
||
)"
|
||
if [[ "$JOB_SCHEDULER_USERS" != "$PROJECT_ROOT/app/src/main/java/io/github/zapretkvn/android/updates/UpdateCheckJob.kt" ]]; then
|
||
echo "JobScheduler may only be used by the daily update check (updates/UpdateCheckJob.kt): $JOB_SCHEDULER_USERS" >&2
|
||
exit 1
|
||
fi
|
||
if grep -R -F 'CommandConnections' "$PROJECT_ROOT/app/src/main/java"; then
|
||
echo "Production runtime must not subscribe to per-connection polling" >&2
|
||
exit 1
|
||
fi
|
||
|
||
python3 - "$PROJECT_ROOT" <<'PY'
|
||
from pathlib import Path
|
||
import sys
|
||
|
||
root = Path(sys.argv[1])
|
||
source_root = root / "app/src/main/java"
|
||
module_roots = [
|
||
source_root,
|
||
*sorted(root.glob("core/*/src/main/java")),
|
||
*sorted(root.glob("feature/*/src/main/java")),
|
||
]
|
||
for module_root in module_roots:
|
||
for source in module_root.rglob("*.kt"):
|
||
declaration = next((line for line in source.read_text().splitlines() if line.startswith("package ")), "")
|
||
expected_package = ".".join(source.relative_to(module_root).parts[:-1])
|
||
if declaration != "package " + expected_package:
|
||
raise SystemExit(f"Kotlin package/path mismatch: {source.relative_to(root)}")
|
||
vpn_root = source_root / "io/github/zapretkvn/android/vpn"
|
||
if {path.name for path in vpn_root.glob("*.kt")} != {
|
||
"ZapretVpnService.kt", "ZapretQuickSettingsTileService.kt",
|
||
"VpnController.kt", "VpnRecoveryPolicy.kt",
|
||
}:
|
||
raise SystemExit("Keep VPN lifecycle in vpn/; place adapters, probes and app scope in their packages")
|
||
delays = []
|
||
for source in (
|
||
root / "app/src/main/java",
|
||
root / "app-updater/src/main/java",
|
||
root / "network-bootstrap/src/main/java",
|
||
*sorted(root.glob("core/*/src/main/java")),
|
||
*sorted(root.glob("feature/*/src/main/java")),
|
||
):
|
||
for path in source.rglob("*.kt"):
|
||
count = path.read_text().count("delay(")
|
||
if count:
|
||
delays.append((str(path.relative_to(root)), count))
|
||
delays.sort()
|
||
expected = [
|
||
# Случайная пауза 0,15–1,2 с между разовыми DoH-запросами страны сервера внутри
|
||
# одной пачки (не больше 3 параллельно): запросы не идут залпом. Пачка стартует
|
||
# только после импорта/обновления профиля или при запуске; повторов и циклов нет.
|
||
("app/src/main/java/io/github/zapretkvn/android/network/geo/ServerCountryStore.kt", 1),
|
||
("app/src/main/java/io/github/zapretkvn/android/network/probes/HealthProbeRace.kt", 1),
|
||
# Разовый случайный сдвиг старта ICMP-замера (ICMP_START_JITTER_MILLIS):
|
||
# воркеры не шлют эхо-запросы одним залпом. Не повтор и не цикл ожидания.
|
||
("app/src/main/java/io/github/zapretkvn/android/network/probes/LatencyProbeCoordinator.kt", 1),
|
||
# Проверка доступности сервисов: единственная пауза probeSleep(). Ею делаются случайный сдвиг
|
||
# старта запросов (0-400 мс), единственный повтор основного запроса через туннель (1,5-3 с)
|
||
# и разовая пауза автозапуска после подключения (4-12 с). Внутри одного запуска, без цикла и
|
||
# без периодического таймера; повторных запусков по времени нет.
|
||
("app/src/main/java/io/github/zapretkvn/android/network/probes/services/ServiceProbeRunner.kt", 1),
|
||
# Пауза после ввода в редакторе JSON перед разбором текста (selectors, серверы,
|
||
# совпадения поиска): один Job на редактор, предыдущий отменяется. Только UI.
|
||
("app/src/main/java/io/github/zapretkvn/android/profiles/ProfileEditorController.kt", 1),
|
||
# Прореживание перерисовки списка серверов (не чаще 4 раз в секунду) во
|
||
# время пачек замеров. Только UI: сетевых запросов и повторов не создаёт.
|
||
("app/src/main/java/io/github/zapretkvn/android/ui/servers/ServersRoute.kt", 1),
|
||
# Единый дебаунс смены сети (перезапуск, пауза и возобновление по правилам
|
||
# сети) плюс ограниченный backoff восстановления. Одновременно активен
|
||
# только один debounce Job; ожидание сети событийное и таймера не добавляет.
|
||
("app/src/main/java/io/github/zapretkvn/android/vpn/runtime/VpnRuntime.kt", 2),
|
||
# Видимое время сессии на Главной: тикает, только пока таймер на экране.
|
||
("feature/home/src/main/java/io/github/zapretkvn/android/ui/home/HomeScreen.kt", 1),
|
||
# Подпись «проверено 2 мин назад» на карточке сервисов обновляется раз в 30 с, пока карточка
|
||
# на экране. Только текст: сетевых запросов и запуска проверок не создаёт.
|
||
("feature/home/src/main/java/io/github/zapretkvn/android/ui/home/ServicesCard.kt", 1),
|
||
("network-bootstrap/src/main/java/io/github/zapretkvn/networkbootstrap/UnderlyingNetworkMonitor.kt", 1),
|
||
]
|
||
if delays != expected:
|
||
raise SystemExit(f"Unexpected production timer/retry surface: {delays!r}")
|
||
print(
|
||
"Production delay calls are limited to visible session time, server-list redraw throttle, probe stagger, "
|
||
"service-check pauses, card age label, "
|
||
"network debounce, bounded recovery backoff and bootstrap settle."
|
||
)
|
||
PY
|
||
|
||
python3 - "$MANIFEST" "$PROJECT_ROOT/app/src/main/res/xml/diagnostic_file_paths.xml" <<'PY'
|
||
from pathlib import Path
|
||
import sys
|
||
import xml.etree.ElementTree as ET
|
||
|
||
android = "{http://schemas.android.com/apk/res/android}"
|
||
manifest_path, paths_path = map(Path, sys.argv[1:])
|
||
root = ET.parse(manifest_path).getroot()
|
||
application = root.find("application")
|
||
if application is None:
|
||
raise SystemExit("Manifest has no application")
|
||
if application.get(android + "usesCleartextTraffic") != "false":
|
||
raise SystemExit("Application must explicitly reject platform cleartext traffic")
|
||
source_permissions = {node.get(android + "name") for node in root.findall("uses-permission")}
|
||
expected_source_permissions = {
|
||
"android.permission.INTERNET",
|
||
"android.permission.ACCESS_NETWORK_STATE",
|
||
"android.permission.ACCESS_WIFI_STATE",
|
||
"android.permission.ACCESS_COARSE_LOCATION",
|
||
"android.permission.ACCESS_FINE_LOCATION",
|
||
"android.permission.FOREGROUND_SERVICE",
|
||
"android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED",
|
||
"android.permission.POST_NOTIFICATIONS",
|
||
"android.permission.REQUEST_INSTALL_PACKAGES",
|
||
"android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION",
|
||
"android.permission.CAMERA",
|
||
"android.permission.QUERY_ALL_PACKAGES",
|
||
}
|
||
if source_permissions != expected_source_permissions:
|
||
raise SystemExit(
|
||
f"Source manifest permission allowlist mismatch: "
|
||
f"{sorted(source_permissions ^ expected_source_permissions)}"
|
||
)
|
||
queries = root.find("queries")
|
||
if queries is None:
|
||
raise SystemExit("Manifest must declare launcher package visibility fallback")
|
||
query_intents = set()
|
||
for intent in queries.findall("intent"):
|
||
actions = tuple(sorted(
|
||
node.get(android + "name") for node in intent.findall("action")
|
||
))
|
||
categories = tuple(sorted(
|
||
node.get(android + "name") for node in intent.findall("category")
|
||
))
|
||
query_intents.add((actions, categories))
|
||
expected_query_intents = {
|
||
(("android.intent.action.MAIN",), ("android.intent.category.LAUNCHER",)),
|
||
(("android.intent.action.MAIN",), ("android.intent.category.LEANBACK_LAUNCHER",)),
|
||
}
|
||
if query_intents != expected_query_intents:
|
||
raise SystemExit(
|
||
f"Launcher package visibility queries mismatch: "
|
||
f"{sorted(query_intents ^ expected_query_intents)}"
|
||
)
|
||
profileable = application.findall("profileable")
|
||
if len(profileable) != 1 or profileable[0].get(android + "shell") != "true":
|
||
raise SystemExit("Release-gate profiling requires exactly one shell-profileable declaration")
|
||
|
||
vpn_services = [
|
||
node for node in application.findall("service")
|
||
if node.get(android + "permission") == "android.permission.BIND_VPN_SERVICE"
|
||
]
|
||
if len(vpn_services) != 1:
|
||
raise SystemExit(f"Expected exactly one VPN service, found {len(vpn_services)}")
|
||
vpn_service = vpn_services[0]
|
||
if vpn_service.get(android + "exported") != "false":
|
||
raise SystemExit("VPN service must not be exported")
|
||
always_on_metadata = [
|
||
node for node in vpn_service.findall("meta-data")
|
||
if node.get(android + "name") == "android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||
]
|
||
if len(always_on_metadata) != 1 or always_on_metadata[0].get(android + "value") != "false":
|
||
raise SystemExit("VPN service must explicitly opt out of Always-on")
|
||
|
||
job_services = [
|
||
node for node in application.findall("service")
|
||
if node.get(android + "permission") == "android.permission.BIND_JOB_SERVICE"
|
||
]
|
||
if (
|
||
len(job_services) != 1
|
||
or job_services[0].get(android + "name") != ".updates.UpdateCheckJobService"
|
||
or job_services[0].get(android + "exported") != "false"
|
||
or job_services[0].findall("intent-filter")
|
||
):
|
||
raise SystemExit("Expected exactly one non-exported update-check JobService without intent filters")
|
||
receivers = application.findall("receiver")
|
||
if (
|
||
len(receivers) != 1
|
||
or receivers[0].get(android + "name") != ".updates.UpdateInstallReceiver"
|
||
or receivers[0].get(android + "exported") != "false"
|
||
or receivers[0].findall("intent-filter")
|
||
):
|
||
raise SystemExit("Expected exactly one non-exported install-result receiver without intent filters")
|
||
|
||
quick_settings_tiles = [
|
||
node for node in application.findall("service")
|
||
if node.get(android + "permission") == "android.permission.BIND_QUICK_SETTINGS_TILE"
|
||
]
|
||
if len(quick_settings_tiles) != 1:
|
||
raise SystemExit(
|
||
f"Expected exactly one Quick Settings tile service, found {len(quick_settings_tiles)}"
|
||
)
|
||
quick_settings_tile = quick_settings_tiles[0]
|
||
if (
|
||
quick_settings_tile.get(android + "name") != ".vpn.ZapretQuickSettingsTileService"
|
||
or quick_settings_tile.get(android + "exported") != "true"
|
||
):
|
||
raise SystemExit("Quick Settings tile must be the single exported system-bound tile service")
|
||
tile_actions = {
|
||
action.get(android + "name")
|
||
for intent_filter in quick_settings_tile.findall("intent-filter")
|
||
for action in intent_filter.findall("action")
|
||
}
|
||
if tile_actions != {"android.service.quicksettings.action.QS_TILE"}:
|
||
raise SystemExit(f"Quick Settings tile intent actions differ: {sorted(tile_actions)}")
|
||
|
||
providers = [
|
||
node for node in application.findall("provider")
|
||
if node.get(android + "name") == "androidx.core.content.FileProvider"
|
||
]
|
||
if len(providers) != 1:
|
||
raise SystemExit(f"Expected one FileProvider, found {len(providers)}")
|
||
provider = providers[0]
|
||
expected = {
|
||
android + "authorities": "${applicationId}.fileprovider",
|
||
android + "exported": "false",
|
||
android + "grantUriPermissions": "true",
|
||
}
|
||
for attribute, value in expected.items():
|
||
if provider.get(attribute) != value:
|
||
raise SystemExit(f"Unsafe FileProvider attribute {attribute}: {provider.get(attribute)!r}")
|
||
metadata = provider.findall("meta-data")
|
||
if len(metadata) != 1 or metadata[0].get(android + "resource") != "@xml/diagnostic_file_paths":
|
||
raise SystemExit("FileProvider must reference only diagnostic_file_paths")
|
||
|
||
paths = ET.parse(paths_path).getroot()
|
||
children = list(paths)
|
||
entries = {(child.tag, child.get("name"), child.get("path")) for child in children}
|
||
expected_paths = {
|
||
("cache-path", "diagnostics", "diagnostics/"),
|
||
}
|
||
if entries != expected_paths:
|
||
raise SystemExit(f"FileProvider paths differ from bounded cache paths: {entries!r}")
|
||
PY
|
||
|
||
python3 - "$PROJECT_ROOT" <<'PY'
|
||
from pathlib import Path
|
||
import hashlib
|
||
import re
|
||
import sys
|
||
|
||
root = Path(sys.argv[1])
|
||
docs_root = root / "docs"
|
||
docs = [docs_root / name for name in (
|
||
"ARCHITECTURE.md",
|
||
"DNS_ARCHITECTURE.md",
|
||
"ROUTING_ARCHITECTURE.md",
|
||
"IMPORT_FORMATS.md",
|
||
"IMPLEMENTATION_PLAN.md",
|
||
"README.md",
|
||
"LICENSING.md",
|
||
"SIGNING.md",
|
||
"GATE8_RESULTS.md",
|
||
"THIRD_PARTY_NOTICES.md",
|
||
)]
|
||
all_text = "\n".join(path.read_text() for path in docs)
|
||
fixture_root = root / "testdata"
|
||
manifest = {}
|
||
for line in (fixture_root / "SHA256SUMS").read_text().splitlines():
|
||
digest, relative = line.split(maxsplit=1)
|
||
manifest[relative] = digest
|
||
fixtures = sorted(fixture_root.rglob("*.json"))
|
||
fixture_names = {str(path.relative_to(fixture_root)) for path in fixtures}
|
||
if fixture_names != set(manifest):
|
||
raise SystemExit("Fixture set differs from testdata/SHA256SUMS")
|
||
for fixture in fixtures:
|
||
digest = hashlib.sha256(fixture.read_bytes()).hexdigest()
|
||
relative = str(fixture.relative_to(fixture_root))
|
||
if manifest[relative] != digest:
|
||
raise SystemExit(f"Fixture hash mismatch: {fixture}: {digest}")
|
||
if digest not in all_text:
|
||
raise SystemExit(f"Fixture hash is not documented: {fixture}: {digest}")
|
||
for doc in docs:
|
||
text = doc.read_text()
|
||
if len(re.findall(r"^```", text, re.MULTILINE)) % 2:
|
||
raise SystemExit(f"Unbalanced Markdown fences: {doc}")
|
||
for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text):
|
||
if "://" in target or target.startswith("#"):
|
||
continue
|
||
local = target.split("#", 1)[0]
|
||
if local and not (doc.parent / local).exists():
|
||
raise SystemExit(f"Broken local link in {doc}: {target}")
|
||
print("Markdown, local links and fixture hashes are valid.")
|
||
PY
|
||
|
||
python3 "$PROJECT_ROOT/scripts/check-release-changes.py" "$PROJECT_ROOT/scripts/release-changes.txt"
|
||
|
||
python3 - "$PROJECT_ROOT" <<'PY'
|
||
from pathlib import Path
|
||
import sys
|
||
|
||
root = Path(sys.argv[1])
|
||
surfaces = {
|
||
"README": (root / "docs/README.md").read_text(),
|
||
"UI": (root / "feature/settings/src/main/java/io/github/zapretkvn/android/ui/settings/AboutPage.kt").read_text(),
|
||
"release notes generator": (root / "scripts/create-release-bundle.sh").read_text(),
|
||
}
|
||
required = (
|
||
"Известные ограничения MVP",
|
||
"arm64-v8a",
|
||
"Always-on/Lockdown",
|
||
"shared UID",
|
||
"DoH, DoT",
|
||
"FakeIP",
|
||
"Domain-only block",
|
||
"Clash YAML",
|
||
"Hysteria v1",
|
||
"silent install",
|
||
"plaintext DNS fallback",
|
||
)
|
||
for surface, text in surfaces.items():
|
||
missing = [value for value in required if value not in text]
|
||
if missing:
|
||
raise SystemExit(f"{surface} omits known limitations: {missing!r}")
|
||
print("Known limitations are synchronized across UI, README and release notes.")
|
||
PY
|
||
|
||
python3 - "$PROJECT_ROOT" <<'PY'
|
||
from pathlib import Path
|
||
import hashlib
|
||
import json
|
||
import sys
|
||
|
||
root = Path(sys.argv[1])
|
||
asset_root = root / "app/src/main/assets/rule-sets"
|
||
manifest = json.loads((asset_root / "manifest.json").read_text())
|
||
if manifest.get("generated_with_core") != "ff11f007ec798136a5de258f947a4f34011a37ea":
|
||
raise SystemExit("Rule-set manifest core revision mismatch")
|
||
expected = {"zapret-ru-domains", "zapret-ru-ip"}
|
||
entries = manifest.get("sets", [])
|
||
if {entry["tag"] for entry in entries} != expected:
|
||
raise SystemExit("Unexpected packaged rule-set tag set")
|
||
for entry in entries:
|
||
path = asset_root / entry["file"]
|
||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||
if digest != entry["sha256"]:
|
||
raise SystemExit(f"Rule-set asset hash mismatch: {path}: {digest}")
|
||
print("Packaged rule-set manifest and hashes are valid.")
|
||
PY
|
||
|
||
python3 - "$PROJECT_ROOT" <<'PY'
|
||
from pathlib import Path
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import struct
|
||
import sys
|
||
|
||
root = Path(sys.argv[1])
|
||
asset_root = root / "app/src/main/assets/geoip"
|
||
manifest = json.loads((asset_root / "manifest.json").read_text())
|
||
blob = (asset_root / manifest["file"]).read_bytes()
|
||
if hashlib.sha256(blob).hexdigest() != manifest["sha256"] or len(blob) != manifest["bytes"]:
|
||
raise SystemExit("GeoIP country asset hash/size mismatch")
|
||
if blob[:4] != b"ZGC1" or manifest.get("format") != "ZGC1":
|
||
raise SystemExit("GeoIP country asset has an unexpected format")
|
||
if len(blob) > 3 * 1024 * 1024:
|
||
raise SystemExit("GeoIP country asset exceeds the 3 MiB budget")
|
||
if manifest.get("license") != "CC-BY-4.0" or manifest.get("attribution") != "IP Geolocation by DB-IP":
|
||
raise SystemExit("GeoIP manifest lost the DB-IP license/attribution")
|
||
if not re.fullmatch(r"\d{4}-\d{2}", manifest["month"]) or not manifest["source"].endswith(
|
||
f"dbip-country-lite-{manifest['month']}.csv.gz"
|
||
):
|
||
raise SystemExit("GeoIP manifest source/month mismatch")
|
||
month, count = struct.unpack_from(">IB", blob, 4)
|
||
if month != int(manifest["month"].replace("-", "")) or count != manifest["countries"]:
|
||
raise SystemExit("GeoIP asset header disagrees with its manifest")
|
||
notices = (root / "docs/THIRD_PARTY_NOTICES.md").read_text()
|
||
for required in ("IP Geolocation by DB-IP", "https://db-ip.com", "CC BY 4.0"):
|
||
if required not in notices:
|
||
raise SystemExit(f"Third-party notices omit the DB-IP attribution: {required!r}")
|
||
print("Packaged GeoIP country asset, manifest and attribution are valid.")
|
||
PY
|
||
|
||
PERFORMANCE_POLICY="$PROJECT_ROOT/scripts/gate8-performance-summary.jq"
|
||
POLICY_RESULT="$(
|
||
printf '%s\n' \
|
||
'{"scenario":"stack_current_mixed","cpu_ticks":100,"elapsed_nanos":1000000000,"pss_kb":100,"rss_kb":100,"tun_bytes":100,"throughput_mbps":100}' \
|
||
'{"scenario":"stack_system","cpu_ticks":104,"elapsed_nanos":1040000000,"pss_kb":104,"rss_kb":104,"tun_bytes":104,"throughput_mbps":104}' \
|
||
'{"scenario":"mtu_default_9000","cpu_ticks":100,"elapsed_nanos":1000000000,"pss_kb":100,"rss_kb":100,"tun_bytes":100,"throughput_mbps":100}' \
|
||
'{"scenario":"mtu_1500","cpu_ticks":106,"elapsed_nanos":1060000000,"pss_kb":106,"rss_kb":106,"tun_bytes":106,"throughput_mbps":106}' \
|
||
| jq -s --argjson threshold 5 -f "$PERFORMANCE_POLICY"
|
||
)"
|
||
BELOW_THRESHOLD_DECISION="$(
|
||
jq -r '.comparisons[] | select(.name == "mixed_vs_system") | .decision' <<<"$POLICY_RESULT"
|
||
)"
|
||
ABOVE_THRESHOLD_DECISION="$(
|
||
jq -r '.comparisons[] | select(.name == "mtu_default_vs_1500") | .decision' <<<"$POLICY_RESULT"
|
||
)"
|
||
if [[ "$BELOW_THRESHOLD_DECISION" != "NO_CHANGE_BELOW_5_PERCENT_OR_NO_SIGNAL" ]]; then
|
||
echo "Performance policy accepted a sub-5% change" >&2
|
||
exit 1
|
||
fi
|
||
if [[ "$ABOVE_THRESHOLD_DECISION" != "PHYSICAL_CONFIRMATION_REQUIRED" ]]; then
|
||
echo "Performance policy ignored a >=5% change" >&2
|
||
exit 1
|
||
fi
|
||
echo "Performance significance policy verified."
|
||
|
||
echo "Project structure verified."
|